// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.
#pragma warning disable ASPIREUSERSECRETS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.DevTunnels;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.UserSecrets;
using k8s.Models;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Retry;
namespace Aspire.Hosting.Tests.Dcp;
[Trait("Partition", "4")]
public class DcpExecutorTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task ContainersArePassedOtelServiceName()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("CustomName", "container").WithOtlpExporter();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.Equal("CustomName", container.Metadata.Annotations["otel-service-name"]);
}
[Fact]
public async Task DockerfileContainerBuildSpecIncludesPlatform()
{
using var tempDockerfileContext = await DockerfileUtils.CreateTemporaryDockerfileAsync(outputHelper);
var builder = DistributedApplication.CreateBuilder();
#pragma warning disable ASPIREPIPELINES003 // ContainerBuildOptions APIs are experimental.
builder.AddDockerfile("mycontainer", tempDockerfileContext.ContextPath, tempDockerfileContext.DockerfilePath)
.WithContainerBuildOptions(ctx => ctx.TargetPlatform = ContainerTargetPlatform.LinuxArm64);
#pragma warning restore ASPIREPIPELINES003
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.NotNull(container.Spec.Build);
Assert.Equal("linux/arm64", container.Spec.Build!.Platform);
}
[Fact]
public async Task DockerfileContainerBuildSpec_RunMode_DefaultsToHostPlatform()
{
using var tempDockerfileContext = await DockerfileUtils.CreateTemporaryDockerfileAsync(outputHelper);
var builder = DistributedApplication.CreateBuilder();
builder.AddDockerfile("mycontainer", tempDockerfileContext.ContextPath, tempDockerfileContext.DockerfilePath);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.NotNull(container.Spec.Build);
Assert.Null(container.Spec.Build!.Platform);
}
[Fact]
public async Task ResourceStarted_ProjectHasReplicas_EventRaisedOnce()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var resource = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithReplicas(2).Resource;
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
var startingEvents = new List<OnResourceStartingContext>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>((context) =>
{
startingEvents.Add(context);
return Task.CompletedTask;
});
var channel = Channel.CreateUnbounded<string>();
events.Subscribe<OnResourceChangedContext>(async (context) =>
{
if (context.Resource == resource)
{
await channel.Writer.WriteAsync(context.DcpResourceName);
}
});
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync();
var executables = GetCreatedExecutablesForResource(kubernetesService, "ServiceA");
Assert.Equal(2, executables.Count);
var e = Assert.Single(startingEvents);
Assert.Equal(resource, e.Resource);
var resourceIds = new HashSet<string>();
var watchResourceTask = Task.Run(async () =>
{
await foreach (var item in channel.Reader.ReadAllAsync())
{
resourceIds.Add(item);
if (resourceIds.Count == 2)
{
break;
}
}
});
await watchResourceTask.DefaultTimeout();
Assert.Equal(2, resourceIds.Count);
}
[Theory]
[InlineData(ExecutionType.IDE, false, null, new string[] { "--test1", "--test2" })]
[InlineData(ExecutionType.IDE, true, new string[] { "--withargs-test" }, new string[] { "--withargs-test" })]
[InlineData(ExecutionType.Process, false, new string[] { "--test1", "--test2" }, new string[] { "--test1", "--test2" })]
[InlineData(ExecutionType.Process, true, new string[] { "--", "--test1", "--test2", "--withargs-test" }, new string[] { "--", "--test1", "--test2", "--withargs-test" })]
public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAdded(string executionType, bool addAppHostArgs, string[]? expectedArgs, string[]? expectedAnnotations)
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
IConfiguration? configuration = null;
if (executionType == ExecutionType.IDE)
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "8080"
});
configuration = configurationBuilder.Build();
}
var resourceBuilder = builder.AddProject<Projects.ServiceA>("ServiceA");
if (addAppHostArgs)
{
resourceBuilder
.WithArgs(c =>
{
c.Args.Add("--withargs-test");
});
}
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events, configuration: configuration);
await appExecutor.RunApplicationAsync();
var executables = GetCreatedExecutablesForResource(kubernetesService, "ServiceA");
var exe = Assert.Single(executables);
// Ignore dotnet specific args for .NET project in process execution.
var callArgs = executionType == ExecutionType.IDE ? exe.Spec.Args : exe.Spec.Args![^(expectedArgs?.Length ?? 0)..];
Assert.Equal(expectedArgs, callArgs);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(expectedAnnotations, argAnnotations.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, exe.Spec.Args);
}
[Theory]
[InlineData()]
[InlineData("--arg1", "foo")]
public async Task CreateExecutable_ToolHasCommandLineArgs_AnnotationsAdded(params string[] toolArgs)
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var resourceBuilder = builder.AddDotnetTool("tool", "package")
.WithArgs(toolArgs);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync();
var executables = kubernetesService.CreatedResources.OfType<Executable>().ToList();
var exe = Assert.Single(executables);
string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"];
string[] callArgs = [.. dotnetToolExecArgs, .. toolArgs];
Assert.Equal(callArgs, exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(toolArgs, argAnnotations.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, exe.Spec.Args);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DotnetToolResource_ExtensionMode_OwnedLaunchToolArgsAreWithheldAndRespectCommandLineVisibility(bool showInCommandLine)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddDotnetTool("tool", "package")
.WithArgs("app-arg")
.WithLaunchToolArgs(
static context =>
{
context.Args.Add("tool");
context.Args.Add("exec");
context.Args.Add("package");
context.Args.Add("--yes");
context.Args.Add("--");
},
ownedByLaunchConfigurationType: "test",
showInCommandLine: showInCommandLine)
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "tool");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"];
string[] expectedDisplayArgs = showInCommandLine ? [.. dotnetToolExecArgs, "app-arg"] : ["app-arg"];
Assert.Equal(expectedDisplayArgs, displayArgs.Select(a => a.Argument));
Assert.All(displayArgs.Take(displayArgs.Count - 1), argument => Assert.Null(argument.EffectiveArgumentIndex));
Assert.Equal(0, displayArgs[^1].EffectiveArgumentIndex);
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DotnetToolResource_ProcessMode_LaunchToolArgsReplaceBuiltInInvocation(bool showInCommandLine)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddDotnetTool("tool", "package")
.WithArgs("app-arg")
.WithArgs(static context => context.Args.Insert(0, "prepended-arg"))
.WithLaunchToolArgs(
static context =>
{
context.Args.Add("custom");
context.Args.Add("exec");
context.Args.Add("--");
},
showInCommandLine: showInCommandLine);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "tool");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal(["custom", "exec", "--", "prepended-arg", "app-arg"], exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
string[] expectedDisplayArgs = showInCommandLine
? new[] { "custom", "exec", "--", "prepended-arg", "app-arg" }
: ["prepended-arg", "app-arg"];
Assert.Equal(expectedDisplayArgs, displayArgs.Select(a => a.Argument));
Assert.Equal(4, displayArgs[^1].EffectiveArgumentIndex);
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Fact]
public async Task CreateExecutable_ProjectArgsResolvedInSnapshot_UsesEffectiveArgsFromCreatorIndexes()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var project = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithHttpEndpoint(targetPort: 8080);
var endpoint = project.GetEndpoint("http");
project.WithArgs("--port")
.WithArgs(c => c.Args.Add(endpoint.Property(EndpointProperty.Port)));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(2, argAnnotations.Count);
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, exe.Spec.Args);
var effectiveArgs = exe.Spec.Args!.ToList();
var portArgument = Assert.Single(argAnnotations, a => a.Argument != "--port");
effectiveArgs[Assert.IsType<int>(portArgument.EffectiveArgumentIndex)] = "52731";
exe.Status = new ExecutableStatus
{
EffectiveArgs = effectiveArgs
};
var snapshot = CreateSnapshotBuilder(distributedAppModel).ToSnapshot(exe, CreatePreviousSnapshot());
Assert.Equal(["--port", "52731"], GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Resource.AppArgs).ToArray());
Assert.Equal(effectiveArgs, GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Executable.Args).ToArray());
}
[Fact]
public async Task CreateContainer_ArgsResolvedInSnapshot_UsesEffectiveArgsFromCreatorIndexes()
{
var builder = DistributedApplication.CreateBuilder();
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
builder.AddContainer("aContainer", "image")
.WithArgs(c =>
{
c.Args.Add("--port");
c.Args.Add(executable.GetEndpoint("http").Property(EndpointProperty.Port));
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
Assert.True(container.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(2, argAnnotations.Count);
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, container.Spec.Args);
var effectiveArgs = container.Spec.Args!.ToList();
var portArgument = Assert.Single(argAnnotations, a => a.Argument != "--port");
effectiveArgs[Assert.IsType<int>(portArgument.EffectiveArgumentIndex)] = "5678";
container.Status = new ContainerStatus
{
EffectiveArgs = effectiveArgs
};
var snapshot = CreateSnapshotBuilder(distributedAppModel).ToSnapshot(container, CreatePreviousSnapshot());
Assert.Equal(["--port", "5678"], GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Resource.AppArgs).ToArray());
Assert.Equal(effectiveArgs, GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Container.Args).ToArray());
}
[Theory]
[InlineData("aspire")]
[InlineData("ASPIRE")]
public async Task RunApplicationAsync_ThrowsWhenContainerResourceNameConflictsWithContainerTunnelName(string containerName)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer(containerName, "image");
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: new TestKubernetesService(),
dcpOptions: new DcpOptions { EnableAspireContainerTunnel = true });
var ex = await Assert.ThrowsAsync<DistributedApplicationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains("container tunnel container name", ex.Message);
}
[Theory]
[InlineData("aspire")]
[InlineData("ASPIRE")]
public async Task RunApplicationAsync_ThrowsWhenExplicitContainerNameConflictsWithContainerTunnelName(string containerName)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("aContainer", "image")
.WithContainerName(containerName);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: new TestKubernetesService(),
dcpOptions: new DcpOptions { EnableAspireContainerTunnel = true });
var ex = await Assert.ThrowsAsync<DistributedApplicationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains("container tunnel container name", ex.Message);
}
[Theory]
[InlineData("aspire")]
[InlineData("ASPIRE")]
public async Task RunApplicationAsync_ThrowsWhenNetworkAliasConflictsWithContainerTunnelName(string alias)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("aContainer", "image")
.WithContainerNetworkAlias(alias);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: new TestKubernetesService(),
dcpOptions: new DcpOptions { EnableAspireContainerTunnel = true });
var ex = await Assert.ThrowsAsync<DistributedApplicationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains("container tunnel container name", ex.Message);
}
[Fact]
public async Task RunApplicationAsync_AllowsContainerNameMatchingContainerTunnelNameWhenContainerTunnelDisabled()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("aspire", "image");
builder.AddContainer("aContainer", "image")
.WithContainerName("ASPIRE");
builder.AddContainer("bContainer", "image")
.WithContainerNetworkAlias("ASPIRE");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
dcpOptions: new DcpOptions { EnableAspireContainerTunnel = false });
await appExecutor.RunApplicationAsync();
Assert.Equal(3, kubernetesService.CreatedResources.OfType<Container>().Count());
}
[Fact]
public async Task ResourceRestarted_EnvironmentCallbacksApplied()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var callCount = 0;
var resource = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithArgs(c =>
{
c.Args.Add("--test");
})
.WithEnvironment(c =>
{
Interlocked.Increment(ref callCount);
c.EnvironmentVariables["CALL_COUNT"] = callCount.ToString();
}).Resource;
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
var events = new DcpExecutorEvents();
var connectionStringAvailableCount = 0;
events.Subscribe<OnConnectionStringAvailableContext>(context =>
{
if (ReferenceEquals(context.Resource, resource))
{
Interlocked.Increment(ref connectionStringAvailableCount);
}
return Task.CompletedTask;
});
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync();
var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name);
var exe1 = Assert.Single(executables);
var callCount1 = exe1.Spec.Env!.Single(e => e.Name == "CALL_COUNT");
Assert.Equal("1", callCount1.Value);
Assert.Single(exe1.Spec.Args!, a => a == "--no-build");
Assert.Single(exe1.Spec.Args!, a => a == "--test");
Assert.True(exe1.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations1));
Assert.Single(argAnnotations1, a => a.Argument == "--test");
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations1, exe1.Spec.Args);
Assert.Equal(1, connectionStringAvailableCount);
var reference = appExecutor.GetResource(exe1.Metadata.Name);
await appExecutor.StopResourceAsync(reference, CancellationToken.None);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name);
Assert.Equal(2, executables.Count);
var exe2 = executables[1];
var callCount2 = exe2.Spec.Env!.Single(e => e.Name == "CALL_COUNT");
Assert.Equal("2", callCount2.Value);
Assert.Single(exe2.Spec.Args!, a => a == "--no-build");
Assert.Single(exe2.Spec.Args!, a => a == "--test");
Assert.True(exe2.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations2));
Assert.Single(argAnnotations2, a => a.Argument == "--test");
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations2, exe2.Spec.Args);
Assert.Equal(2, connectionStringAvailableCount);
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxiedNoPortNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder();
var exe = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "NoPortNoTargetPort", env: "NO_PORT_NO_TARGET_PORT", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Neither Port, nor TargetPort are set
// Clients use proxy, MAY have the proxy port injected.
// Proxy gets autogenerated port.
// Program gets (different) autogenerated port that MUST be injected via env var / startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
Assert.True(spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port is null,
"Expected service producer (target) port to not be set (leave allocation to DCP)");
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "CoolProgram" """, envVarVal);
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxiedPortSetNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1000;
var exe = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is set, but TargetPort is empty
// Clients use proxy, MAY have the proxy port injected.
// Proxy uses Port.
// Program gets autogenerated port that MUST be injected via env var / startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.True(spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port is null,
"Expected service producer (target) port to not be set (leave allocation to DCP)");
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "CoolProgram" """, envVarVal);
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxiedNoPortTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 999;
var exe = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredPort, env: "NO_PORT_TARGET_PORT_SET", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is empty, TargetPort is set
// Clients use proxy, MAY have the proxy port injected.
// Proxy gets autogenerated port.
// Program uses TargetPort which MAY be injected via env var/ startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxiedPortAndTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 997;
var exe = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPort, targetPort: desiredTargetPort, env: "PORT_AND_TARGET_PORT_SET", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port and TargetPort set (MUST be different).
// Clients use proxy, MAY have the proxy port injected.
// Proxy uses Port.
// Program uses TargetPort with MAY be injected via env var/ startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_AND_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
/// <summary>
/// Verifies that applying unsupported endpoint port configuration to non-replicated, proxied Executable
/// results in an error.
/// </summary>
[Fact]
public async Task UnsupportedEndpointPortsExecutableNotReplicatedProxied()
{
// Invalid configuration: Port and TargetPort have the same value. This would result in a port conflict.
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1000;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "EqualPortAndTargetPort", port: desiredPort, targetPort: desiredPort, env: "EQUAL_PORT_AND_TARGET_PORT", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains("cannot be proxied when both TargetPort and Port are specified with the same value", exception.Message);
}
[Fact]
public async Task EndpointPortsExecutableWithEndpointProxySupportUsesProxylessEndpoint()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1001;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT")
.WithEndpointProxySupport(false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableWithEndpointProxySupportOverridesExplicitProxiedEndpoint()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1001;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "EqualPortAndTargetPort", port: desiredPort, targetPort: desiredPort, env: "EQUAL_PORT_AND_TARGET_PORT", isProxied: true)
.WithEndpointProxySupport(false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "EQUAL_PORT_AND_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsPersistentExecutableDefaultsToProxylessEndpoint()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1002;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithPersistentLifetime()
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT");
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsPersistentExecutableDefaultsToProxiedEndpointWhenPortsAreRandomized()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1002;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithPersistentLifetime()
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT");
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", RandomizePorts = true };
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, configuration: configuration);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Null(svc.Spec.Port);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
Assert.NotEqual(desiredPort, svc.Status?.EffectivePort);
Assert.Null(spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "CoolProgram" """, envVarVal);
}
[Fact]
public async Task EndpointPortsPersistentExecutableExplicitProxylessStaysProxylessWhenPortsAreRandomized()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1002;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithPersistentLifetime()
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT", isProxied: false);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", RandomizePorts = true };
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, configuration: configuration);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxylessPortSetNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1000;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is set, but TargetPort is empty
// Clients connect directly to the program, MAY have the program port injected.
// Program uses TargetPort, which MAY be injected via env var / startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxylessNoPortTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 999;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredPort, env: "NO_PORT_TARGET_PORT_SET", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is empty, TargetPort is set.
// Clients connect directly to the program, MAY have the program port injected.
// Program uses TargetPort, which MAY be injected via env var / startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, svc.Spec.Port);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxylessPortAndTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPort, targetPort: desiredPort, env: "PORT_AND_TARGET_PORT_SET", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port and target port set (MUST be the same).
// Clients connect directly to the program, MAY have the program port injected.
// Program uses TargetPort, which MAY be injected via env var / startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_AND_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsExecutableNotReplicatedProxylessNoPortNoTargetPortAllocated()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "NoPortNoTargetPort", env: "NO_PORT_NO_TARGET_PORT", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>());
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.Equal(allocatedPort, svc.Spec.Port);
Assert.Equal(allocatedPort, spAnnList.Single(ann => ann.ServiceName == "CoolProgram").Port);
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(allocatedPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task ProxylessPortAllocatorOnlyAllocatesPortsForDcpWorkloads()
{
var (rangeStart, rangeEnd) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
var compute = builder.AddExecutable("compute", "compute", Environment.CurrentDirectory)
.WithEndpoint(name: "tcp", isProxied: false);
var target = builder.AddExecutable("target", "target", Environment.CurrentDirectory)
.WithHttpEndpoint(targetPort: 8000, name: "http");
builder.AddDevTunnel("tunnel")
.WithReference(target);
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = rangeStart,
ProxylessEndpointPortRangeEnd = rangeEnd
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var tunnelPort = Assert.Single(distributedAppModel.Resources.OfType<DevTunnelPortResource>());
var computeEndpoint = compute.GetEndpoint("tcp").EndpointAnnotation;
var tunnelEndpoint = Assert.Single(tunnelPort.Annotations.OfType<EndpointAnnotation>());
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
Assert.NotNull(computeEndpoint.AllocatedEndpoint);
var computePort = Assert.IsType<int>(computeEndpoint.Port);
Assert.InRange(computePort, rangeStart, rangeEnd);
Assert.Equal(computePort, computeEndpoint.TargetPort);
Assert.Null(tunnelEndpoint.Port);
Assert.Null(tunnelEndpoint.TargetPort);
Assert.Null(tunnelEndpoint.AllocatedEndpoint);
}
[Fact]
public async Task ProxylessPortAllocatorAllocatesPortForNonComputeContainerResource()
{
const int targetPort = 10000;
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
var emulator = builder.AddResource(new TestContainerResource("emulator"))
.WithAnnotation(new ContainerImageAnnotation { Image = "image" })
.WithAnnotation(new ContainerLifetimeAnnotation { Lifetime = ContainerLifetime.Persistent })
.WithHttpEndpoint(targetPort: targetPort, name: "http");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
})
.Build();
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var endpoint = emulator.GetEndpoint("http").EndpointAnnotation;
var appExecutor = CreateAppExecutor(
distributedAppModel,
configuration: configuration,
kubernetesService: kubernetesService,
dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
Assert.IsNotAssignableFrom<IComputeResource>(emulator.Resource);
Assert.True(emulator.Resource.IsContainer());
Assert.Equal(allocatedPort, endpoint.Port);
Assert.Equal(targetPort, endpoint.TargetPort);
Assert.Equal(allocatedPort, endpoint.AllocatedEndpoint?.Port);
Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == emulator.Resource.Name);
}
[Fact]
public async Task ProxylessPortAllocatorExcludesFixedPublicPorts()
{
var (fixedPort, allocatedPort) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("FixedProgram", "fixed", Environment.CurrentDirectory)
.WithEndpoint(name: "fixed", port: fixedPort, isProxied: false);
builder.AddExecutable("DynamicProgram", "dynamic", Environment.CurrentDirectory)
.WithEndpoint(name: "dynamic", env: "DYNAMIC_PORT", isProxied: false);
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = fixedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var fixedService = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "FixedProgram");
var dynamicService = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "DynamicProgram");
var dynamicExecutable = kubernetesService.CreatedResources.OfType<Executable>().Single(e => e.AppModelResourceName == "DynamicProgram");
Assert.Equal(fixedPort, fixedService.Status?.EffectivePort);
Assert.Equal(allocatedPort, dynamicService.Status?.EffectivePort);
Assert.Equal(allocatedPort, dynamicService.Spec.Port);
var envVarVal = dynamicExecutable.Spec.Env?.Single(v => v.Name == "DYNAMIC_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(allocatedPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Theory]
[InlineData(null)]
[InlineData(0)]
public async Task PersistentProxylessExecutableWithUnspecifiedPortPersistsAllocatedPort(int? port)
{
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithPersistentLifetime()
.WithEndpoint(name: "http", port: port, env: "HTTP_PORT");
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var userSecretsManager = new MockUserSecretsManager();
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions, userSecretsManager: userSecretsManager);
await appExecutor.RunApplicationAsync();
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(allocatedPort, svc.Status?.EffectivePort);
Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), userSecretsManager.Secrets["Resources:CoolProgram:http:port"]);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
public async Task PersistentProxylessContainerWithUnspecifiedPortPersistsAllocatedPort(int? port)
{
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
const int targetPort = TestKubernetesService.StartOfAutoPortRange - 999;
builder.AddContainer("database", "image")
.WithPersistentLifetime()
.WithEndpoint(name: "http", port: port, targetPort: targetPort);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var userSecretsManager = new MockUserSecretsManager();
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions, userSecretsManager: userSecretsManager);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(allocatedPort, svc.Status?.EffectivePort);
Assert.Equal(allocatedPort, svc.Spec.Port);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == targetPort);
Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), userSecretsManager.Secrets["Resources:database:http:port"]);
}
[Fact]
public async Task PersistentProxylessWithoutPortLogsWarningButStillAllocatesWhenPersistenceFails()
{
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithPersistentLifetime()
.WithEndpoint(name: "http", env: "HTTP_PORT", isProxied: false);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var userSecretsManager = new MockUserSecretsManager(canSetSecret: false);
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var testSink = new TestSink();
var logger = new TestLogger<DcpExecutor>(new TestLoggerFactory(testSink, enabled: true));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions, userSecretsManager: userSecretsManager, logger: logger);
await appExecutor.RunApplicationAsync();
// Persistence failed, but allocation must still proceed and assign the port to the endpoint.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
Assert.Equal(allocatedPort, svc.Status?.EffectivePort);
Assert.Empty(userSecretsManager.Secrets);
// A warning must surface so the user knows the port won't be stable across runs.
Assert.Contains(testSink.Writes, w => w.LogLevel == LogLevel.Warning
&& w.Message?.Contains("Failed to persist public port") == true
&& w.Message?.Contains("CoolProgram") == true
&& w.Message?.Contains("http") == true);
}
[Fact]
public async Task ProxylessExecutableAllocatedPortIsStableOnResourceRestart()
{
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithEndpoint(name: "http", env: "HTTP_PORT", isProxied: false);
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var service = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "CoolProgram");
var firstExecutable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "CoolProgram"));
Assert.Equal(allocatedPort, service.Status?.EffectivePort);
Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), firstExecutable.Spec.Env?.Single(v => v.Name == "HTTP_PORT").Value);
var reference = appExecutor.GetResource(firstExecutable.Metadata.Name);
await appExecutor.StopResourceAsync(reference, CancellationToken.None);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
var executables = GetCreatedExecutablesForResource(kubernetesService, "CoolProgram");
Assert.Equal(2, executables.Count);
Assert.Equal(allocatedPort, service.Status?.EffectivePort);
Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), executables[1].Spec.Env?.Single(v => v.Name == "HTTP_PORT").Value);
}
[Fact]
public async Task ProxylessContainerAllocatedHostPortIsStableOnResourceRestart()
{
var (allocatedPort, _) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
const int targetPort = TestKubernetesService.StartOfAutoPortRange - 999;
builder.AddContainer("database", "image")
.WithEndpoint(name: "http", targetPort: targetPort, isProxied: false);
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = allocatedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var service = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
var firstContainer = Assert.Single(GetCreatedContainersForResource(kubernetesService, "database"));
Assert.Equal(allocatedPort, service.Status?.EffectivePort);
Assert.Contains(firstContainer.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == targetPort);
var reference = appExecutor.GetResource(firstContainer.Metadata.Name);
await appExecutor.StopResourceAsync(reference, CancellationToken.None);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
var containers = GetCreatedContainersForResource(kubernetesService, "database");
Assert.Equal(2, containers.Count);
Assert.Equal(allocatedPort, service.Status?.EffectivePort);
Assert.Contains(containers[1].Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == targetPort);
}
[Fact]
public async Task PersistedProxylessEndpointPortIsReusedAndExcludedFromDynamicAllocation()
{
var (persistedPort, allocatedPort) = GetAvailableConsecutivePortPair();
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("PersistentProgram", "persistent", Environment.CurrentDirectory)
.WithPersistentLifetime()
.WithEndpoint(name: "http", env: "PERSISTENT_PORT", isProxied: false);
builder.AddExecutable("DynamicProgram", "dynamic", Environment.CurrentDirectory)
.WithEndpoint(name: "http", env: "DYNAMIC_PORT", isProxied: false);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["Resources:PersistentProgram:http:port"] = persistedPort.ToString(CultureInfo.InvariantCulture)
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var userSecretsManager = new MockUserSecretsManager();
var dcpOptions = new DcpOptions
{
DashboardPath = "./dashboard",
ProxylessEndpointPortRangeStart = persistedPort,
ProxylessEndpointPortRangeEnd = allocatedPort
};
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions, userSecretsManager: userSecretsManager);
await appExecutor.RunApplicationAsync();
var persistentService = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "PersistentProgram");
var dynamicService = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "DynamicProgram");
Assert.Equal(persistedPort, persistentService.Status?.EffectivePort);
Assert.Equal(persistedPort, persistentService.Spec.Port);
Assert.Equal(allocatedPort, dynamicService.Status?.EffectivePort);
Assert.Equal(allocatedPort, dynamicService.Spec.Port);
Assert.Empty(userSecretsManager.Secrets);
}
/// <summary>
/// Verifies that applying unsupported endpoint port configuration to non-replicated, proxy-less Executables
/// results in an error
/// </summary>
[Fact]
public async Task UnsupportedEndpointPortsExecutableNotReplicatedProxyless()
{
const int desiredPortOne = TestKubernetesService.StartOfAutoPortRange - 1000;
const int desiredPortTwo = TestKubernetesService.StartOfAutoPortRange - 999;
(Action<IResourceBuilder<ExecutableResource>> AddEndpoint, string ErrorMessageFragment)[] testcases = [
// Invalid configuration: both Port and TargetPort set, but to different values.
(
er => er.WithEndpoint(name: "PortAndTargetPortSetDifferently", port: desiredPortOne, targetPort: desiredPortTwo, env: "PORT_AND_TARGET_PORT_SET_DIFFERENTLY", isProxied: false),
"has a value of Port property that is different from the value of TargetPort property"
),
// Invalid configuration: Port requests dynamic allocation while TargetPort is fixed.
(
er => er.WithEndpoint(name: "ZeroPortAndTargetPortSetDifferently", port: 0, targetPort: desiredPortOne, env: "ZERO_PORT_AND_TARGET_PORT_SET_DIFFERENTLY", isProxied: false),
"has a value of Port property that is different from the value of TargetPort property"
)
];
foreach (var tc in testcases)
{
var builder = DistributedApplication.CreateBuilder();
var exe = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo");
tc.AddEndpoint(exe);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains(tc.ErrorMessageFragment, exception.Message);
}
}
[Theory]
[InlineData(1, "ServiceA")]
[InlineData(2, "ServiceA")]
public async Task EndpointOtelServiceName(int replicaCount, string expectedName)
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA")
.WithReplicas(replicaCount);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var executables = GetCreatedExecutablesForResource(kubernetesService, "ServiceA");
Assert.Equal(replicaCount, executables.Count);
foreach (var exe in executables)
{
Assert.Equal(expectedName, exe.Metadata.Annotations[CustomResource.OtelServiceNameAnnotation]);
}
}
[Fact]
public async Task ResourceLogging_MultipleStreams_StreamedOverTime()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var logStreamPipesChannel = Channel.CreateUnbounded<(string Type, Pipe Pipe)>();
var kubernetesService = new TestKubernetesService(startStream: (obj, logStreamType) =>
{
var s = new Pipe();
if (!logStreamPipesChannel.Writer.TryWrite((logStreamType, s)))
{
Assert.Fail("Pipe channel unexpectedly closed.");
}
return s.Reader.AsStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync();
var exeResource = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
// Start watching logs for container.
var watchCts = new CancellationTokenSource();
var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync();
var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator();
var watchLogs = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
var watchLogsEnumerator = watchLogs.GetAsyncEnumerator(watchCts.Token);
var moveNextTask = watchLogsEnumerator.MoveNextAsync().AsTask();
Assert.True(await moveNextTask);
moveNextTask = watchLogsEnumerator.MoveNextAsync().AsTask();
Assert.False(moveNextTask.IsCompletedSuccessfully, "No logs yet.");
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.True(watchSubscribersEnumerator.Current.AnySubscribers);
exeResource.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(exeResource);
var pipes = await GetStreamPipesAsync(logStreamPipesChannel);
// Write content to container output stream. This is read by logging and creates log lines.
await pipes.StandardOut.Writer.WriteAsync(Encoding.UTF8.GetBytes("2024-08-19T06:10:33.473275911Z Hello world" + Environment.NewLine));
Assert.True(await moveNextTask);
var logLine = watchLogsEnumerator.Current.Single();
Assert.Equal("2024-08-19T06:10:33.4732759Z Hello world", logLine.Content);
Assert.Equal(2, logLine.LineNumber);
Assert.False(logLine.IsErrorMessage);
moveNextTask = watchLogsEnumerator.MoveNextAsync().AsTask();
Assert.False(moveNextTask.IsCompletedSuccessfully, "No logs yet.");
// Note: This console log is earlier than the previous, but logs are displayed in real time as they're available.
await pipes.StandardErr.Writer.WriteAsync(Encoding.UTF8.GetBytes("2024-08-19T06:10:32.661Z Next" + Environment.NewLine));
Assert.True(await moveNextTask);
logLine = watchLogsEnumerator.Current.Single();
Assert.Equal("2024-08-19T06:10:32.6610000Z Next", logLine.Content);
Assert.Equal(3, logLine.LineNumber);
Assert.True(logLine.IsErrorMessage);
var loggerState = resourceLoggerService.GetResourceLoggerState(exeResource.Metadata.Name);
Assert.Collection(loggerState.GetBacklogSnapshot(),
l => Assert.Equal("Next", l.Content),
l => Assert.Equal("Hello world", l.Content),
l => { });
// Stop watching.
moveNextTask = watchLogsEnumerator.MoveNextAsync().AsTask();
watchCts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await moveNextTask);
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.False(watchSubscribersEnumerator.Current.AnySubscribers);
// State is clear when no longer watching.
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => loggerState.GetBacklogSnapshot().Length == 0,
"Backlog is asynchronously cleared after watch ends.");
}
[Fact]
public async Task ResourceLogging_ReplayBacklog_SentInBatch()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService(startStream: (obj, logStreamType) =>
{
switch (logStreamType)
{
case Logs.StreamTypeStdOut:
return new MemoryStream(Encoding.UTF8.GetBytes("2024-08-19T06:10:01.000Z First" + Environment.NewLine));
case Logs.StreamTypeStdErr:
return new MemoryStream(Encoding.UTF8.GetBytes("2024-08-19T06:10:02.000Z Second" + Environment.NewLine));
case Logs.StreamTypeStartupStdOut:
return new MemoryStream(Encoding.UTF8.GetBytes("2024-08-19T06:10:03.000Z Third" + Environment.NewLine));
case Logs.StreamTypeStartupStdErr:
return new MemoryStream(Encoding.UTF8.GetBytes(
"2024-08-19T06:10:05.000Z Sixth" + Environment.NewLine +
"2024-08-19T06:10:05.000Z Seventh" + Environment.NewLine +
"2024-08-19T06:10:04.000Z Forth" + Environment.NewLine +
"2024-08-19T06:10:04.000Z Fifth" + Environment.NewLine));
case Logs.StreamTypeSystem:
return new MemoryStream();
default:
throw new InvalidOperationException("Unexpected type: " + logStreamType);
}
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync();
var exeResource = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
// Start watching logs for container.
var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync();
var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator();
var watchLogs1 = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
var watchLogsTask1 = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs1, targetLogCount: 8);
Assert.False(watchLogsTask1.IsCompletedSuccessfully, "Logs not available yet.");
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.True(watchSubscribersEnumerator.Current.AnySubscribers);
exeResource.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(exeResource);
var watchLogsResults1 = await watchLogsTask1;
Assert.Equal(8, watchLogsResults1.Count);
Assert.Contains(watchLogsResults1, l => l.Content.Contains("First"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Second"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Third"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Forth"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Fifth"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Sixth"));
Assert.Contains(watchLogsResults1, l => l.Content.Contains("Seventh"));
var watchLogs2 = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
var watchLogsTask2 = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs2, targetLogCount: 8);
var watchLogsResults2 = await watchLogsTask2;
Assert.Contains(watchLogsResults2, l => l.Content.Contains("First"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Second"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Third"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Forth"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Fifth"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Sixth"));
Assert.Contains(watchLogsResults2, l => l.Content.Contains("Seventh"));
}
[Fact]
public async Task ResourceLogging_LateSubscriberReceivesFailedToStartLogsWithoutWatchReplay()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string failedToStartLogMessage = "failure discovered after the terminal notification";
var followStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.FailedToStart } &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
followStreamStarted.TrySetResult();
return new MemoryStream(Encoding.UTF8.GetBytes(
"2024-08-19T06:10:33.473275911Z " + failedToStartLogMessage + Environment.NewLine));
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var terminalNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
string? dcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName &&
context.Status.State == ContainerState.FailedToStart)
{
terminalNotification.TrySetResult();
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService,
events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
container.Status = new ContainerStatus { State = ContainerState.FailedToStart };
kubernetesService.PushResourceModified(container);
await terminalNotification.Task.DefaultTimeout();
Assert.False(followStreamStarted.Task.IsCompleted, "A terminal log stream should not start without a subscriber.");
var logLines = new ConcurrentQueue<LogLine>();
using var subscription = resourceLoggerService.Subscribe(dcpResourceName, batch =>
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
});
await followStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => logLines.Any(line => line.Content.Contains(failedToStartLogMessage, StringComparison.Ordinal)),
"The subscriber-driven follow stream should deliver logs without a watch replay.");
Assert.Single(logLines, line => line.Content.Contains(failedToStartLogMessage, StringComparison.Ordinal));
}
[Fact]
public async Task ResourceLogging_ActiveSubscriberReceivesFailedToStartLogsAfterSnapshot()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string failedToStartLogMessage = "failure discovered after the point-in-time snapshot";
var runningFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var failedToStartSnapshotStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var failedToStartFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var failedToStartFollowStreamCount = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.Running } &&
logStreamType == Logs.StreamTypeSystem &&
follow == true)
{
runningFollowStreamStarted.TrySetResult();
}
if (obj is Container { Status.State: ContainerState.FailedToStart } &&
logStreamType == Logs.StreamTypeStdErr)
{
if (follow == false)
{
failedToStartSnapshotStarted.TrySetResult();
}
else if (follow == true)
{
Interlocked.Increment(ref failedToStartFollowStreamCount);
failedToStartFollowStreamStarted.TrySetResult();
return new MemoryStream(Encoding.UTF8.GetBytes(
"2024-08-19T06:10:33.473275911Z " + failedToStartLogMessage + Environment.NewLine));
}
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
using var subscription = resourceLoggerService.Subscribe(container.Metadata.Name, batch =>
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
});
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await runningFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(container.Metadata.Name) is null,
"The initial follow stream should complete before the terminal transition.");
container.Status = new ContainerStatus { State = ContainerState.FailedToStart };
kubernetesService.PushResourceModified(container);
await failedToStartSnapshotStarted.Task.DefaultTimeout();
await failedToStartFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => logLines.Any(line => line.Content.Contains(failedToStartLogMessage, StringComparison.Ordinal)),
"The normal follow stream should deliver logs after the point-in-time snapshot.");
Assert.Equal(1, Volatile.Read(ref failedToStartFollowStreamCount));
Assert.Single(logLines, line => line.Content.Contains(failedToStartLogMessage, StringComparison.Ordinal));
}
[Fact]
public async Task ResourceLogging_TerminalStateFollowsLogsBeforeNotification()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string terminalLogMessage = "crash before terminal notification";
bool? terminalStdErrFollow = null;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.Exited } &&
logStreamType == Logs.StreamTypeStdErr)
{
terminalStdErrFollow = follow;
return new MemoryStream(Encoding.UTF8.GetBytes("2024-08-19T06:10:33.473275911Z " + terminalLogMessage + Environment.NewLine));
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var logLines = new List<LogLine>();
var logLinesLock = new object();
var terminalLogCountAtNotification = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
string? dcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName && context.Status.State == ContainerState.Exited)
{
int logCount;
lock (logLinesLock)
{
logCount = logLines.Count(l => l.Content.Contains(terminalLogMessage));
}
terminalLogCountAtNotification.TrySetResult(logCount);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
using var subscription = resourceLoggerService.Subscribe(dcpResourceName, batch =>
{
lock (logLinesLock)
{
logLines.AddRange(batch);
}
});
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
Assert.Equal(1, await terminalLogCountAtNotification.Task.DefaultTimeout());
Assert.True(terminalStdErrFollow == true);
lock (logLinesLock)
{
Assert.Single(logLines, l => l.Content.Contains(terminalLogMessage));
}
}
[Fact]
public async Task ResourceLogging_TerminalLogFlushTimeoutDoesNotBlockOtherResourceNotifications()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("blocking", "image");
builder.AddContainer("other", "image");
string? blockingDcpResourceName = null;
var normalStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blockingTerminalFollowStreamCount = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj.Metadata.Name == blockingDcpResourceName &&
obj is Container container &&
container.Status?.State != ContainerState.Exited &&
logStreamType == Logs.StreamTypeSystem &&
follow == true)
{
normalStreamStarted.TrySetResult();
}
if (obj.Metadata.Name == blockingDcpResourceName &&
obj is Container { Status.State: ContainerState.Exited } &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
if (Interlocked.Increment(ref blockingTerminalFollowStreamCount) == 1)
{
return new Pipe().Reader.AsStream();
}
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var otherTerminalNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blockingTerminalNotifications = 0;
string? otherDcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.Status.State == ContainerState.Exited)
{
if (context.DcpResourceName == blockingDcpResourceName)
{
Interlocked.Increment(ref blockingTerminalNotifications);
}
else if (context.DcpResourceName == otherDcpResourceName)
{
otherTerminalNotification.TrySetResult();
}
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService, events: events);
appExecutor.ResourceWatcher.TerminalLogFlushTimeout = TimeSpan.FromMilliseconds(100);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var blockingContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "blocking");
var otherContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "other");
blockingDcpResourceName = blockingContainer.Metadata.Name;
otherDcpResourceName = otherContainer.Metadata.Name;
using var subscription = resourceLoggerService.Subscribe(blockingDcpResourceName, _ => { });
blockingContainer.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(blockingContainer);
await normalStreamStarted.Task.DefaultTimeout();
blockingContainer.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(blockingContainer);
otherContainer.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(otherContainer);
await otherTerminalNotification.Task.DefaultTimeout();
blockingContainer.Status = new ContainerStatus { State = ContainerState.Exited, ExitCode = 1 };
kubernetesService.PushResourceModified(blockingContainer);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref blockingTerminalNotifications) >= 2,
"The terminal state change after a timed-out flush should be reported.");
Assert.Equal(2, Volatile.Read(ref blockingTerminalNotifications));
// The first terminal event opens the timed-out flush and its recovery stream. The changed
// terminal event then retries the flush because the first one was not recorded as complete.
Assert.Equal(3, Volatile.Read(ref blockingTerminalFollowStreamCount));
}
[Fact]
public async Task ResourceLogging_ActiveSubscriberContinuesAfterTerminalFlushTimeout()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string terminalLogMessage = "log delivered after terminal flush timeout";
var timedOutFlushPipe = new Pipe();
var runningFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var terminalFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var terminalStdErrFollowStreamCount = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.Running } &&
logStreamType == Logs.StreamTypeSystem &&
follow == true)
{
runningFollowStreamStarted.TrySetResult();
}
if (obj is Container { Status.State: ContainerState.Exited } &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
if (Interlocked.Increment(ref terminalStdErrFollowStreamCount) == 1)
{
return timedOutFlushPipe.Reader.AsStream();
}
terminalFollowStreamStarted.TrySetResult();
return new MemoryStream(Encoding.UTF8.GetBytes(
"2024-08-19T06:10:33.473275911Z " + terminalLogMessage + Environment.NewLine));
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService);
appExecutor.ResourceWatcher.TerminalLogFlushTimeout = TimeSpan.FromMilliseconds(100);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
using var subscription = resourceLoggerService.Subscribe(container.Metadata.Name, batch =>
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
});
try
{
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await runningFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(container.Metadata.Name) is null,
"The initial follow stream should complete before the terminal transition.");
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await terminalFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => logLines.Any(line => line.Content.Contains(terminalLogMessage, StringComparison.Ordinal)),
"The normal follow stream should deliver logs after the terminal flush times out.");
Assert.Equal(2, Volatile.Read(ref terminalStdErrFollowStreamCount));
Assert.Single(logLines, line => line.Content.Contains(terminalLogMessage, StringComparison.Ordinal));
}
finally
{
timedOutFlushPipe.Writer.Complete();
}
}
[Fact]
public async Task ResourceLogging_FollowStreamDeduplicatesOnlyPendingTerminalFlush()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var followStdErrPipeChannel = Channel.CreateUnbounded<Pipe>();
var followStdErrStreamCount = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (logStreamType == Logs.StreamTypeStdErr)
{
if (follow == true)
{
if (Interlocked.Increment(ref followStdErrStreamCount) == 1)
{
var pipe = new Pipe();
followStdErrPipeChannel.Writer.TryWrite(pipe);
return pipe.Reader.AsStream();
}
return new MemoryStream(Encoding.UTF8.GetBytes("same" + Environment.NewLine));
}
return new MemoryStream();
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var logLines = new List<LogLine>();
var logLinesLock = new object();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
using var subscription = resourceLoggerService.Subscribe(container.Metadata.Name, batch =>
{
lock (logLinesLock)
{
logLines.AddRange(batch);
}
});
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
var followStdErrPipe = await followStdErrPipeChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
lock (logLinesLock)
{
return logLines.Count(l => l.Content == "same") == 1;
}
},
"Terminal flush should deliver the snapshot log.");
Assert.True(appExecutor.ResourceWatcher.HasLogStreamPendingDeduplication(container.Metadata.Name));
await followStdErrPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes("same" + Environment.NewLine + "same" + Environment.NewLine));
await AsyncTestHelpers.AssertIsTrueRetryAsync(() =>
{
lock (logLinesLock)
{
return logLines.Count(l => l.Content == "same") == 2;
}
},
"Follow stream should skip the overlapping flushed line but preserve a later identical line.");
Assert.False(appExecutor.ResourceWatcher.HasLogStreamPendingDeduplication(container.Metadata.Name));
}
[Fact]
public async Task ResourceLogging_CompletedFollowStreamIsRemovedAndCanRestartWithExistingSubscriber()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string restartedLogMessage = "log after restart";
var restartedLogLine = "2024-08-19T06:10:33.473275911Z " + restartedLogMessage + Environment.NewLine;
var firstFollowStream = new GatedReadStream();
var secondFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var stdoutFollowStreamCount = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.Running } &&
logStreamType == Logs.StreamTypeStdOut &&
follow == true)
{
if (Interlocked.Increment(ref stdoutFollowStreamCount) == 1)
{
return firstFollowStream;
}
secondFollowStreamStarted.TrySetResult();
return new MemoryStream(Encoding.UTF8.GetBytes(restartedLogLine));
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
using var subscription = resourceLoggerService.Subscribe(container.Metadata.Name, batch =>
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
});
try
{
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await firstFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(container.Metadata.Name) is not null,
"The first subscription should start a log stream.");
var firstLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(container.Metadata.Name);
Assert.NotNull(firstLogStreamTask);
firstFollowStream.Release();
await firstLogStreamTask.DefaultTimeout();
Assert.Null(appExecutor.ResourceWatcher.GetLogStreamTask(container.Metadata.Name));
kubernetesService.PushResourceModified(container);
await secondFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => logLines.Any(line => line.Content.Contains(restartedLogMessage, StringComparison.Ordinal)),
"The follow stream started after the restart should deliver logs to the existing subscriber.");
Assert.Equal(2, Volatile.Read(ref stdoutFollowStreamCount));
Assert.Single(logLines, line => line.Content.Contains(restartedLogMessage, StringComparison.Ordinal));
}
finally
{
firstFollowStream.TryRelease();
}
}
[Fact]
public async Task ResourceLogging_OverlappingSameUidStreamCannotClearNewDeduplicationState()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string firstTerminalLogMessage = "first terminal period";
const string secondTerminalLogMessage = "second terminal period";
var firstTerminalLogLine = "2024-08-19T06:10:33.473275911Z " + firstTerminalLogMessage + Environment.NewLine;
var secondTerminalLogLine = "2024-08-19T06:10:34.473275911Z " + secondTerminalLogMessage + Environment.NewLine;
var previousFollowStream = new GatedReadStream();
var currentFollowStream = new GatedReadStream();
var logger = new GatedLogger<DcpExecutor>("was cancelled.");
var runningFollowStreams = 0;
var terminalFlushes = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container container &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
if (container.Status?.State == ContainerState.Running)
{
return Interlocked.Increment(ref runningFollowStreams) == 1
? previousFollowStream
: currentFollowStream;
}
if (container.Status?.State == ContainerState.Exited)
{
return new MemoryStream(Encoding.UTF8.GetBytes(
Interlocked.Increment(ref terminalFlushes) == 1
? firstTerminalLogLine
: secondTerminalLogLine));
}
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var runningNotifications = 0;
var terminalNotifications = 0;
string? dcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName)
{
if (context.Status.State == ContainerState.Running)
{
Interlocked.Increment(ref runningNotifications);
}
else if (context.Status.State == ContainerState.Exited)
{
Interlocked.Increment(ref terminalNotifications);
}
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService,
events: events,
logger: logger);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
Assert.False(string.IsNullOrEmpty(container.Metadata.Uid));
IDisposable? firstSubscription = resourceLoggerService.Subscribe(dcpResourceName, AddLogLines);
IDisposable? secondSubscription = null;
try
{
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await previousFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is not null,
"The first terminal period should have an active follow stream.");
var previousLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(previousLogStreamTask);
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref terminalNotifications) >= 1,
"The first terminal period should be reported.");
Assert.Single(logLines, line => line.Content.Contains(firstTerminalLogMessage, StringComparison.Ordinal));
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref runningNotifications) >= 2,
"The restarted resource should be reported as running.");
firstSubscription.Dispose();
firstSubscription = null;
await logger.Blocked.DefaultTimeout();
secondSubscription = resourceLoggerService.Subscribe(dcpResourceName, AddLogLines);
await currentFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is { } task && task != previousLogStreamTask,
"The second subscription should start a new follow stream.");
var currentLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(currentLogStreamTask);
container.Status = new ContainerStatus { State = ContainerState.Exited, ExitCode = 1 };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref terminalNotifications) >= 2,
"The second terminal period should be reported.");
Assert.Single(logLines, line => line.Content.Contains(secondTerminalLogMessage, StringComparison.Ordinal));
// The canceled stream owns the first terminal period's deduplication state. Finishing it
// now must not clear the newer state installed for the same UID's second terminal period.
previousFollowStream.Release();
logger.Release();
await previousLogStreamTask.DefaultTimeout();
Assert.Same(currentLogStreamTask, appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName));
currentFollowStream.Release(secondTerminalLogLine);
await currentLogStreamTask.DefaultTimeout();
Assert.Equal(2, Volatile.Read(ref terminalFlushes));
Assert.Single(logLines, line => line.Content.Contains(secondTerminalLogMessage, StringComparison.Ordinal));
}
finally
{
logger.Release();
previousFollowStream.TryRelease();
currentFollowStream.TryRelease();
firstSubscription?.Dispose();
secondSubscription?.Dispose();
}
void AddLogLines(IReadOnlyList<LogLine> batch)
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
}
}
[Fact]
public async Task ResourceLogging_CanceledSameUidStreamCannotClearHandedOffDeduplicationState()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string terminalLogMessage = "terminal period";
var terminalLogLine = "2024-08-19T06:10:33.473275911Z " + terminalLogMessage + Environment.NewLine;
var previousFollowStream = new GatedReadStream();
var currentFollowStream = new GatedReadStream();
var logger = new GatedLogger<DcpExecutor>("was cancelled.");
var runningFollowStreams = 0;
var terminalFlushes = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container container &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
if (container.Status?.State == ContainerState.Running)
{
return Interlocked.Increment(ref runningFollowStreams) == 1
? previousFollowStream
: currentFollowStream;
}
if (container.Status?.State == ContainerState.Exited)
{
Interlocked.Increment(ref terminalFlushes);
return new MemoryStream(Encoding.UTF8.GetBytes(terminalLogLine));
}
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var runningNotifications = 0;
var terminalNotifications = 0;
string? dcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName)
{
if (context.Status.State == ContainerState.Running)
{
Interlocked.Increment(ref runningNotifications);
}
else if (context.Status.State == ContainerState.Exited)
{
Interlocked.Increment(ref terminalNotifications);
}
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService,
events: events,
logger: logger);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
Assert.False(string.IsNullOrEmpty(container.Metadata.Uid));
IDisposable? firstSubscription = resourceLoggerService.Subscribe(dcpResourceName, AddLogLines);
IDisposable? secondSubscription = null;
try
{
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await previousFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is not null,
"The first subscription should start a log stream.");
var previousLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(previousLogStreamTask);
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref terminalNotifications) >= 1,
"The terminal period should be reported.");
Assert.Single(logLines, line => line.Content.Contains(terminalLogMessage, StringComparison.Ordinal));
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref runningNotifications) >= 2,
"The restarted resource should be reported as running.");
firstSubscription.Dispose();
firstSubscription = null;
await logger.Blocked.DefaultTimeout();
secondSubscription = resourceLoggerService.Subscribe(dcpResourceName, AddLogLines);
await currentFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is { } task && task != previousLogStreamTask,
"The second subscription should start a new follow stream.");
var currentLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(currentLogStreamTask);
Assert.True(appExecutor.ResourceWatcher.HasLogStreamPendingDeduplication(dcpResourceName));
// The second stream has adopted the same pending object retained by the canceled stream.
// Late completion of the canceled stream must leave the handed-off state in place.
previousFollowStream.Release();
logger.Release();
await previousLogStreamTask.DefaultTimeout();
Assert.Same(currentLogStreamTask, appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName));
Assert.True(appExecutor.ResourceWatcher.HasLogStreamPendingDeduplication(dcpResourceName));
currentFollowStream.Release(terminalLogLine);
await currentLogStreamTask.DefaultTimeout();
Assert.Equal(1, Volatile.Read(ref terminalFlushes));
Assert.Single(logLines, line => line.Content.Contains(terminalLogMessage, StringComparison.Ordinal));
Assert.False(appExecutor.ResourceWatcher.HasLogStreamPendingDeduplication(dcpResourceName));
}
finally
{
logger.Release();
previousFollowStream.TryRelease();
currentFollowStream.TryRelease();
firstSubscription?.Dispose();
secondSubscription?.Dispose();
}
void AddLogLines(IReadOnlyList<LogLine> batch)
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
}
}
[Fact]
public async Task ResourceWatch_ResourceWithoutResourceVersionIsAlwaysProcessed()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var observedStates = new ConcurrentQueue<string?>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.Resource.Name == "database")
{
observedStates.Enqueue(context.Status.State);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Running),
"The state change to Running should be reported.");
// Without a resource version there is no way to tell a replay from a change, so the watcher
// must fall back to processing the event. Suppressing it instead would freeze the resource.
container.Metadata.ResourceVersion = null;
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceUnchanged(container);
kubernetesService.PushResourceUnchanged(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Count(s => s == ContainerState.Exited) >= 2,
"Events without a resource version should always be processed.");
Assert.Equal(2, observedStates.Count(s => s == ContainerState.Exited));
}
[Fact]
public async Task ResourceWatch_UnchangedResourceNotificationIsIgnored()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var observedStates = new ConcurrentQueue<string?>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.Resource.Name == "database")
{
observedStates.Enqueue(context.Status.State);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Running),
"The state change to Running should be reported.");
// Re-delivering the same resource UID without a new resource version means nothing was stored,
// so it does not describe a change no matter which event type carries it.
kubernetesService.PushResourceUnchanged(container);
kubernetesService.PushResourceUnchanged(container, k8s.WatchEventType.Added);
// Pushing a real change afterwards acts as a barrier: the container watch is processed in
// order, so once Exited is observed the replays above have already been handled or dropped.
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Exited),
"The state change to Exited should be reported.");
AssertStatesReportedAfter(observedStates, ContainerState.Running, ContainerState.Exited);
}
[Fact]
public async Task ResourceWatch_RecreatedResourceWithPreviouslySeenVersionIsProcessed()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var observedStates = new ConcurrentQueue<string?>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.Resource.Name == "database")
{
observedStates.Enqueue(context.Status.State);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Running),
"The state change to Running should be reported.");
// Resource versions are opaque, so a recreated object can carry a value that was already
// observed for the previous object with the same kind and name. The Deleted event must clear
// that version before the recreated object is delivered as Added.
kubernetesService.PushResourceDeleted(container);
container.Metadata.Uid = "database-instance-2";
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceUnchanged(container, k8s.WatchEventType.Added);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Exited),
"The recreated resource should be reported even when its resource version was seen before deletion.");
AssertStatesReportedAfter(observedStates, ContainerState.Running, ContainerState.Exited);
}
[Fact]
public async Task ResourceWatch_RecreatedResourceAfterMissedDeleteIsProcessedAndResetsLogState()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
const string previousLateLogMessage = "late log from previous resource";
const string replacementLogMessage = "replacement terminal log";
var previousLateLogLine = "2024-08-19T06:10:32.473275911Z " + previousLateLogMessage + Environment.NewLine;
var replacementLogLine = "2024-08-19T06:10:33.473275911Z " + replacementLogMessage + Environment.NewLine;
var previousFollowStream = new GatedReadStream();
var replacementFollowStream = new GatedReadStream();
var previousBatchReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releasePreviousBatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var replacementFollowStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var replacementStdErrFollowStreams = 0;
string? previousResourceUid = null;
string? replacementResourceUid = null;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container container &&
logStreamType == Logs.StreamTypeStdErr &&
follow == true)
{
if (container.Metadata.Uid == previousResourceUid &&
container.Status?.State == ContainerState.Running)
{
return previousFollowStream;
}
if (container.Metadata.Uid == replacementResourceUid &&
container.Status?.State == ContainerState.Exited)
{
if (Interlocked.Increment(ref replacementStdErrFollowStreams) == 1)
{
return new MemoryStream(Encoding.UTF8.GetBytes(replacementLogLine));
}
replacementFollowStreamStarted.TrySetResult();
return replacementFollowStream;
}
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
var logLines = new ConcurrentQueue<LogLine>();
var terminalNotifications = 0;
string? dcpResourceName = null;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName &&
context.Status.State == ContainerState.Exited)
{
Interlocked.Increment(ref terminalNotifications);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
resourceLoggerService: resourceLoggerService,
events: events);
appExecutor.ResourceWatcher.BeforeLogBatchDeliveryAsync = async resourceUid =>
{
if (resourceUid == previousResourceUid)
{
previousBatchReady.TrySetResult();
await releasePreviousBatch.Task.ConfigureAwait(false);
}
};
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
previousResourceUid = container.Metadata.Uid;
Assert.False(string.IsNullOrEmpty(previousResourceUid));
using var subscription = resourceLoggerService.Subscribe(dcpResourceName, batch =>
{
foreach (var logLine in batch)
{
logLines.Enqueue(logLine);
}
});
try
{
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await previousFollowStream.ReadStarted.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is not null,
"The first resource incarnation should have an active log stream.");
var previousLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(previousLogStreamTask);
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref terminalNotifications) >= 1,
"The first resource incarnation should report its terminal state.");
// Pause after the old stream has yielded a batch to DcpResourceWatcher. Cancellation can stop
// future reads, but it cannot revoke a batch that the outer await-foreach loop already received.
previousFollowStream.Release(previousLateLogLine);
await previousBatchReady.Task.DefaultTimeout();
// Simulate a delete and recreation that happened while the watch was disconnected. The fresh
// list-and-watch reports only Added for the new UID, and resourceVersion is opaque enough that
// it can equal the value last observed for the previous object.
replacementResourceUid = "database-instance-2";
container.Metadata.Uid = replacementResourceUid;
container.Status = new ContainerStatus { State = ContainerState.Exited, ExitCode = 1 };
kubernetesService.PushResourceUnchanged(container, k8s.WatchEventType.Added);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref terminalNotifications) >= 2,
"The replacement resource should be processed even though no delete event was observed.");
await replacementFollowStreamStarted.Task.DefaultTimeout();
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName) is { } task && task != previousLogStreamTask,
"The replacement resource should start a new log stream.");
var replacementLogStreamTask = appExecutor.ResourceWatcher.GetLogStreamTask(dcpResourceName);
Assert.NotNull(replacementLogStreamTask);
Assert.Single(logLines, line => line.Content.Contains(replacementLogMessage, StringComparison.Ordinal));
// Let the old batch continue only after the replacement stream has installed its pending
// deduplication state. It must neither publish under the replacement's name nor remove that state.
releasePreviousBatch.TrySetResult();
await previousLogStreamTask.DefaultTimeout();
replacementFollowStream.Release(replacementLogLine);
await replacementLogStreamTask.DefaultTimeout();
Assert.Equal(2, Volatile.Read(ref replacementStdErrFollowStreams));
Assert.Collection(
logLines.Where(line =>
line.Content.Contains(previousLateLogMessage, StringComparison.Ordinal) ||
line.Content.Contains(replacementLogMessage, StringComparison.Ordinal)),
line => Assert.Contains(replacementLogMessage, line.Content, StringComparison.Ordinal));
}
finally
{
appExecutor.ResourceWatcher.BeforeLogBatchDeliveryAsync = null;
releasePreviousBatch.TrySetResult();
previousFollowStream.TryRelease();
replacementFollowStream.TryRelease();
}
}
[Fact]
public async Task ResourceWatch_WatchRestartDoesNotRepublishUnchangedResources()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var observedStates = new ConcurrentQueue<string?>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.Resource.Name == "database")
{
observedStates.Enqueue(context.Status.State);
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
// Park the container in a terminal state it will never leave, which is the situation
// described in https://github.com/microsoft/aspire/issues/18869.
container.Status = new ContainerStatus { State = ContainerState.FailedToStart };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.FailedToStart),
"The state change to FailedToStart should be reported.");
// Re-establishing a watch replays every existing object, over and over for as long as the
// AppHost runs. None of those replays describe a change.
kubernetesService.SimulateWatchRestart();
kubernetesService.SimulateWatchRestart();
// A real change afterwards acts as a barrier: the container watch is processed in order, so
// once Exited is observed the replays above have already been handled or dropped.
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => observedStates.Contains(ContainerState.Exited),
"The state change to Exited should be reported.");
AssertStatesReportedAfter(observedStates, ContainerState.FailedToStart, ContainerState.Exited);
}
[Fact]
public async Task ResourceWatch_FailedToStartLogsAreRetriedForEachChangedTerminalNotification()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var flushStreamOpens = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container { Status.State: ContainerState.FailedToStart } &&
logStreamType == Logs.StreamTypeSystem &&
follow == false)
{
Interlocked.Increment(ref flushStreamOpens);
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
string? dcpResourceName = null;
var flushesAtNotification = new ConcurrentQueue<int>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
// The flush completes before the resource change is published, so the count read here
// includes any flush performed for this notification.
if (context.DcpResourceName == dcpResourceName && context.Status.State == ContainerState.FailedToStart)
{
flushesAtNotification.Enqueue(Volatile.Read(ref flushStreamOpens));
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, resourceLoggerService: resourceLoggerService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
using var subscription = resourceLoggerService.Subscribe(dcpResourceName, _ => { });
container.Status = new ContainerStatus { State = ContainerState.FailedToStart };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => !flushesAtNotification.IsEmpty,
"The terminal state should be reported.");
// FailedToStart uses a point-in-time snapshot because there is no process stream that can
// complete. A later changed notification must retry in case DCP drained more logs meanwhile.
container.Status = new ContainerStatus { State = ContainerState.FailedToStart, ExitCode = 1 };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => flushesAtNotification.Count >= 2,
"The follow-up change should be reported.");
Assert.Equal([1, 2], flushesAtNotification.ToArray());
}
[Fact]
public async Task ResourceWatch_TerminalLogsAreFlushedOnlyOncePerTerminalPeriod()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var normalStreamStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var flushStreamOpens = 0;
var kubernetesService = new TestKubernetesService(startStreamWithFollow: (obj, logStreamType, follow) =>
{
if (obj is Container container &&
container.Status?.State != ContainerState.Exited &&
logStreamType == Logs.StreamTypeSystem &&
follow == true)
{
normalStreamStarted.TrySetResult();
}
if (obj is Container { Status.State: ContainerState.Exited } &&
logStreamType == Logs.StreamTypeSystem &&
follow == true)
{
Interlocked.Increment(ref flushStreamOpens);
}
return new MemoryStream();
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceLoggerService = new ResourceLoggerService();
string? dcpResourceName = null;
var flushesAtNotification = new ConcurrentQueue<int>();
var runningNotifications = 0;
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceChangedContext>(context =>
{
if (context.DcpResourceName == dcpResourceName)
{
if (context.Status.State == ContainerState.Exited)
{
flushesAtNotification.Enqueue(Volatile.Read(ref flushStreamOpens));
}
else if (context.Status.State == ContainerState.Running)
{
Interlocked.Increment(ref runningNotifications);
}
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, resourceLoggerService: resourceLoggerService, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
dcpResourceName = container.Metadata.Name;
using var subscription = resourceLoggerService.Subscribe(dcpResourceName, _ => { });
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await normalStreamStarted.Task.DefaultTimeout();
container.Status = new ContainerStatus { State = ContainerState.Exited };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => !flushesAtNotification.IsEmpty,
"The first terminal state should be reported.");
container.Status = new ContainerStatus { State = ContainerState.Exited, ExitCode = 1 };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => flushesAtNotification.Count >= 2,
"The changed terminal state should be reported.");
container.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => Volatile.Read(ref runningNotifications) >= 2,
"The resource restart should be reported.");
container.Status = new ContainerStatus { State = ContainerState.Exited, ExitCode = 2 };
kubernetesService.PushResourceModified(container);
await AsyncTestHelpers.AssertIsTrueRetryAsync(
() => flushesAtNotification.Count >= 3,
"The terminal state after restart should be reported.");
Assert.Equal([1, 1, 2], flushesAtNotification.ToArray());
}
/// <summary>
/// Asserts that the only states reported after the first occurrence of <paramref name="afterState"/>
/// are <paramref name="expectedStates"/>.
/// </summary>
/// <remarks>
/// Anchoring on a state instead of clearing the queue keeps the assertion independent of the
/// notifications raised while the app was starting, without racing against the watcher. The queue
/// must not be cleared here: <see cref="ConcurrentQueue{T}.Clear"/> is not safe to call while
/// another thread is still enqueuing.
/// </remarks>
private static void AssertStatesReportedAfter(ConcurrentQueue<string?> observedStates, string afterState, params string?[] expectedStates)
{
var states = observedStates.ToArray();
var anchor = Array.IndexOf(states, afterState);
Assert.True(anchor >= 0, $"Expected '{afterState}' to be reported but saw: {string.Join(", ", states)}");
Assert.Equal(expectedStates, states.Skip(anchor + 1));
}
[Fact]
public async Task ResourceLogging_SystemStream_FormatsWithSysPrefix()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService(startStream: (obj, logStreamType) =>
{
switch (logStreamType)
{
case Logs.StreamTypeStdOut:
return new MemoryStream();
case Logs.StreamTypeStdErr:
return new MemoryStream();
case Logs.StreamTypeStartupStdOut:
return new MemoryStream();
case Logs.StreamTypeStartupStdErr:
return new MemoryStream();
case Logs.StreamTypeSystem:
// Simulate real DCP system log format with JSON metadata
var systemLogs =
"2024-08-19T06:10:01.000Z\tinfo\tdcp.ExecutableReconciler\tStarting process...\t{\"Executable\": \"/foo-pwrqgpew\", \"Reconciliation\": 4, \"Cmd\": \"bla\", \"Args\": []}" + Environment.NewLine +
"2024-08-19T06:10:02.000Z\terror\tdcp.ExecutableReconciler\tFailed to start process\t{\"Executable\": \"/foo-pwrqgpew\", \"Reconciliation\": 4, \"Cmd\": \"bla\", \"Args\": [], \"error\": \"exec: \\\"bla\\\": executable file not found in $PATH\"}" + Environment.NewLine;
return new MemoryStream(Encoding.UTF8.GetBytes(systemLogs));
default:
throw new InvalidOperationException("Unexpected type: " + logStreamType);
}
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync();
var exeResource = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
// Start watching logs for container
var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync();
var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator();
var watchLogs = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
// Wait for at least 3 logs (there might be additional logs like certificate authority messages)
var watchLogsTask = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs, targetLogCount: 3);
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.True(watchSubscribersEnumerator.Current.AnySubscribers);
exeResource.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(exeResource);
var watchLogsResults = await watchLogsTask;
Assert.True(watchLogsResults.Count >= 2, $"Expected at least 2 log entries, got {watchLogsResults.Count}");
// Verify the system logs are formatted with [sys] prefix and proper formatting
Assert.Contains(watchLogsResults, l => l.Content.Contains("[sys] Starting process...: Cmd = bla, Args = []"));
Assert.Contains(watchLogsResults, l => l.Content.Contains("[sys] Failed to start process: Cmd = bla, Args = [], Error = exec: \"bla\": executable file not found in $PATH"));
Assert.Contains(watchLogsResults, l => l.Content.Contains("2024-08-19T06:10:01.0000000Z [sys] Starting process...: Cmd = bla, Args = []"));
Assert.Contains(watchLogsResults, l => l.Content.Contains("2024-08-19T06:10:02.0000000Z [sys] Failed to start process: Cmd = bla, Args = [], Error = exec: \"bla\": executable file not found in $PATH"));
}
[Fact]
public async Task ResourceLogging_CarriageReturnProgressOutput_NormalizesOverwrittenLines()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService(startStream: (obj, logStreamType) =>
{
switch (logStreamType)
{
case Logs.StreamTypeStdOut:
var stdout =
"2024-08-19T06:10:01.000Z 0%\r 50%\r100%" + Environment.NewLine +
"2024-08-19T06:10:02.000Z Windows line" + "\r\n" +
"2024-08-19T06:10:03.000Z Done" + Environment.NewLine;
return new MemoryStream(Encoding.UTF8.GetBytes(stdout));
case Logs.StreamTypeStdErr:
case Logs.StreamTypeStartupStdOut:
case Logs.StreamTypeStartupStdErr:
case Logs.StreamTypeSystem:
return new MemoryStream();
default:
throw new InvalidOperationException("Unexpected type: " + logStreamType);
}
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync();
var exeResource = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync();
var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator();
var watchLogs = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
// Wait for all three stdout records plus the certificate authority message, which can arrive first in CI.
var watchLogsTask = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs, targetLogCount: 4);
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.True(watchSubscribersEnumerator.Current.AnySubscribers);
exeResource.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(exeResource);
var watchLogsResults = await watchLogsTask;
Assert.Contains(watchLogsResults, l => l.Content.Contains("2024-08-19T06:10:01.0000000Z 100%"));
Assert.Contains(watchLogsResults, l => l.Content.Contains("2024-08-19T06:10:02.0000000Z Windows line"));
Assert.Contains(watchLogsResults, l => l.Content.Contains("2024-08-19T06:10:03.0000000Z Done"));
Assert.DoesNotContain(watchLogsResults, l => l.Content.Contains(" 0%"));
Assert.DoesNotContain(watchLogsResults, l => l.Content.Contains("50%"));
}
[Fact]
public async Task ResourceLogging_SystemStreamWithCarriageReturnInMessage_ParsesCorrectly()
{
// Regression test: NormalizeCarriageReturns must not be applied to the full DCP raw line
// before parsing; doing so would corrupt the tab-delimited structure and cause the parser
// to fail, dropping the [sys] prefix and timestamp.
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService(startStream: (obj, logStreamType) =>
{
switch (logStreamType)
{
case Logs.StreamTypeStdOut:
case Logs.StreamTypeStdErr:
case Logs.StreamTypeStartupStdOut:
case Logs.StreamTypeStartupStdErr:
return new MemoryStream();
case Logs.StreamTypeSystem:
// A DCP log line whose message content contains \r (e.g. a progress-style
// overwrite inside a system log message). The tab-delimited header must
// be parsed first so the \r normalization only applies to the message part.
var systemLogs =
"2024-08-19T06:10:01.000Z\tinfo\tdcp.ExecutableReconciler\tfirst\rsecond\rthird" + Environment.NewLine;
return new MemoryStream(Encoding.UTF8.GetBytes(systemLogs));
default:
throw new InvalidOperationException("Unexpected type: " + logStreamType);
}
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard" };
var resourceLoggerService = new ResourceLoggerService();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, resourceLoggerService: resourceLoggerService);
await appExecutor.RunApplicationAsync();
var exeResource = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync();
var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator();
var watchLogs = resourceLoggerService.WatchAsync(exeResource.Metadata.Name);
var watchLogsTask = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs, targetLogCount: 2); // 1 DCP system log + 1 certificate authority message
await watchSubscribersEnumerator.MoveNextAsync();
Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name);
Assert.True(watchSubscribersEnumerator.Current.AnySubscribers);
exeResource.Status = new ContainerStatus { State = ContainerState.Running };
kubernetesService.PushResourceModified(exeResource);
var watchLogsResults = await watchLogsTask;
// The entry should be parsed as a DCP [sys] log (not plain stdout) with the
// last \r-overwritten segment ("third") preserved as the message content.
Assert.Contains(watchLogsResults, l => l.Content.Contains("[sys] third"));
Assert.DoesNotContain(watchLogsResults, l => l.Content.Contains("first"));
}
private sealed class LogStreamPipes
{
public Pipe StandardOut { get; set; } = default!;
public Pipe StandardErr { get; set; } = default!;
public Pipe StartupOut { get; set; } = default!;
public Pipe StartupErr { get; set; } = default!;
public Pipe System { get; set; } = default!;
}
private static async Task<LogStreamPipes> GetStreamPipesAsync(Channel<(string Type, Pipe Pipe)> logStreamPipesChannel)
{
var pipeCount = 0;
var result = new LogStreamPipes();
await foreach (var item in logStreamPipesChannel.Reader.ReadAllAsync())
{
switch (item.Type)
{
case Logs.StreamTypeStdOut:
result.StandardOut = item.Pipe;
break;
case Logs.StreamTypeStdErr:
result.StandardErr = item.Pipe;
break;
case Logs.StreamTypeStartupStdOut:
result.StartupOut = item.Pipe;
break;
case Logs.StreamTypeStartupStdErr:
result.StartupErr = item.Pipe;
break;
case Logs.StreamTypeSystem:
result.System = item.Pipe;
break;
default:
throw new InvalidOperationException("Unexpected type: " + item.Type);
}
pipeCount++;
if (pipeCount == 5)
{
logStreamPipesChannel.Writer.Complete();
}
}
return result;
}
[Fact]
public async Task EndpointPortsProjectNoPortNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA")
.WithEndpoint(name: "NoPortNoTargetPort", env: "NO_PORT_NO_TARGET_PORT", isProxied: true)
.WithHttpEndpoint(name: "hp1", port: 5001)
.WithHttpEndpoint(name: "dontinjectme", port: 5002)
.WithEndpointsInEnvironment(e => e.Name != "dontinjectme")
.WithReplicas(3);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exes = GetCreatedExecutablesForResource(kubernetesService, "ServiceA");
Assert.Equal(3, exes.Count);
foreach (var dcpExe in exes)
{
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Neither Port, nor TargetPort are set
// Clients use proxy, MAY have the proxy port injected.
// Proxy gets autogenerated port.
// Each replica gets a different autogenerated port that MUST be injected via env var/startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "ServiceA-NoPortNoTargetPort");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-NoPortNoTargetPort").Port is null,
"Expected service producer (target) port to not be set (leave allocation to DCP)");
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "ServiceA-NoPortNoTargetPort" """, envVarVal);
// ASPNETCORE_URLS should not include dontinjectme, as it was excluded using WithEndpointsInEnvironment
var aspnetCoreUrls = dcpExe.Spec.Env?.Single(v => v.Name == KnownAspNetCoreConfigNames.Urls).Value;
Assert.Equal("http://localhost:{{- portForServing \"ServiceA-http\" -}};http://localhost:{{- portForServing \"ServiceA-hp1\" -}}", aspnetCoreUrls);
}
}
[Fact]
public async Task EndpointPortsProjectPortSetNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
const int desiredPortOne = TestKubernetesService.StartOfAutoPortRange - 1000;
builder.AddProject<Projects.ServiceA>("ServiceA")
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPortOne, env: "PORT_SET_NO_TARGET_PORT")
.WithReplicas(3);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exes = GetCreatedExecutablesForResource(kubernetesService, "ServiceA");
Assert.Equal(3, exes.Count);
foreach (var dcpExe in exes)
{
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is set, but TargetPort is empty.
// Clients use proxy, MAY have the proxy port injected.
// Proxy uses Port.
// Each replica gets a different autogenerated port that MUST be injected via env var/startup param.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "ServiceA-PortSetNoTargetPort");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPortOne, svc.Status?.EffectivePort);
Assert.True(spAnnList.Single(ann => ann.ServiceName == "ServiceA-PortSetNoTargetPort").Port is null,
"Expected service producer (target) port to not be set (leave allocation to DCP)");
var envVarVal = dcpExe.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "ServiceA-PortSetNoTargetPort" """, envVarVal);
}
}
[Fact]
public async Task EndpointPortsProjectWithEndpointProxySupportUsesProxylessEndpoint()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1001;
builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithHttpEndpoint(name: "stable", port: desiredPort)
.WithEndpointProxySupport(false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpExe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "ServiceA");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "ServiceA").Port);
var aspnetCoreUrls = dcpExe.Spec.Env?.Single(v => v.Name == KnownAspNetCoreConfigNames.Urls).Value;
Assert.Equal($"http://localhost:{desiredPort}", aspnetCoreUrls);
}
[Fact]
public async Task EndpointPortsPersistentProjectDefaultsToProxylessEndpoint()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1002;
builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithPersistentLifetime()
.WithHttpEndpoint(name: "stable", port: desiredPort);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var dcpExe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "ServiceA");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "ServiceA").Port);
var aspnetCoreUrls = dcpExe.Spec.Env?.Single(v => v.Name == KnownAspNetCoreConfigNames.Urls).Value;
Assert.Equal($"http://localhost:{desiredPort}", aspnetCoreUrls);
}
[Fact]
public async Task EndpointPortsPersistentProjectDefaultsToProxiedEndpointWhenPortsAreRandomized()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1002;
builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithPersistentLifetime()
.WithHttpEndpoint(name: "stable", port: desiredPort);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", RandomizePorts = true };
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, configuration: configuration);
await appExecutor.RunApplicationAsync();
var dcpExe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.True(dcpExe.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "ServiceA");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Null(svc.Spec.Port);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
Assert.NotEqual(desiredPort, svc.Status?.EffectivePort);
Assert.Null(spAnnList.Single(ann => ann.ServiceName == "ServiceA").Port);
var aspnetCoreUrls = dcpExe.Spec.Env?.Single(v => v.Name == KnownAspNetCoreConfigNames.Urls).Value;
Assert.Contains("""portForServing "ServiceA" """, aspnetCoreUrls);
}
[Fact]
public async Task EndpointPortsConainerProxiedNoPortTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, env: "NO_PORT_TARGET_PORT_SET", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port is empty, TargetPort is set
// Clients use proxy, MAY have the proxy port injected.
// Proxy gets autogenerated port.
// Container is using TargetPort inside the container. Container host port is auto-allocated by Docker/Podman.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.True(svc.Status?.EffectivePort >= TestKubernetesService.StartOfAutoPortRange);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort is null && p.ContainerPort == desiredTargetPort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "NO_PORT_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerProxiedPortAndTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 997;
builder.AddContainer("database", "image")
.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPort, targetPort: desiredTargetPort, env: "PORT_AND_TARGET_PORT_SET", isProxied: true);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port and TargetPort are set.
// Clients use proxy, MAY have the proxy port injected.
// Proxy uses Port.
// Container is using TargetPort inside the container. Container host port is auto-allocated by Docker/Podman.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Localhost, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort is null && p.ContainerPort == desiredTargetPort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PORT_AND_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
/// <summary>
/// Verifies that applying unsupported endpoint port configuration to Containers results in an error.
/// </summary>
[Fact]
public async Task UnsupportedEndpointPortsContainer()
{
const int desiredPortOne = TestKubernetesService.StartOfAutoPortRange - 1000;
(Action<IResourceBuilder<ContainerResource>> AddEndpoint, string ErrorMessageFragment)[] testcases = [
// Invalid configuration: TargetPort is empty (and Port too) (proxied).
(
cr => cr.WithEndpoint(name: "NoPortNoTargetPortProxied", env: "NO_PORT_NO_TARGET_PORT_PROXIED", isProxied: true),
"must specify the TargetPort"
),
// Invalid configuration: TargetPort is empty (Port is set but it should not matter) (proxied).
(
cr => cr.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPortOne, env: "PORT_SET_NO_TARGET_PORT", isProxied: true),
"must specify the TargetPort"
),
// Invalid configuration: TargetPort is empty (and Port too) (proxy-less).
(
cr => cr.WithEndpoint(name: "NoPortNoTargetPortProxyless", env: "NO_PORT_NO_TARGET_PORT_PROXYLESS", isProxied: false),
"must specify the TargetPort"
),
// Invalid configuration: Port requests dynamic allocation, but no container target port is available.
(
cr => cr.WithEndpoint(name: "ZeroPortNoTargetPortProxyless", port: 0, env: "ZERO_PORT_NO_TARGET_PORT_PROXYLESS", isProxied: false),
"must specify the TargetPort"
),
];
foreach (var tc in testcases)
{
var builder = DistributedApplication.CreateBuilder();
var ctr = builder.AddContainer("database", "image");
tc.AddEndpoint(ctr);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => appExecutor.RunApplicationAsync());
Assert.Contains(tc.ErrorMessageFragment, exception.Message);
}
}
[Fact]
public async Task EndpointPortsContainerProxylessPortSetNoTargetPort()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 1000;
builder.AddContainer("database", "image")
.WithEndpoint(name: "PortSetNoTargetPort", port: desiredPort, env: "PORT_SET_NO_TARGET_PORT", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Neither Port, nor TargetPort are set.
// Clients connect directly to the container host port, MAY have the container host port injected.
// Container is using TargetPort for BOTH listening inside the container and as a host port.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredPort && p.ContainerPort == desiredPort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PORT_SET_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, env: "NO_PORT_TARGET_PORT_SET", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(allocatedPort, svc.Spec.Port);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "NO_PORT_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSetPublishesAllocatedEndpoint()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
var database = builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false);
var allocatedPortChannel = Channel.CreateUnbounded<int>();
var connectionStringAvailableChannel = Channel.CreateUnbounded<IResource>();
var observedEvents = new ConcurrentQueue<string>();
var eventing = new Hosting.Eventing.DistributedApplicationEventing();
eventing.Subscribe<ResourceEndpointsAllocatedEvent>((@event, ct) =>
{
if (@event.Resource.Name == "database")
{
observedEvents.Enqueue(nameof(ResourceEndpointsAllocatedEvent));
var endpoint = ((IResourceWithEndpoints)@event.Resource).GetEndpoint("NoPortTargetPortSet");
if (endpoint.AllocatedEndpoint is { } allocatedEndpoint)
{
allocatedPortChannel.Writer.TryWrite(allocatedEndpoint.Port);
}
}
return Task.CompletedTask;
});
var events = new DcpExecutorEvents();
events.Subscribe<OnConnectionStringAvailableContext>(context =>
{
if (context.Resource.Name == "database")
{
observedEvents.Enqueue(nameof(OnConnectionStringAvailableContext));
connectionStringAvailableChannel.Writer.TryWrite(context.Resource);
}
return Task.CompletedTask;
});
events.Subscribe<OnResourceStartingContext>(context =>
{
if (context.Resource.Name == "database")
{
observedEvents.Enqueue(nameof(OnResourceStartingContext));
}
return Task.CompletedTask;
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events, distributedApplicationEventing: eventing);
await appExecutor.RunApplicationAsync();
var allocatedPort = await allocatedPortChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
var connectionStringAvailableResource = await connectionStringAvailableChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Same(database.Resource, connectionStringAvailableResource);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
Assert.Equal(allocatedPort, svc.Status?.EffectivePort);
Assert.Equal(allocatedPort, svc.Spec.Port);
Assert.NotEqual(desiredTargetPort, allocatedPort);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.Equal(allocatedPort.ToString(CultureInfo.InvariantCulture), await database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port).GetValueAsync());
Assert.Collection(
observedEvents,
eventName => Assert.Equal(nameof(ResourceEndpointsAllocatedEvent), eventName),
eventName => Assert.Equal(nameof(OnConnectionStringAvailableContext), eventName),
eventName => Assert.Equal(nameof(OnResourceStartingContext), eventName));
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSetAllocatesHostPortAndInjectsTargetPortForContainerSelfReference()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
var database = builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false);
database.WithEnvironment("PUBLIC_PORT", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port));
database.WithEnvironment("PUBLIC_PORT_AGAIN", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.Port));
var connectionStringAvailableChannel = Channel.CreateUnbounded<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnConnectionStringAvailableContext>(context =>
{
if (context.Resource.Name == "database")
{
connectionStringAvailableChannel.Writer.TryWrite(context.Resource);
}
return Task.CompletedTask;
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
var connectionStringAvailableResource = await connectionStringAvailableChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Same(database.Resource, connectionStringAvailableResource);
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal(allocatedPort, svc.Spec.Port);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
var secondEnvVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_PORT_AGAIN").Value;
Assert.False(string.IsNullOrWhiteSpace(secondEnvVarVal));
Assert.Equal(desiredTargetPort, int.Parse(secondEnvVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSetAllocatesHostPortAndInjectsTargetHostAndPortForContainerSelfReference()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
var database = builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", targetPort: desiredTargetPort, isProxied: false);
database.WithEnvironment("PUBLIC_HOST_AND_PORT", database.GetEndpoint("NoPortTargetPortSet").Property(EndpointProperty.HostAndPort));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal(allocatedPort, svc.Spec.Port);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PUBLIC_HOST_AND_PORT").Value;
Assert.Equal($"database.dev.internal:{desiredTargetPort}", envVarVal);
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSetCanBeResolvedWhileDependentResourceIsStarting()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
var client = builder.AddContainer("client", "image");
var database = builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", scheme: "http", targetPort: desiredTargetPort, isProxied: false)
.WaitFor(client);
var resolvedUrlChannel = Channel.CreateUnbounded<string?>();
var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run);
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(async context =>
{
if (ReferenceEquals(context.Resource, client.Resource))
{
var url = await database.GetEndpoint("NoPortTargetPortSet").GetValueAsync(new ValueProviderContext
{
Caller = context.Resource,
ExecutionContext = executionContext
}, context.CancellationToken).ConfigureAwait(false);
resolvedUrlChannel.Writer.TryWrite(url);
}
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
var resolvedUrl = await resolvedUrlChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
var dcpCtr = kubernetesService.CreatedResources.OfType<Container>().Single(c => c.AppModelResourceName == "database");
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal($"http://database.dev.internal:{desiredTargetPort}", resolvedUrl);
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(allocatedPort, svc.Spec.Port);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
}
[Fact]
public async Task EndpointPortsContainerProxylessNoPortTargetPortSetCanBeResolvedWithoutCallerWhileDependentResourceIsStarting()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 999;
var client = builder.AddContainer("client", "image");
var database = builder.AddContainer("database", "image")
.WithEndpoint(name: "NoPortTargetPortSet", scheme: "http", targetPort: desiredTargetPort, isProxied: false)
.WaitFor(client);
var resolvedUrlChannel = Channel.CreateUnbounded<string?>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(async context =>
{
if (ReferenceEquals(context.Resource, client.Resource))
{
var url = await database.GetEndpoint("NoPortTargetPortSet").GetValueAsync(context.CancellationToken).ConfigureAwait(false);
resolvedUrlChannel.Writer.TryWrite(url);
}
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
var resolvedUrl = await resolvedUrlChannel.Reader.ReadAsync().AsTask().DefaultTimeout();
var dcpCtr = kubernetesService.CreatedResources.OfType<Container>().Single(c => c.AppModelResourceName == "database");
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
var allocatedPort = Assert.IsType<int>(svc.Status?.EffectivePort);
Assert.Equal($"http://localhost:{allocatedPort}", resolvedUrl);
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(allocatedPort, svc.Spec.Port);
AssertPortAllocatedFromProxylessEndpointAllocatorRange(allocatedPort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == allocatedPort && p.ContainerPort == desiredTargetPort);
}
[Fact]
public async Task ResourceEndpointsAllocatedEventSubscribersBlockDcpStartup()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("database", "image")
.WithHttpEndpoint(targetPort: 8080);
var subscriberEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseSubscriber = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var eventing = new Hosting.Eventing.DistributedApplicationEventing();
eventing.Subscribe<ResourceEndpointsAllocatedEvent>(async (@event, ct) =>
{
if (@event.Resource.Name == "database")
{
subscriberEntered.TrySetResult();
await releaseSubscriber.Task.WaitAsync(ct).ConfigureAwait(false);
}
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, distributedApplicationEventing: eventing);
var runTask = appExecutor.RunApplicationAsync();
await subscriberEntered.Task.DefaultTimeout();
var startupWasBlocked = !runTask.IsCompleted;
releaseSubscriber.SetResult();
await runTask.DefaultTimeout();
Assert.True(startupWasBlocked);
}
[Fact]
public async Task EndpointPortsContainerProxylessPortAndTargetPortSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 997;
builder.AddContainer("database", "image")
.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPort, targetPort: desiredTargetPort, env: "PORT_AND_TARGET_PORT_SET", isProxied: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port and TargetPort are set.
// Clients connect directly to the container host port, MAY have the container host port injected.
// Container is using TargetPort for listening inside the container and the Port as the host port.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredPort && p.ContainerPort == desiredTargetPort);
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PORT_AND_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerWithEndpointProxySupportOverridesExplicitProxiedEndpoint()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 997;
builder.AddContainer("database", "image")
.WithEndpoint(name: "PortAndTargetPortSet", port: desiredPort, targetPort: desiredTargetPort, env: "PORT_AND_TARGET_PORT_SET", isProxied: true)
.WithEndpointProxySupport(false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredPort && p.ContainerPort == desiredTargetPort);
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PORT_AND_TARGET_PORT_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task EndpointPortsContainerProxylessProtocolSet()
{
var builder = DistributedApplication.CreateBuilder();
const int desiredPort = TestKubernetesService.StartOfAutoPortRange - 998;
const int desiredTargetPort = TestKubernetesService.StartOfAutoPortRange - 997;
builder.AddContainer("database", "image")
.WithEndpoint(name: "PortAndProtocolSet", port: desiredPort, targetPort: desiredTargetPort, env: "PORT_AND_PROTOCOL_SET", isProxied: false, protocol: System.Net.Sockets.ProtocolType.Udp);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(dcpCtr.TryGetAnnotationAsObjectList<ServiceProducerAnnotation>(CustomResource.ServiceProducerAnnotation, out var spAnnList));
// Port and TargetPort are set.
// Clients connect directly to the container host port, MAY have the container host port injected.
// Container is using TargetPort for listening inside the container and the Port as the host port.
var svc = kubernetesService.CreatedResources.OfType<Service>().Single(s => s.Name() == "database");
Assert.Equal(AddressAllocationModes.Proxyless, svc.Spec.AddressAllocationMode);
Assert.Equal(desiredPort, svc.Status?.EffectivePort);
Assert.NotNull(dcpCtr.Spec.Ports);
Assert.Contains(dcpCtr.Spec.Ports!, p => p.HostPort == desiredPort && p.ContainerPort == desiredTargetPort && p.Protocol == "UDP");
// Desired port should be part of the service producer annotation.
Assert.Equal(desiredTargetPort, spAnnList.Single(ann => ann.ServiceName == "database").Port);
var envVarVal = dcpCtr.Spec.Env?.Single(v => v.Name == "PORT_AND_PROTOCOL_SET").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Equal(desiredTargetPort, int.Parse(envVarVal, CultureInfo.InvariantCulture));
}
[Fact]
public async Task ErrorIfResourceNotDeletedBeforeRestart()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("database", "image");
var kubernetesService = new TestKubernetesService(ignoreDeletes: true);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpEvents = new DcpExecutorEvents();
var tcs = new TaskCompletionSource<OnResourceFailedToStartContext>(TaskCreationOptions.RunContinuationsAsynchronously);
dcpEvents.Subscribe<OnResourceFailedToStartContext>(c =>
{
tcs.SetResult(c);
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: dcpEvents);
// Set a custom pipeline without retries or delays to avoid waiting.
appExecutor.DeleteResourceRetryPipeline = new ResiliencePipelineBuilder<bool>().Build();
await appExecutor.RunApplicationAsync();
var dcpCtr = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var resourceReference = appExecutor.GetResource(dcpCtr.Metadata.Name);
var ex = await Assert.ThrowsAsync<DistributedApplicationException>(async () => await appExecutor.StartResourceAsync(resourceReference, CancellationToken.None));
Assert.Equal($"Failed to delete '{dcpCtr.Metadata.Name}' successfully before restart.", ex.Message);
var failedContext = await tcs.Task.DefaultTimeout();
Assert.Equal(ex.Message, failedContext.ErrorMessage);
}
[Fact]
public async Task AddsDefaultsCommandsToResources()
{
var builder = DistributedApplication.CreateBuilder();
var container = builder.AddContainer("database", "image");
var exe = builder.AddExecutable("node", "node.exe", ".");
var project = builder.AddProject<TestProject>("project");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
HasKnownCommandAnnotations(exe.Resource);
HasKnownCommandAnnotations(container.Resource);
HasKnownProjectCommandAnnotations(project.Resource);
}
[Fact]
public async Task ContainersArePassedExpectedImagePullPolicy()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("ImplicitDefault", "container");
builder.AddContainer("ExplicitDefault", "container").WithImagePullPolicy(ImagePullPolicy.Default);
builder.AddContainer("ExplicitAlways", "container").WithImagePullPolicy(ImagePullPolicy.Always);
builder.AddContainer("ExplicitMissing", "container").WithImagePullPolicy(ImagePullPolicy.Missing);
builder.AddContainer("ExplicitNever", "container").WithImagePullPolicy(ImagePullPolicy.Never);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
// Act
await appExecutor.RunApplicationAsync();
// Assert
Assert.Equal(5, kubernetesService.CreatedResources.OfType<Container>().Count());
var implicitDefaultContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "ImplicitDefault");
Assert.Null(implicitDefaultContainer.Spec.PullPolicy);
var explicitDefaultContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "ExplicitDefault");
Assert.Null(explicitDefaultContainer.Spec.PullPolicy);
var explicitAlwaysContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "ExplicitAlways");
Assert.Equal(ContainerPullPolicy.Always, explicitAlwaysContainer.Spec.PullPolicy);
var explicitMissingContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "ExplicitMissing");
Assert.Equal(ContainerPullPolicy.Missing, explicitMissingContainer.Spec.PullPolicy);
var explicitNeverContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "ExplicitNever");
Assert.Equal(ContainerPullPolicy.Never, explicitNeverContainer.Spec.PullPolicy);
}
[Theory]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("[::1]", "[::1]")]
[InlineData("localhost", "localhost")]
[InlineData("0.0.0.0", "localhost")]
[InlineData("[::]", "localhost")]
[InlineData("machine-name", "localhost")]
[InlineData("10.0.0.1", "10.0.0.1")]
public async Task ServiceProducerHasCorrectAddress(string bindingAddress, string serviceAddress)
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("CustomName", "container")
.WithHttpEndpoint(port: 5000, targetPort: 5000, name: "customendpoint")
.WithEndpoint("customendpoint", (endpoint) =>
{
endpoint.TargetHost = bindingAddress;
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
var annotations = container.Metadata.EnsureAnnotations();
var serviceProducers = JsonSerializer.Deserialize<List<ServiceProducerAnnotation>>(annotations[CustomResource.ServiceProducerAnnotation]);
Assert.NotNull(serviceProducers);
var serviceProducer = Assert.Single(serviceProducers);
Assert.Equal(serviceAddress, serviceProducer.Address);
}
[Fact]
public async Task ProjectLaunchConfiguration_Populated_WhenLaunchProfileSpecified_InDebugSession()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345" // Force IDE execution path
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
// Act
await executor.RunApplicationAsync();
// Assert
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.NotNull(plc);
Assert.False(plc!.DisableLaunchProfile);
Assert.Equal("http", plc.LaunchProfile);
}
[Theory]
[InlineData("Debug", ExecutableLaunchMode.Debug)]
[InlineData("NoDebug", ExecutableLaunchMode.NoDebug)]
public async Task ProjectLaunchConfiguration_RespectsDebugSessionRunMode(string runMode, string expectedMode)
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionRunMode] = runMode
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.NotNull(plc);
Assert.Equal(expectedMode, plc!.Mode);
}
[Fact]
public async Task ProjectLaunchConfiguration_UsesProjectDebugSupportProducer_InDebugSession()
{
// The producer owns the whole launch configuration: nothing downstream overwrites what it returns,
// not even the project path, which differs here from the one on the resource's project metadata.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(_ => new ProjectLaunchConfiguration
{
Mode = ExecutableLaunchMode.NoDebug,
ProjectPath = "ProducerSuppliedPath",
DisableLaunchProfile = true
}, "project");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.NotNull(plc);
Assert.Equal("ProducerSuppliedPath", plc!.ProjectPath);
Assert.Equal(ExecutableLaunchMode.NoDebug, plc.Mode);
Assert.True(plc.DisableLaunchProfile);
}
[Fact]
public async Task ProjectLaunchConfiguration_Disabled_WhenLaunchProfileExcluded_InDebugSession()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Passing null launchProfileName applies ExcludeLaunchProfileAnnotation
builder.AddProject<Projects.ServiceA>("proj", launchProfileName: null);
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345" // Force IDE execution path
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
// Act
await executor.RunApplicationAsync();
// Assert
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.NotNull(plc);
Assert.True(plc!.DisableLaunchProfile);
Assert.Equal(string.Empty, plc.LaunchProfile);
}
[Fact]
public async Task ProjectLaunchConfiguration_DefaultLaunchProfileAnnotationFallsBack_WhenProfileMissing_InDebugSession()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Configure a default launch profile name that does NOT exist in TestProjectWithLaunchSettings (profiles: Foo, http)
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "DoesNotExistProfile";
builder.AddProject<TestProjectWithLaunchSettings>("proj");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345" // Force IDE execution path
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
// Act
await executor.RunApplicationAsync();
// Assert
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.NotNull(plc);
// Should have fallen back to the first available profile (in insertion order) which is Foo, not the missing one.
Assert.False(plc!.DisableLaunchProfile);
Assert.Equal("Foo", plc.LaunchProfile);
Assert.NotEqual("DoesNotExistProfile", plc.LaunchProfile);
// DOTNET_LAUNCH_PROFILE env var should reflect the effective profile name.
Assert.NotNull(exe.Spec.Env);
var effectiveLaunchProfileEnv = exe.Spec.Env.SingleOrDefault(v => v.Name == "DOTNET_LAUNCH_PROFILE")?.Value;
Assert.Equal("Foo", effectiveLaunchProfileEnv);
}
[Fact]
public async Task ProjectLaunchConfiguration_DefaultLaunchProfileAnnotationSelectsExisting_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "http"; // existing profile
builder.AddProject<TestProjectWithLaunchSettings>("proj");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.False(plc!.DisableLaunchProfile);
Assert.Equal("http", plc.LaunchProfile);
var envVal = exe.Spec.Env!.SingleOrDefault(e => e.Name == "DOTNET_LAUNCH_PROFILE")?.Value;
Assert.Equal("http", envVal);
}
[Fact]
public async Task ProjectLaunchConfiguration_ExplicitLaunchProfileOverridesDefault_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "Foo"; // default points to Foo
builder.AddProject<TestProjectWithLaunchSettings>("proj", launchProfileName: "http"); // explicit different
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.False(plc!.DisableLaunchProfile);
Assert.Equal("http", plc.LaunchProfile); // explicit wins
var envVal = exe.Spec.Env!.SingleOrDefault(e => e.Name == "DOTNET_LAUNCH_PROFILE")?.Value;
Assert.Equal("http", envVal);
}
[Fact]
public async Task ProjectLaunchConfiguration_DefaultIgnoredWhenExcluded_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "Foo";
builder.AddProject<TestProjectWithLaunchSettings>("proj", launchProfileName: null); // exclude
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.True(plc!.DisableLaunchProfile);
Assert.Equal(string.Empty, plc.LaunchProfile);
Assert.DoesNotContain(exe.Spec.Env ?? [], e => e.Name == "DOTNET_LAUNCH_PROFILE");
}
[Fact]
public async Task ProjectLaunchConfiguration_NoProfiles_NoLaunchProfileSelected_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.Configuration["AppHost:DefaultLaunchProfileName"] = "Foo"; // won't match anything
builder.AddProject<TestProjectNoProfiles>("proj");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.False(plc!.DisableLaunchProfile); // not excluded
Assert.Equal(string.Empty, plc.LaunchProfile); // nothing selected
Assert.DoesNotContain(exe.Spec.Env ?? [], e => e.Name == "DOTNET_LAUNCH_PROFILE");
}
[Fact]
public async Task ProjectLaunchConfiguration_FallbackToFirstProfileInsertionOrder_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<TestProjectMultiProfileOrder>("proj");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.False(plc!.DisableLaunchProfile);
Assert.Equal("Zed", plc.LaunchProfile); // first inserted wins
}
[Fact]
public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnvironmentVariables()
{
var builder = DistributedApplication.CreateBuilder();
LaunchConfigurationCallbackContext? launchContext = null;
var environmentCallbackInvocationCount = 0;
var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["test"]
});
builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithEnvironment(context =>
{
var currentInvocation = Interlocked.Increment(ref environmentCallbackInvocationCount);
context.EnvironmentVariables["DEBUG_VALUE"] = $"resolved-{currentInvocation}";
})
.WithDebugSupport(
context =>
{
launchContext = context;
return Task.FromResult(new ExecutableLaunchConfiguration("test")
{
Mode = context.Mode
});
},
"test");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration);
using var cts = new CancellationTokenSource();
await appExecutor.RunApplicationAsync(cts.Token);
Assert.NotNull(launchContext);
Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode);
Assert.Same(resource, launchContext.Resource);
Assert.Equal(cts.Token, launchContext.CancellationToken);
var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name);
var debugValue = Assert.Single(executable.Spec.Env!, variable => variable.Name == "DEBUG_VALUE").Value;
Assert.Equal(1, Volatile.Read(ref environmentCallbackInvocationCount));
Assert.Equal("resolved-1", debugValue);
Assert.Equal(debugValue, launchContext.EnvironmentVariables["DEBUG_VALUE"]);
Assert.Equal(["app-arg"], executable.Spec.Args);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test");
var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2");
// No SupportsDebuggingAnnotation for this one
builder.AddResource(nonDebuggableExecutable);
// Simulate debug session port and extension endpoint (extension mode)
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
List<Executable> dcpExes = [];
var haveExes = RetryTillTrueOrTimeout(() =>
{
dcpExes.Clear();
dcpExes.AddRange(kubernetesService.CreatedResources.OfType<Executable>());
return dcpExes.Count == 2;
}, TestConstants.DefaultOrchestratorTestTimeout);
Assert.True(haveExes, $"Expected two running but instead got {dcpExes.Count}");
var debuggableExe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.IDE, debuggableExe.Spec.ExecutionType);
Assert.Null(debuggableExe.Spec.FallbackExecutionTypes);
Assert.True(debuggableExe.TryGetAnnotationAsObjectList<ExecutableLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs1));
var config1 = Assert.Single(launchConfigs1);
Assert.Equal(ExecutableLaunchMode.Debug, config1.Mode);
Assert.Equal("test", config1.Type);
var nonDebuggableExe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestOtherExecutable");
Assert.Equal(ExecutionType.Process, nonDebuggableExe.Spec.ExecutionType);
Assert.False(nonDebuggableExe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out _));
}
[Fact]
public async Task PersistentPlainExecutable_ExtensionMode_RunsInProcess()
{
var builder = DistributedApplication.CreateBuilder();
var executable = new TestExecutableResource("test-working-directory");
builder.AddResource(executable)
.WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test")
.WithPersistentLifetime();
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "TestExecutable");
Assert.Equal("TestExecutable-12345678", exe.Metadata.Name);
Assert.True(exe.Spec.Persistent.GetValueOrDefault());
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task ProjectResource_WithLaunchToolArgs_ReplacesDotnetRunScaffolding_InProcessMode()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http")
.WithLaunchToolArgs(ctx =>
{
ctx.Args.Add("tool");
ctx.Args.Add("exec");
ctx.Args.Add("package");
ctx.Args.Add("--");
})
.WithArgs("app-arg");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var executor = CreateAppExecutor(model, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
var expectedArgs = new[] { "tool", "exec", "package", "--", "--profile-arg", "profile value", "app-arg" };
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal(expectedArgs, exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(expectedArgs, argAnnotations.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, exe.Spec.Args);
}
[Fact]
public async Task ProjectResource_WithDotnetToolRunLaunchArgs_DoesNotInjectProjectLaunchOptions_InProcessMode()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http")
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/project"
})
.WithLaunchToolArgs(ctx =>
{
ctx.Args.Add("tool");
ctx.Args.Add("run");
ctx.Args.Add("package");
ctx.Args.Add("--");
})
.WithArgs("app-arg");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var executor = CreateAppExecutor(model, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
var expectedArgs = new[] { "tool", "run", "package", "--", "--profile-arg", "profile value", "app-arg" };
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal(expectedArgs, exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Equal(expectedArgs, argAnnotations.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, exe.Spec.Args);
}
[Fact]
public async Task ProjectResource_EmptyLaunchToolArgs_KeepsDotnetRunScaffolding_InProcessMode()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<TestProject>("proj", launchProfileName: null)
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { });
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var executor = CreateAppExecutor(model, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
AssertDefaultProjectProcessArgs(exe.Spec.Args, "app-arg");
}
[Fact]
public async Task ProjectResource_WithLaunchToolArgsDebugSupport_WithholdsOwnedPrefix_InDebugSession()
{
// A ProjectResource can, via the generic WithLaunchToolArgs API, declare a tool
// invocation prefix (ProjectResource implements IResourceWithArgs). The matching IDE launch configuration
// owns that prefix, so it is withheld from Spec.Args.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
// Replace the default "project" debug support with a custom launch type that also owns the launch tool arguments.
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithLaunchToolArgs(ctx => ctx.Args.Add("run"), ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] })
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task ProjectResource_EmptyOwnedLaunchToolArgs_DoesNotConfigureRuntimeFallback()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] })
})
.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task ProjectResource_CustomIdeLaunchWithoutProcessInvocation_UsesApplicationArgumentsOnly()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithArgs("app-arg")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode },
"azure-functions");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["azure-functions"]
})
})
.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task ProjectResource_CustomIdeLaunchWithoutProcessInvocation_DoesNotAddDotnetArgumentSeparator()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http");
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithArgs("app-arg")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode },
"azure-functions");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["azure-functions"]
})
})
.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["--profile-arg", "profile value", "app-arg"], exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Equal(["--profile-arg", "profile value", "app-arg"], displayArgs.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Fact]
public async Task ProjectResource_EmptyOwnedLaunchToolArgs_LaunchConfigurationFailureFailsResource()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "test")
.WithDebugSupport(ThrowingLaunchConfiguration, "test");
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] })
})
.Build();
var executor = CreateAppExecutor(
model,
configuration: configuration,
kubernetesService: kubernetes,
events: events);
await executor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetes, "proj"));
Assert.Same(projectBuilder.Resource, Assert.Single(failedResources));
static ExecutableLaunchConfiguration ThrowingLaunchConfiguration(string mode)
{
throw new InvalidOperationException("Launch configuration failed.");
}
}
[Fact]
public async Task ProjectResource_WithoutLaunchToolArgs_DoesNotConfigureRuntimeFallback_InDebugSession()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<Projects.ServiceA>("proj", launchProfileName: null);
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var kubernetes = new TestKubernetesService();
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "token",
["AppHost:OtlpApiKey"] = "otlp-key",
[DcpExecutor.DebugSessionPortVar] = "12345"
});
var configuration = configBuilder.Build();
var executor = CreateAppExecutor(model, configuration: configuration, kubernetesService: kubernetes);
await executor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetes, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task PersistentDcpResourcesDoNotIncludeMonitorProcessByDefault()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("database", "image")
.WithPersistentLifetime();
builder.AddExecutable("worker", "worker", Environment.CurrentDirectory)
.WithPersistentLifetime();
builder.AddProject<TestProject>("project", launchProfileName: null)
.WithPersistentLifetime();
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(container.Spec.Persistent.GetValueOrDefault());
Assert.Null(container.Spec.MonitorPid);
Assert.Null(container.Spec.MonitorTimestamp);
var executables = kubernetesService.CreatedResources.OfType<Executable>()
.Where(e => e.AppModelResourceName is "worker" or "project")
.ToArray();
Assert.Equal(2, executables.Length);
Assert.All(executables, exe =>
{
Assert.True(exe.Spec.Persistent.GetValueOrDefault());
Assert.Null(exe.Spec.MonitorPid);
Assert.Null(exe.Spec.MonitorTimestamp);
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
});
}
[Fact]
public async Task PersistentProjectWithReplicasThrows()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<TestProject>("project", launchProfileName: null)
.WithReplicas(2)
.WithPersistentLifetime();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => appExecutor.RunApplicationAsync());
Assert.Equal("Resource 'project' uses multiple replicas and a persistent lifetime. These features do not work together.", exception.Message);
}
[Fact]
public async Task PersistentPlainExecutableWithReplicasThrows()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddExecutable("worker", "worker", Environment.CurrentDirectory)
.WithAnnotation(new ReplicaAnnotation(2))
.WithPersistentLifetime();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => appExecutor.RunApplicationAsync());
Assert.Equal("Resource 'worker' uses multiple replicas and a persistent lifetime. These features do not work together.", exception.Message);
}
[Fact]
public async Task PersistentContainerWithOtlpExporterUsesStableServiceInstanceId()
{
var first = await CreateOtlpServiceInstanceIdAsync(builder =>
{
builder.AddContainer("database", "image")
.WithPersistentLifetime()
.WithOtlpExporter();
});
var second = await CreateOtlpServiceInstanceIdAsync(builder =>
{
builder.AddContainer("database", "image")
.WithPersistentLifetime()
.WithOtlpExporter();
});
Assert.Equal("database-12345678", first);
Assert.Equal(first, second);
}
[Fact]
public async Task PersistentExecutableWithOtlpExporterUsesStableServiceInstanceId()
{
var first = await CreateOtlpServiceInstanceIdAsync(builder =>
{
builder.AddExecutable("worker", "worker", Environment.CurrentDirectory)
.WithPersistentLifetime()
.WithOtlpExporter();
});
var second = await CreateOtlpServiceInstanceIdAsync(builder =>
{
builder.AddExecutable("worker", "worker", Environment.CurrentDirectory)
.WithPersistentLifetime()
.WithOtlpExporter();
});
Assert.Equal("worker-12345678", first);
Assert.Equal(first, second);
}
private static async Task<string> CreateOtlpServiceInstanceIdAsync(Action<IDistributedApplicationBuilder> configureBuilder)
{
var builder = DistributedApplication.CreateBuilder();
configureBuilder(builder);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration);
await appExecutor.RunApplicationAsync();
var resource = Assert.Single(kubernetesService.CreatedResources, r =>
r.Metadata.Annotations is not null &&
r.Metadata.Annotations.ContainsKey(CustomResource.OtelServiceInstanceIdAnnotation));
return resource.Metadata.Annotations![CustomResource.OtelServiceInstanceIdAnnotation];
}
[Fact]
public async Task ExplicitParentProcessLifetimeIncludesMonitorProcess()
{
var builder = DistributedApplication.CreateBuilder();
using var parentProcess = Process.GetCurrentProcess();
var parentProcessIdentity = DcpProcessMonitor.GetMonitorProcessIdentity(parentProcess);
builder.AddContainer("database", "image")
.WithParentProcessLifetime(parentProcess.Id);
builder.AddExecutable("worker", "worker", Environment.CurrentDirectory)
.WithParentProcessLifetime(parentProcess.Id);
builder.AddProject<TestProject>("project", launchProfileName: null)
.WithParentProcessLifetime(parentProcess.Id);
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.True(container.Spec.Persistent.GetValueOrDefault());
Assert.Equal(parentProcessIdentity.ProcessId, container.Spec.MonitorPid);
Assert.NotNull(container.Spec.MonitorTimestamp);
Assert.Equal(parentProcessIdentity.Timestamp, container.Spec.MonitorTimestamp.Value, TimeSpan.FromMicroseconds(1));
var executables = kubernetesService.CreatedResources.OfType<Executable>()
.Where(e => e.AppModelResourceName is "worker" or "project")
.ToArray();
Assert.Equal(2, executables.Length);
Assert.All(executables, exe =>
{
Assert.True(exe.Spec.Persistent.GetValueOrDefault());
Assert.Equal(parentProcessIdentity.ProcessId, exe.Spec.MonitorPid);
Assert.NotNull(exe.Spec.MonitorTimestamp);
Assert.Equal(parentProcessIdentity.Timestamp, exe.Spec.MonitorTimestamp.Value, TimeSpan.FromMicroseconds(1));
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
});
}
[Fact]
public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath()
{
var builder = DistributedApplication.CreateBuilder();
using var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build());
using var aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store");
using var certificate = CreateTestCertificate();
var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates")
.WithCertificate(certificate);
var executable = new TestExecutableResource("test-working-directory");
builder.AddResource(executable)
.WithCertificateAuthorityCollection(certificateAuthorities)
.WithCertificateTrustScope(CertificateTrustScope.Override)
.WithPersistentLifetime();
var configDict = new Dictionary<string, string?>
{
[AspireStore.AspireStorePathKeyName] = aspireStoreDirectory.Path,
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "TestExecutable");
var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value;
var sslCertFile = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_FILE").Value;
var expectedCertificatesRoot = Path.Join(aspireStoreDirectory.Path, ".aspire", "dcp", "executables", "TestExecutable-12345678", "certificates");
Assert.Equal(Path.Join(expectedCertificatesRoot, "certs"), sslCertDir);
Assert.Equal(Path.Join(expectedCertificatesRoot, "cert.pem"), sslCertFile);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Unix file modes do not apply on Windows, where the directory inherits its ACL from the parent.")]
public async Task PersistentPlainExecutable_WritesCustomBundleDirectoryOwnerOnly()
{
var builder = DistributedApplication.CreateBuilder();
using var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build());
using var aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store");
using var certificate = CreateTestCertificate();
var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates")
.WithCertificate(certificate);
var executable = new TestExecutableResource("test-working-directory");
builder.AddResource(executable)
.WithCertificateAuthorityCollection(certificateAuthorities)
.WithCertificateTrustScope(CertificateTrustScope.Override)
// A custom bundle is what Aspire.Hosting.Java writes for JAVAX_NET_SSL_TRUSTSTORE, and it is
// the only thing that causes the bundles/ directory to be created.
.WithCertificateTrustConfiguration(static ctx =>
{
ctx.EnvironmentVariables["TEST_BUNDLE"] = ctx.CreateCustomBundle(static (_, _) => Task.FromResult(new byte[] { 1, 2, 3 }));
return Task.CompletedTask;
})
// Persistent lifetime is the case that matters: the bundle lands in the stable Aspire store
// path rather than inside the session-scoped temp directory, which is already owner-only.
.WithPersistentLifetime();
var configDict = new Dictionary<string, string?>
{
[AspireStore.AspireStorePathKeyName] = aspireStoreDirectory.Path,
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var certificatesRoot = Path.Join(aspireStoreDirectory.Path, ".aspire", "dcp", "executables", "TestExecutable-12345678", "certificates");
var bundlesDirectory = Path.Join(certificatesRoot, "bundles");
Assert.True(Directory.Exists(bundlesDirectory), $"Expected the custom bundle directory to exist at {bundlesDirectory}.");
var mode = GetUnixFileModeForTest(bundlesDirectory);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, mode);
}
private static UnixFileMode GetUnixFileModeForTest(string path)
{
// The caller guards on platform, but the analyzer cannot see through [SkipOnPlatform].
#pragma warning disable CA1416
return File.GetUnixFileMode(path);
#pragma warning restore CA1416
}
[Fact]
public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnsetOnLinux()
{
Assert.SkipUnless(OperatingSystem.IsLinux(), "OpenSSL default certificate directories are only inferred on Linux.");
var options = new RemoteInvokeOptions();
options.StartInfo.Environment.Remove("SSL_CERT_DIR");
RemoteExecutor.Invoke(static async () =>
{
Environment.SetEnvironmentVariable("SSL_CERT_DIR", null);
var expectedWellKnownCertificateDirectories = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths
.Where(Directory.Exists)
.ToArray();
Assert.NotEmpty(expectedWellKnownCertificateDirectories);
var builder = DistributedApplication.CreateBuilder();
using var certificate = CreateTestCertificate();
var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates")
.WithCertificate(certificate);
var executable = new TestExecutableResource("test-working-directory");
builder.AddResource(executable)
.WithCertificateAuthorityCollection(certificateAuthorities);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "TestExecutable");
var sslCertDir = Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value;
Assert.NotNull(sslCertDir);
var sslCertDirs = sslCertDir.Split(Path.PathSeparator);
Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]);
Assert.Equal(expectedWellKnownCertificateDirectories, sslCertDirs.Skip(1));
}, options).Dispose();
}
[Fact]
public void PlainExecutableCertificateDirectoriesPath_PreservesAppHostSslCertDirForAppend()
{
var expectedExistingSslCertDirs = new[] { "/custom/certs", "/home/me/.aspnet/dev-certs/trust" };
var options = new RemoteInvokeOptions();
options.StartInfo.Environment["SSL_CERT_DIR"] = string.Join(Path.PathSeparator, expectedExistingSslCertDirs);
RemoteExecutor.Invoke(static expectedExistingSslCertDir =>
{
Environment.SetEnvironmentVariable("SSL_CERT_DIR", expectedExistingSslCertDir);
var sslCertDir = GetPlainExecutableSslCertDirAsync().GetAwaiter().GetResult();
Assert.NotNull(sslCertDir);
var sslCertDirs = sslCertDir.Split(Path.PathSeparator);
Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]);
Assert.Equal(expectedExistingSslCertDir.Split(Path.PathSeparator), sslCertDirs.Skip(1));
}, string.Join(Path.PathSeparator, expectedExistingSslCertDirs), options).Dispose();
}
[Fact]
public void PlainExecutableCertificateDirectoriesPath_PreservesEmptyAppHostSslCertDirForAppend()
{
var options = new RemoteInvokeOptions();
options.StartInfo.Environment["SSL_CERT_DIR"] = string.Empty;
RemoteExecutor.Invoke(static () =>
{
var sslCertDir = GetPlainExecutableSslCertDirAsync().GetAwaiter().GetResult();
Assert.NotNull(sslCertDir);
var sslCertDirs = sslCertDir.Split(Path.PathSeparator);
Assert.Single(sslCertDirs);
Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]);
}, options).Dispose();
}
[Fact]
public void PlainExecutableCertificateDirectoriesPath_DoesNotIncludeAppHostSslCertDirForOverride()
{
var options = new RemoteInvokeOptions();
options.StartInfo.Environment["SSL_CERT_DIR"] = "/custom/certs";
RemoteExecutor.Invoke(static () =>
{
Environment.SetEnvironmentVariable("SSL_CERT_DIR", "/custom/certs");
var sslCertDir = GetPlainExecutableSslCertDirAsync(builder => builder.WithCertificateTrustScope(CertificateTrustScope.Override)).GetAwaiter().GetResult();
Assert.NotNull(sslCertDir);
var sslCertDirs = sslCertDir.Split(Path.PathSeparator);
Assert.Single(sslCertDirs);
Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]);
}, options).Dispose();
}
[Fact]
public async Task PlainExecutableCertificateDirectoriesPath_IgnoresResourceSslCertDirForAppend()
{
var customSslCertDir = $"/resource-certs-{Guid.NewGuid():N}";
var sslCertDir = await GetPlainExecutableSslCertDirAsync(builder => builder.WithEnvironment("SSL_CERT_DIR", customSslCertDir));
Assert.NotNull(sslCertDir);
var sslCertDirs = sslCertDir.Split(Path.PathSeparator);
Assert.EndsWith($"{Path.DirectorySeparatorChar}certs", sslCertDirs[0]);
Assert.All(sslCertDirs, dir => Assert.NotEqual(customSslCertDir, dir));
}
[Fact]
public async Task SessionScopedExplicitStartPlainExecutable_DefersDcpObjectCreationUntilManualStart()
{
var builder = DistributedApplication.CreateBuilder();
var resource = builder.AddExecutable("CoolProgram", "cool", Environment.CurrentDirectory, "--alpha", "--bravo")
.WithExplicitStart();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "CoolProgram"));
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
var exe = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "CoolProgram"));
Assert.True(exe.Spec.Start);
}
[Fact]
public async Task PlainExecutable_MultipleLaunchRecipes_ReportsLaunchPlanFailure()
{
var builder = DistributedApplication.CreateBuilder();
var resource = builder.AddExecutable("app", "tool", Environment.CurrentDirectory)
.WithExplicitStart();
resource.Resource.Annotations.Add(
new ExecutableLaunchRecipeAnnotation(DirectExecutableLaunchRecipe.Instance));
var kubernetesService = new TestKubernetesService();
var failures = new ConcurrentQueue<OnResourceFailedToStartContext>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failures.Enqueue(context);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
events: events);
await appExecutor.RunApplicationAsync();
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
var exception = await Assert.ThrowsAsync<FailedToApplyEnvironmentException>(
() => appExecutor.StartResourceAsync(reference, CancellationToken.None));
const string innerMessage =
"Resource 'app' must have exactly one executable launch recipe, but 2 were found.";
const string expectedMessage =
"Failed to create executable launch plan for resource 'app'. " + innerMessage;
Assert.Equal(expectedMessage, exception.Message);
var innerException = Assert.IsType<InvalidOperationException>(exception.InnerException);
Assert.Equal(innerMessage, innerException.Message);
var failure = Assert.Single(failures);
Assert.Same(resource.Resource, failure.Resource);
Assert.Equal(expectedMessage, failure.ErrorMessage);
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "app"));
}
[Fact]
public async Task PlainExecutable_ExtensionMode_UnsupportedDebugMode_RunsInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var executable = new TestExecutableResource("test-working-directory");
builder.AddResource(executable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate debug session port and extension endpoint (extension mode)
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["other_executable"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task PlainExecutable_NoExtensionMode_RunInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2");
builder.AddResource(nonDebuggableExecutable);
// Simulate no extension endpoint (no extension mode) - this means no debug session port
var configDict = new Dictionary<string, string?>
{
[KnownConfigNames.ExtensionEndpoint] = null
// No DEBUG_SESSION_PORT set
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Equal(2, dcpExes.Count);
var debuggableExe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, debuggableExe.Spec.ExecutionType);
Assert.False(debuggableExe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out _));
var nonDebuggableExe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestOtherExecutable");
Assert.Equal(ExecutionType.Process, nonDebuggableExe.Spec.ExecutionType);
Assert.False(nonDebuggableExe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out _));
}
[Fact]
public async Task CustomExecutable_NoDebugSessionInfo_RunInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate no debug session port and no extension endpoint (no debug session info)
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
// No DebugSessionInfo set
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task CustomExecutable_InvalidDebugSessionInfo_RunInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate debug session port with invalid JSON in DebugSessionInfo
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = "{invalid json}",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task CustomExecutable_DebugSessionInfoWithNullSupportedLaunchConfigurations_RunInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate debug session info with null SupportedLaunchConfigurations
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = null
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProcess()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate debug session info with SupportedLaunchConfigurations that do not match the executable type
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["other_type"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Create executable resources with SupportsDebuggingAnnotation
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test");
// Simulate debug session info with SupportedLaunchConfigurations that match the executable type
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["test"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var dcpExes = kubernetesService.CreatedResources.OfType<Executable>().ToList();
Assert.Single(dcpExes);
var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectExecutable_NoDebugSessionInfo_DefaultsToProjectSupport()
{
// Arrange
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA");
// Simulate debug session port but no DebugSessionInfo (simulates missing or null configuration)
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
// No DebugSessionInfo set - should default to ["project"]
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
[Fact]
public async Task Project_WithTerminal_RunsAsProcess_InDebugSessionWhenDebugSupportIsAddedLater()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var resource = builder.AddProject<Projects.ServiceA>("ServiceA").WithTerminal();
resource
.WithLaunchToolArgs(ctx => ctx.Args.Add("launch-tool-arg"), ownedByLaunchConfigurationType: "project")
.WithDebugSupport(
mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode },
"project");
// Simulate a debug session whose capability list advertises "project" support.
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Materialize the per-replica terminal hosts (see Project_WithTerminal_PopulatesPerReplicaTerminalSpec).
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel));
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
// Process execution keeps the full command line, so the tool-invocation prefix is passed through.
Assert.NotNull(exe.Spec.Args);
Assert.Contains("launch-tool-arg", exe.Spec.Args);
Assert.NotNull(exe.Spec.Terminal);
}
[Fact]
public async Task Project_WithTerminal_RunsAsProcess_NoDebugSessionInfo()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA").WithTerminal();
// No DebugSessionInfo simulates the Visual Studio scenario, where project resources normally
// fall back to IDE execution. A terminal-attached resource must still run as a plain process,
// because attaching the debugger would break the PTY flow and leave the user with an empty
// terminal. Temporary until DCP supports both at once: https://github.com/microsoft/dcp/issues/189.
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel));
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectExecutable_InvalidDebugSessionInfo_DefaultsToProjectSupport()
{
// Arrange
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA");
// Simulate debug session port with invalid JSON in DebugSessionInfo
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = "{invalid json}",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectExecutable_DebugSessionInfoWithNullSupportedLaunchConfigurations_DefaultsToProjectSupport()
{
// Arrange
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA");
// Simulate debug session info with null SupportedLaunchConfigurations
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = null
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectExecutable_DebugSessionInfoWithoutProject_SelectsProcess()
{
// When the IDE explicitly advertises a SupportedLaunchConfigurations list that does NOT
// include "project", honor it: the IDE cannot launch project resources, so we must run
// them as a Process from the AppHost. The VS Code extension behaves this way when the
// C# extension is not installed; routing project resources to the extension in that case
// would result in them never starting (the extension returns 400 UnsupportedLaunchConfiguration).
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("ServiceA");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["azure-functions"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectWithNonProjectAnnotation_DebugSessionWithoutInfo_UsesProjectIdeExecution()
{
// Bug #15378: Simulates the Visual Studio scenario for projects with custom debug types.
// VS sets DEBUG_SESSION_PORT but does NOT send DEBUG_SESSION_INFO. A project resource
// with a non-"project" SupportsDebuggingAnnotation (e.g., "azure-functions") should still
// get ExecutionType.IDE with a ProjectLaunchConfiguration so VS can launch and debug it.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
Assert.Single(launchConfigs);
Assert.Equal("project", launchConfigs[0].Type);
}
[Fact]
public async Task ProjectWithNonProjectAnnotation_VSCodeExplicitlyUnsupported_RunsInProcess()
{
// Guard: When VS Code extension sends DEBUG_SESSION_INFO with SupportedLaunchConfigurations
// that do NOT include the custom type, the resource should fall to Process mode. This ensures
// the else-if branch doesn't over-capture VS Code scenarios.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["project"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
public static TheoryData<string, string[]> MauiProjectLaunchConfigurationsForProcessExecution => new()
{
{ "maui", ["run", "-f", "net10.0-windows10.0.19041.0"] },
{ "maui", ["run", "-f", "net10.0-maccatalyst", "-p:OpenArguments=-W"] },
{ "maui", ["run", "-f", "net10.0-ios", "-p:_DeviceName=:v2:udid=E25BBE37-69BA-4720-B6FD-D54C97791E79"] },
{ "maui", ["run", "-f", "net10.0-ios", "-p:RuntimeIdentifier=ios-arm64"] },
{ "maui", ["run", "-f", "net10.0-android", "-p:AdbTarget=-e"] },
{ "maui", ["run", "-f", "net10.0-android", "-p:AdbTarget=-d"] }
};
[Theory]
[MemberData(nameof(MauiProjectLaunchConfigurationsForProcessExecution))]
public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_VSCodeExplicitlyUnsupported_RunsInProcessWithResourceArgs(string launchConfigurationType, string[] resourceArgs)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType)
.WithArgs(resourceArgs);
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["project"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal("dotnet", exe.Spec.ExecutablePath);
Assert.Equal("/tmp/mauiapp", exe.Spec.WorkingDirectory);
var expectedArgs = new List<string> { resourceArgs[0] };
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.Add("--no-launch-profile");
expectedArgs.AddRange(resourceArgs.Skip(1));
Assert.Equal(expectedArgs, exe.Spec.Args);
}
[Theory]
[MemberData(nameof(MauiProjectLaunchConfigurationsForProcessExecution))]
public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebugSessionInfo_RunsInProcessWithResourceArgs(string launchConfigurationType, string[] resourceArgs)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType)
.WithArgs(resourceArgs);
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal("dotnet", exe.Spec.ExecutablePath);
Assert.Equal("/tmp/mauiapp", exe.Spec.WorkingDirectory);
var expectedArgs = new List<string> { resourceArgs[0] };
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.Add("--no-launch-profile");
expectedArgs.AddRange(resourceArgs.Skip(1));
Assert.Equal(expectedArgs, exe.Spec.Args);
}
[Theory]
[MemberData(nameof(MauiProjectLaunchConfigurationsForProcessExecution))]
public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebugSession_RunsInProcessWithResourceArgs(string launchConfigurationType, string[] resourceArgs)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType)
.WithArgs(resourceArgs);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal("dotnet", exe.Spec.ExecutablePath);
Assert.Equal("/tmp/mauiapp", exe.Spec.WorkingDirectory);
var expectedArgs = new List<string> { resourceArgs[0] };
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.Add("--no-launch-profile");
expectedArgs.AddRange(resourceArgs.Skip(1));
Assert.Equal(expectedArgs, exe.Spec.Args);
}
[Fact]
public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfiguration_RunsInIdeWithLaunchMetadata()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(mode => new TestMauiLaunchConfiguration
{
Mode = mode,
ProjectPath = "/tmp/mauiapp/MauiApp.csproj",
TargetFramework = "net10.0-android",
Platform = "android",
TargetKind = "emulator",
MsBuildProperties = new Dictionary<string, string>
{
["AdbTarget"] = "-e"
}
}, "maui")
.WithArgs("run", "-f", "net10.0-android", "-p:AdbTarget=-e");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["maui"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal("dotnet", exe.Spec.ExecutablePath);
Assert.Equal("/tmp/mauiapp", exe.Spec.WorkingDirectory);
Assert.Null(exe.Spec.FallbackExecutionTypes);
var expectedArgs = new List<string> { "run" };
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.Add("--no-launch-profile");
expectedArgs.AddRange(["-f", "net10.0-android", "-p:AdbTarget=-e"]);
Assert.Equal(expectedArgs, exe.Spec.Args);
Assert.True(exe.TryGetAnnotationAsObjectList<TestMauiLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
var launchConfig = Assert.Single(launchConfigs);
Assert.Equal("maui", launchConfig.Type);
Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode);
Assert.Equal("/tmp/mauiapp/MauiApp.csproj", launchConfig.ProjectPath);
Assert.Equal("net10.0-android", launchConfig.TargetFramework);
Assert.Equal("android", launchConfig.Platform);
Assert.Equal("emulator", launchConfig.TargetKind);
Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_PreservesProjectMetadataAndAppliesMauiLaunchConfiguration(bool useContextOverload)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var projectResource = projectBuilder.Resource;
var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultDebugSupport is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultDebugSupport);
}
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(
new ProjectLaunchArgsOverrideAnnotation(
["build", "--no-restore", "/t:Run", "-p:NoBuild=true"],
leadingResourceArgumentToRemove: "run"));
#pragma warning restore ASPIREPROJECTS001
var producerInvocationCount = 0;
LaunchConfigurationCallbackContext? launchContext = null;
if (useContextOverload)
{
projectBuilder.WithDebugSupport(
context =>
{
Interlocked.Increment(ref producerInvocationCount);
launchContext = context;
return Task.FromResult(CreateMauiLaunchConfiguration(context.Mode));
},
"maui");
}
else
{
projectBuilder.WithDebugSupport(
mode =>
{
Interlocked.Increment(ref producerInvocationCount);
return CreateMauiLaunchConfiguration(mode);
},
"maui");
}
projectBuilder.WithArgs("run", "-f", "net10.0-android");
var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["maui"]
});
builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(
app.Services.GetRequiredService<DistributedApplicationModel>(),
kubernetesService: kubernetesService,
configuration: configuration,
distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var executable = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
Assert.Equal(1, Volatile.Read(ref producerInvocationCount));
var expectedArgs = new List<string>
{
"build",
"--no-restore",
"/t:Run",
"-p:NoBuild=true",
"TestProject"
};
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.AddRange(["-f", "net10.0-android"]);
Assert.Equal(expectedArgs, executable.Spec.Args);
Assert.True(executable.Metadata.Annotations.TryGetValue(
Executable.LaunchConfigurationsAnnotation,
out var launchConfigurationsJson));
using var launchConfigurations = JsonDocument.Parse(launchConfigurationsJson);
Assert.Collection(
launchConfigurations.RootElement.EnumerateArray(),
projectLaunchConfiguration =>
{
Assert.Equal(KnownLaunchConfigurationTypes.Project, projectLaunchConfiguration.GetProperty("type").GetString());
Assert.Equal(ExecutableLaunchMode.Debug, projectLaunchConfiguration.GetProperty("mode").GetString());
Assert.Equal("TestProject", projectLaunchConfiguration.GetProperty("project_path").GetString());
Assert.True(projectLaunchConfiguration.GetProperty("disable_launch_profile").GetBoolean());
},
mauiLaunchConfiguration =>
{
Assert.Equal("maui", mauiLaunchConfiguration.GetProperty("type").GetString());
Assert.Equal(ExecutableLaunchMode.Debug, mauiLaunchConfiguration.GetProperty("mode").GetString());
Assert.Equal("/mauiapp/MauiApp.csproj", mauiLaunchConfiguration.GetProperty("project_path").GetString());
});
if (useContextOverload)
{
Assert.NotNull(launchContext);
Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode);
Assert.Same(projectResource, launchContext.Resource);
}
static TestMauiLaunchConfiguration CreateMauiLaunchConfiguration(string mode) => new()
{
Mode = mode,
ProjectPath = "/mauiapp/MauiApp.csproj",
TargetFramework = "net10.0-android",
Platform = "android",
TargetKind = "emulator"
};
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task MauiProjectWithLaunchArgsOverride_LaunchConfigurationProducerThrows_RemainsInProcessExecution(bool useContextOverload)
{
// The launch override already provides a runnable Process command. A custom launch producer can add
// metadata in that mode, but a producer fault must not discard the process invocation.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultDebugSupport is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultDebugSupport);
}
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(
new ProjectLaunchArgsOverrideAnnotation(
["build", "--no-restore", "/t:Run", "-p:NoBuild=true"],
leadingResourceArgumentToRemove: "run"));
#pragma warning restore ASPIREPROJECTS001
var producerInvocationCount = 0;
var producerFailureMessage = useContextOverload
? "Test exception from async launch configuration producer"
: "Test exception from launch configuration producer";
if (useContextOverload)
{
projectBuilder.WithDebugSupport(
async Task<TestMauiLaunchConfiguration> (context) =>
{
Interlocked.Increment(ref producerInvocationCount);
await Task.Yield();
throw new InvalidOperationException(producerFailureMessage);
},
"maui");
}
else
{
projectBuilder.WithDebugSupport(
TestMauiLaunchConfiguration (mode) =>
{
Interlocked.Increment(ref producerInvocationCount);
throw new InvalidOperationException(producerFailureMessage);
},
"maui");
}
projectBuilder.WithArgs("run", "-f", "net10.0-android");
var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["maui"]
});
builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
})
.Build();
var kubernetesService = new TestKubernetesService();
using var resourceLoggerService = new ResourceLoggerService();
var failedResources = new ConcurrentQueue<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Enqueue(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var appExecutor = CreateAppExecutor(
app.Services.GetRequiredService<DistributedApplicationModel>(),
kubernetesService: kubernetesService,
configuration: configuration,
distributedApplicationOptions: distributedApplicationOptions,
resourceLoggerService: resourceLoggerService,
events: events);
await appExecutor.RunApplicationAsync();
var executable = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
Assert.Equal(1, Volatile.Read(ref producerInvocationCount));
Assert.Empty(failedResources);
var expectedArgs = new List<string>
{
"build",
"--no-restore",
"/t:Run",
"-p:NoBuild=true",
"TestProject"
};
if (GetTestAssemblyConfiguration() is { } configurationName)
{
expectedArgs.AddRange(["--configuration", configurationName]);
}
expectedArgs.AddRange(["-f", "net10.0-android"]);
Assert.Equal(expectedArgs, executable.Spec.Args);
Assert.True(executable.TryGetAnnotationAsObjectList<JsonElement>(
Executable.LaunchConfigurationsAnnotation,
out var launchConfigurations));
var projectLaunchConfiguration = Assert.Single(launchConfigurations);
Assert.Equal(
KnownLaunchConfigurationTypes.Project,
projectLaunchConfiguration.GetProperty("type").GetString());
var logLines = new List<LogLine>();
await foreach (var lines in resourceLoggerService.GetAllAsync(projectBuilder.Resource).DefaultTimeout())
{
logLines.AddRange(lines);
}
Assert.Contains(logLines, line =>
!line.IsErrorMessage &&
line.Content.Contains(
"Failed to apply optional launch configuration metadata of type 'maui' for Process resource 'proj'. Continuing with Process execution.",
StringComparison.Ordinal) &&
line.Content.Contains(producerFailureMessage, StringComparison.Ordinal));
}
[Fact]
public async Task ProjectResource_CustomIdeLaunch_OwnedDotnetToolRunArgsPreserveLaunchProfileArgs()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http");
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithArgs("app-arg")
.WithLaunchToolArgs(
static context =>
{
context.Args.Add("tool");
context.Args.Add("run");
context.Args.Add("package");
context.Args.Add("--");
},
ownedByLaunchConfigurationType: "custom")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("custom") { Mode = mode },
"custom");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["custom"] })
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["--profile-arg", "profile value", "app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Equal(["tool", "run", "package", "--", "--profile-arg", "profile value", "app-arg"], displayArgs.Select(a => a.Argument));
Assert.All(displayArgs.Take(4), argument => Assert.Null(argument.EffectiveArgumentIndex));
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Theory]
[InlineData("run", false, null, null, null)]
[InlineData("run", true, null, null, null)]
[InlineData("watch", false, null, null, null)]
[InlineData("watch", true, null, null, null)]
[InlineData("run", false, "-d", null, null)]
[InlineData("watch", false, "--diagnostics", null, null)]
[InlineData("run", false, null, new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, null)]
[InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, null)]
[InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE_A=1]", "[env:ASPIRE_PREFIX_PROBE_B=2]" }, null)]
[InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, "app-arg")]
public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedProjectPreservesLaunchProfileArgs(
string launchVerb,
bool useFullDotnetPath,
string? sdkOption,
string[]? environmentVariableDirectives,
string? applicationArgument)
{
var builder = DistributedApplication.CreateBuilder();
var dotnetCommand = useFullDotnetPath
? Path.GetFullPath(Path.Combine("test-dotnet-root", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"))
: "dotnet";
var resourceArgs = new List<string>();
if (environmentVariableDirectives is not null)
{
resourceArgs.AddRange(environmentVariableDirectives);
}
if (sdkOption is not null)
{
resourceArgs.Add(sdkOption);
}
resourceArgs.AddRange([launchVerb, "-f", "net10.0-ios"]);
if (applicationArgument is not null)
{
resourceArgs.AddRange(["--", applicationArgument]);
}
var projectBuilder = builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http");
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = dotnetCommand,
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(
mode => new TestMauiLaunchConfiguration
{
Mode = mode,
ProjectPath = "/tmp/mauiapp/MauiApp.csproj",
TargetFramework = "net10.0-ios",
Platform = "ios",
TargetKind = "simulator"
},
"maui")
.WithArgs([.. resourceArgs]);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["maui"] })
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(dotnetCommand, exe.Spec.ExecutablePath);
var expectedArgs = new List<string>();
if (environmentVariableDirectives is not null)
{
expectedArgs.AddRange(environmentVariableDirectives);
}
if (sdkOption is not null)
{
expectedArgs.Add(sdkOption);
}
expectedArgs.Add(launchVerb);
if (GetTestAssemblyConfiguration() is { } configurationName)
{
expectedArgs.AddRange(["--configuration", configurationName]);
}
expectedArgs.AddRange(["--no-launch-profile", "-f", "net10.0-ios", "--", "--profile-arg", "profile value"]);
if (applicationArgument is not null)
{
expectedArgs.Add(applicationArgument);
}
Assert.Equal(expectedArgs, exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
var expectedDisplayArgs = new List<string>();
if (environmentVariableDirectives is not null)
{
expectedDisplayArgs.AddRange(environmentVariableDirectives);
}
if (sdkOption is not null)
{
expectedDisplayArgs.Add(sdkOption);
}
expectedDisplayArgs.AddRange([launchVerb, "-f", "net10.0-ios", "--", "--profile-arg", "profile value"]);
if (applicationArgument is not null)
{
expectedDisplayArgs.Add(applicationArgument);
}
Assert.Equal(expectedDisplayArgs, displayArgs.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Theory]
[InlineData("run", true, false, null)]
[InlineData("watch", true, false, null)]
[InlineData("run", false, false, null)]
[InlineData("watch", false, false, null)]
[InlineData("run", false, true, null)]
[InlineData("watch", false, true, null)]
[InlineData("watch", false, false, "[env:ASPIRE_PREFIX_PROBE=1]")]
[InlineData("run", false, false, "@options.rsp")]
[InlineData("watch", false, false, "@options.rsp")]
public async Task ProjectResource_CustomIdeLaunch_PreservesOpaqueDotnetApplicationArguments(
string nonLeadingLaunchVerb,
bool useExec,
bool useRuntimeOptions,
string? commandPrefix)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http");
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
string[] resourceArgs = commandPrefix is not null
? [commandPrefix, nonLeadingLaunchVerb]
: useRuntimeOptions
? ["--roll-forward", "LatestMajor", "app.dll", nonLeadingLaunchVerb]
: useExec
? ["exec", "app.dll", nonLeadingLaunchVerb]
: ["app.dll", nonLeadingLaunchVerb];
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(
mode => new TestMauiLaunchConfiguration
{
Mode = mode,
ProjectPath = "/tmp/mauiapp/MauiApp.csproj",
TargetFramework = "net10.0-ios",
Platform = "ios",
TargetKind = "simulator"
},
"maui")
.WithArgs(resourceArgs);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["maui"] })
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
string[] expectedArgs = ["--profile-arg", "profile value", .. resourceArgs];
Assert.Equal(expectedArgs, exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Equal(expectedArgs, displayArgs.Select(a => a.Argument));
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Theory]
[InlineData(new string[] { "exec", "app.dll", "run" }, true)]
[InlineData(new string[] { "app.dll", "run" }, true)]
[InlineData(new string[] { "[env:ASPIRE_PREFIX_PROBE=1]", "watch" }, false)]
public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetApplicationDoesNotConfigureRuntimeFallback(
string[] resourceArgs,
bool _)
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var defaultAnnotation = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (defaultAnnotation is not null)
{
projectBuilder.Resource.Annotations.Remove(defaultAnnotation);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("custom") { Mode = mode },
"custom")
.WithArgs(resourceArgs);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["custom"] })
})
.Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(resourceArgs, exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchProfileArgsStayAfterDotnetRunArgs()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithLaunchProfileCommandLineArgs>("proj", launchProfileName: "http");
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithDebugSupport(mode => new ExecutableLaunchConfiguration("maui") { Mode = mode }, "maui")
.WithArgs("run", "-f", "net10.0-ios", "-p:_DeviceName=:v2:udid=E25BBE37-69BA-4720-B6FD-D54C97791E79");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["project"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyConfigurationAttribute>(typeof(DcpExecutorTests).Assembly)?.Configuration;
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions);
await appExecutor.RunApplicationAsync();
var expectedArgs = new List<string> { "run" };
if (!string.IsNullOrEmpty(expectedConfiguration))
{
expectedArgs.AddRange(["--configuration", expectedConfiguration]);
}
expectedArgs.AddRange([
"--no-launch-profile",
"-f",
"net10.0-ios",
"-p:_DeviceName=:v2:udid=E25BBE37-69BA-4720-B6FD-D54C97791E79",
"--",
"--profile-arg",
"profile value"
]);
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal(expectedArgs, exe.Spec.Args);
}
[Fact]
public async Task ProjectWithNonProjectAnnotation_NoDebugSession_RunsInProcess()
{
// Guard: When there's no debug session (CLI scenario, no DEBUG_SESSION_PORT),
// projects with custom annotations should fall to Process execution.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectWithNonProjectAnnotation_VSCodeWithMatchingSupport_RunsInIde()
{
// When VS Code extension sends DEBUG_SESSION_INFO with SupportedLaunchConfigurations
// that DO include the custom type, the resource should get IDE execution via the
// primary SupportsDebugging path (not the VS fallback).
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["project", "azure-functions"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
[Fact]
public async Task StandardAndCustomProjects_VSScenario_BothRunInIde()
{
// End-to-end VS scenario: a standard project and a custom-debug-type project both
// in the same AppHost. Both should get IDE execution when launched from VS
// (DEBUG_SESSION_PORT set, no DEBUG_SESSION_INFO).
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("standard-project");
var customProject = builder.AddProject<TestProject>("custom-project", launchProfileName: null);
var annotationToRemove = customProject.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
customProject.Resource.Annotations.Remove(annotationToRemove);
}
customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var standardExe = GetCreatedExecutableForResource(kubernetesService, "standard-project");
Assert.Equal(ExecutionType.IDE, standardExe.Spec.ExecutionType);
var customExe = GetCreatedExecutableForResource(kubernetesService, "custom-project");
Assert.Equal(ExecutionType.IDE, customExe.Spec.ExecutionType);
Assert.Null(customExe.Spec.Args);
Assert.Null(customExe.Spec.FallbackExecutionTypes);
Assert.True(customExe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
Assert.Single(launchConfigs);
Assert.Equal("project", launchConfigs[0].Type);
}
[Fact]
public async Task StandardAndCustomProjects_VSCodeScenario_BothRunInIde()
{
// Combined VS Code scenario for class library projects:
// VS Code extension sends SupportedLaunchConfigurations=["azure-functions"] (without "project").
// A standard project (type "project") falls to Process execution because the IDE explicitly
// did not advertise project support — the AppHost spawns dotnet itself.
// A project with "azure-functions" annotation gets IDE (explicit match).
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
builder.AddProject<Projects.ServiceA>("standard-project");
var customProject = builder.AddProject<TestProject>("functions-project", launchProfileName: null);
var annotationToRemove = customProject.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
customProject.Resource.Annotations.Remove(annotationToRemove);
}
customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["coreclr"],
SupportedLaunchConfigurations = ["azure-functions"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
// Standard project: Process execution because the IDE did not advertise "project" support.
var standardExe = GetCreatedExecutableForResource(kubernetesService, "standard-project");
Assert.Equal(ExecutionType.Process, standardExe.Spec.ExecutionType);
// Azure Functions project: IDE via explicit "azure-functions" support.
var functionsExe = GetCreatedExecutableForResource(kubernetesService, "functions-project");
Assert.Equal(ExecutionType.IDE, functionsExe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectWithNonProjectAnnotation_VSCompatibilityLaunch_UsesApplicationArgumentsOnly()
{
// VS falls back to a project launch configuration for custom project types without DEBUG_SESSION_INFO.
// Ordinary resource arguments are application arguments, so `dotnet app-arg` is not a runnable fallback.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder
.WithArgs("app-arg")
.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Theory]
[InlineData()]
[InlineData("alias1", "alias2")]
public async Task ContainerNetworkAliases(params string[]? aliases)
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
var ctr = builder.AddContainer("mycontainer", "myimage");
foreach (var alias in aliases ?? Array.Empty<string>())
{
ctr.WithContainerNetworkAlias(alias);
}
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
Assert.NotNull(container.Spec.Networks);
var network = Assert.Single(container.Spec.Networks);
Assert.NotNull(network.Aliases);
Assert.Equal(2 + (aliases?.Length ?? 0), network.Aliases.Count);
Assert.Contains("mycontainer", network.Aliases);
Assert.Contains("mycontainer.dev.internal", network.Aliases);
foreach (var alias in aliases ?? Array.Empty<string>())
{
Assert.Contains(alias, network.Aliases);
}
}
[Fact]
public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_InDebugSession_RunsInIdeMode()
{
// ProjectResource subclasses added via AddResource (not AddProject) may not have
// a SupportsDebuggingAnnotation (e.g. third-party integrations). When in a debug session, these
// should still default to IDE execution with ProjectLaunchConfiguration — matching
// the pre-13.2 behavior. External integrations should not be forced to call the
// experimental WithDebugSupport API to get basic IDE execution.
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
// Add project but ensure it doesn't have SupportsDebuggingAnnotation
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null);
// Remove the SupportsDebuggingAnnotation that AddProject adds by default
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
// Simulate debug session port to indicate we're in a debug session
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
Assert.Single(launchConfigs);
Assert.Equal("project", launchConfigs[0].Type);
}
[Fact]
public async Task FileBasedProjectResource_InDebugSession_UsesIdeWithoutProcessFallback()
{
var builder = DistributedApplication.CreateBuilder();
var projectPath = Path.Combine("src", "app.cs");
builder.AddResource(new ProjectResource("file-project"))
.WithAnnotation(new TestFileBasedProject(projectPath));
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "file-project");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
// File-based projects used to keep a `dotnet run --file` candidate for Process fallback.
// IDE-only launch is now intentional, so no second command remains in the rendered spec.
Assert.Null(exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetProjectLaunchConfiguration(out var launchConfiguration));
Assert.Equal(projectPath, launchConfiguration.ProjectPath);
Assert.Equal(KnownLaunchConfigurationTypes.Project, launchConfiguration.Type);
}
[Fact]
public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate()
{
// Project launch configuration producers run after the execution configuration has been resolved.
// A producer that genuinely suspends must still complete before the executable is handed to DCP.
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null);
projectBuilder.WithDebugSupport(
async (mode, _) =>
{
// Yield so the producer completes asynchronously rather than returning an already-completed task.
await Task.Yield();
return new ProjectLaunchConfiguration { ProjectPath = "AsyncProducerPath", Mode = mode, LaunchProfile = "async-profile" };
},
KnownLaunchConfigurationTypes.Project);
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.Equal("AsyncProducerPath", plc.ProjectPath);
Assert.Equal("async-profile", plc.LaunchProfile);
Assert.Equal(ExecutableLaunchMode.Debug, plc.Mode);
}
[Fact]
public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate()
{
// The companion to ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare: a
// non-"project" launch configuration is applied when the Executable is created (after endpoints are
// allocated), which is the other producer call site.
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(
async (mode, _) =>
{
await Task.Yield();
return new ExecutableLaunchConfiguration("test") { Mode = mode };
},
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.True(exe.TryGetAnnotationAsObjectList<ExecutableLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
var launchConfig = Assert.Single(launchConfigs);
Assert.Equal("test", launchConfig.Type);
Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode);
}
[Fact]
public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FailsResource()
{
// An async producer that faults after suspending must surface through the resource-start failure path.
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport<TestExecutableResource, ExecutableLaunchConfiguration>(
async (_, _) =>
{
await Task.Yield();
throw new InvalidOperationException("Test exception from async launch configuration producer");
},
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Same(debuggableExecutable, Assert.Single(failedResources));
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsInProcessMode()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null);
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"]));
#pragma warning restore ASPIREPROJECTS001
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("build", arg),
arg => Assert.Equal("/t:Run", arg),
arg => Assert.EndsWith("ServiceA.csproj", arg, StringComparison.Ordinal),
arg => Assert.Equal("--configuration", arg),
arg => Assert.Equal(GetTestAssemblyConfiguration(), arg));
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_AndExecutableAnnotatedSdkRunArgs_DoesNotMutateRunArgs()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithAnnotation(new ExecutableAnnotation
{
Command = "dotnet",
WorkingDirectory = "/tmp/mauiapp"
})
.WithArgs("run", "-f", "net10.0-ios");
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"]));
#pragma warning restore ASPIREPROJECTS001
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("build", arg),
arg => Assert.Equal("/t:Run", arg),
arg => Assert.EndsWith("ServiceA.csproj", arg, StringComparison.Ordinal),
arg => Assert.Equal("--configuration", arg),
arg => Assert.Equal(GetTestAssemblyConfiguration(), arg),
arg => Assert.Equal("run", arg),
arg => Assert.Equal("-f", arg),
arg => Assert.Equal("net10.0-ios", arg));
Assert.DoesNotContain("--no-launch-profile", exe.Spec.Args!);
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_AndLeadingResourceArgumentToRemove_DropsRunBeforeExecuting()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithArgs("run", "-f", "net10.0-ios");
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"], leadingResourceArgumentToRemove: "run"));
#pragma warning restore ASPIREPROJECTS001
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("build", arg),
arg => Assert.Equal("/t:Run", arg),
arg => Assert.EndsWith("ServiceA.csproj", arg, StringComparison.Ordinal),
arg => Assert.Equal("--configuration", arg),
arg => Assert.Equal(GetTestAssemblyConfiguration(), arg),
arg => Assert.Equal("-f", arg),
arg => Assert.Equal("net10.0-ios", arg));
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_EmptyLaunchToolArgsKeepOverride()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithArgs("run", "-f", "net10.0-ios")
.WithLaunchToolArgs(static _ => { });
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"], leadingResourceArgumentToRemove: "run"));
#pragma warning restore ASPIREPROJECTS001
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("build", arg),
arg => Assert.Equal("/t:Run", arg),
arg => Assert.EndsWith("ServiceA.csproj", arg, StringComparison.Ordinal),
arg => Assert.Equal("--configuration", arg),
arg => Assert.Equal(GetTestAssemblyConfiguration(), arg),
arg => Assert.Equal("-f", arg),
arg => Assert.Equal("net10.0-ios", arg));
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_NonEmptyLaunchToolArgsReplaceOverride()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithArgs("-f", "net10.0-ios")
.WithLaunchToolArgs(static ctx => ctx.Args.Add("run"), showInCommandLine: false);
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"], leadingResourceArgumentToRemove: "run"));
#pragma warning restore ASPIREPROJECTS001
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("-f", arg),
arg => Assert.Equal("net10.0-ios", arg));
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Collection(displayArgs,
arg =>
{
Assert.Equal("-f", arg.Argument);
Assert.Equal(1, arg.EffectiveArgumentIndex);
},
arg =>
{
Assert.Equal("net10.0-ios", arg.Argument);
Assert.Equal(2, arg.EffectiveArgumentIndex);
});
AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args);
}
[Fact]
public async Task ProjectExecutable_WithLaunchArgsOverride_AndPersistentLifetime_RunsOverrideInProcessMode()
{
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null)
.WithPersistentLifetime();
#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
projectBuilder.Resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"]));
#pragma warning restore ASPIREPROJECTS001
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.True(exe.Spec.Persistent);
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Collection(
exe.Spec.Args!,
arg => Assert.Equal("build", arg),
arg => Assert.Equal("/t:Run", arg),
arg => Assert.EndsWith("ServiceA.csproj", arg, StringComparison.Ordinal),
arg => Assert.Equal("--configuration", arg),
arg => Assert.Equal(GetTestAssemblyConfiguration(), arg));
}
[Fact]
public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_NoDebugSession_RunsInProcessMode()
{
// When there's no debug session (CLI scenario), projects without annotations
// should still run in Process mode.
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
});
var projectBuilder = builder.AddProject<Projects.ServiceA>("ServiceA", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
[Fact]
public async Task ProjectExecutable_NoAnnotation_ExecutableLaunchProfile_InDebugSession_RunsInIdeMode()
{
// Class library projects with commandName=Executable launch profiles (e.g. AWS Lambda
// using "dotnet exec ...") should get IDE execution so both VS and VS Code can debug them.
// VS natively handles Executable command profiles; VS Code's extension detects the
// Executable commandName and uses the profile's executablePath + args.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithExecutableLaunchProfile>("TestFunction",
launchProfileName: "Aspire_TestFunction");
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestFunction");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<ProjectLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
Assert.Single(launchConfigs);
Assert.Equal("Aspire_TestFunction", launchConfigs[0].LaunchProfile);
Assert.Null(exe.Spec.Args);
}
[Fact]
public async Task ProjectExecutable_NoAnnotation_ProjectLaunchProfile_InDebugSession_RunsInIdeMode()
{
// When a project without SupportsDebuggingAnnotation has a normal Project launch profile
// (not Executable), it should still get IDE execution in a debug session.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProjectWithLaunchSettings>("proj", launchProfileName: "http");
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
// Should be IDE, because it's a normal Project profile
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task DotnetProjectExecutable_InDebugSession_GetsIdeExecutionWithProjectLaunchConfig()
{
// A plain ExecutableResource that carries IProjectMetadata + a "project" SupportsDebuggingAnnotation
// (e.g. DotnetProjectResource, which launches `dotnet run --project`) must be launched/debugged like
// AddProject: IDE execution with a ProjectLaunchConfiguration (project_path + launch profile) so F5 works.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithAnnotation(new LaunchProfileAnnotation("http"))
.WithDebugSupport(mode => ProjectLaunchConfigurationFactory.Create(resource, mode), KnownLaunchConfigurationTypes.Project);
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.Equal("TestProjectWithLaunchSettings", plc.ProjectPath);
Assert.Equal("http", plc.LaunchProfile);
Assert.Equal(ExecutableLaunchMode.NoDebug, plc.Mode);
}
[Fact]
public void GetResourceType_DcpExecutable_DelegatesToAppModelClassifier()
{
// Regression guard for the DCP resource-type classifier. A DotnetProjectResource is an ExecutableResource
// that carries IProjectMetadata, so DCP realizes it as an Executable (not a Container). The dashboard
// snapshot classifies it as "Project" (via ResourceExtensions.GetResourceType); DcpExecutor.GetResourceType
// must agree, otherwise the same resource reports "Executable" in DCP create/start/watch events and
// profiling telemetry while showing "Project" everywhere else. A plain ExecutableResource must still
// classify as "Executable".
var dcpExecutable = Executable.Create("test-exe", "dotnet");
var dotnetProject = new TestDotnetProjectExecutableResource("test-working-directory");
dotnetProject.Annotations.Add(new TestProjectWithLaunchSettings());
Assert.Equal(KnownResourceTypes.Project, DcpExecutor.GetResourceType(dcpExecutable, dotnetProject));
var plainExecutable = new TestExecutableResource("test-working-directory");
Assert.Equal(KnownResourceTypes.Executable, DcpExecutor.GetResourceType(dcpExecutable, plainExecutable));
// A DotnetToolResource is also realized as a DCP Executable but the app-model classifier reports "Tool"
// so the dashboard can render it distinctly. ApplicationOrchestrator.OnResourceStarting handles "Tool" like an
// executable so the resource still transitions to the Starting state.
#pragma warning disable ASPIREDOTNETTOOL // DotnetToolResource is experimental.
var dotnetTool = new DotnetToolResource("test-tool", "SomePackage.Id");
#pragma warning restore ASPIREDOTNETTOOL
Assert.Equal(KnownResourceTypes.Tool, DcpExecutor.GetResourceType(dcpExecutable, dotnetTool));
}
[Fact]
public async Task DotnetProjectExecutable_ProjectLaunchUnsupported_RunsInProcess()
{
// When the IDE does not advertise "project" support, the resource should run as a plain process with
// no ProjectLaunchConfiguration applied.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["python"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.False(exe.TryGetProjectLaunchConfiguration(out _));
}
[Fact]
public async Task DotnetProjectExecutable_PersistentLifetime_InDebugSession_RunsInProcessWithoutProjectLaunchConfig()
{
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project")
.WithPersistentLifetime();
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject");
Assert.True(exe.Spec.Persistent);
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.False(exe.TryGetProjectLaunchConfiguration(out _));
}
[Fact]
public async Task DotnetProjectExecutable_ProjectLaunchConfigurationFailure_FailsResource()
{
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithDebugSupport(CreateProjectLaunchConfiguration, "project");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var resourceLoggerService = new ResourceLoggerService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
resourceLoggerService: resourceLoggerService,
events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(kubernetesService.CreatedResources.OfType<Executable>());
Assert.Same(resource, Assert.Single(failedResources));
var logLines = new List<LogLine>();
await foreach (var lines in resourceLoggerService.GetAllAsync(resource).DefaultTimeout())
{
logLines.AddRange(lines);
}
Assert.Contains(logLines, line => line.Content.Contains("Project launch configuration failed.", StringComparison.Ordinal));
static Task<ProjectLaunchConfiguration> CreateProjectLaunchConfiguration(string mode, CancellationToken cancellationToken)
{
throw new InvalidOperationException("Project launch configuration failed.");
}
}
[Fact]
public async Task PlainExecutable_ExtensionMode_LaunchToolArgsDebugSupport_WithholdsOwnedPrefix()
{
// A non-"project" debuggable executable that declares launch tool arguments (e.g. Go/Python, where the IDE
// debugger owns the `go run <pkg>` / `python -m <mod>` tool invocation) must not pass the prefix to the
// launched program.
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable)
.WithArgs("app-arg")
.WithLaunchToolArgs(static ctx => ctx.Args.Add("run"), ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
// The dashboard still shows the resource's real command line, prefix included. The prefix has no effective
// argument index because it is not passed to the launched program.
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Collection(displayArgs,
arg =>
{
Assert.Equal("run", arg.Argument);
Assert.Null(arg.EffectiveArgumentIndex);
},
arg =>
{
Assert.Equal("app-arg", arg.Argument);
Assert.Equal(0, arg.EffectiveArgumentIndex);
});
}
[Fact]
public async Task PlainExecutable_ExtensionMode_OwnedLaunchToolArgsCanBeHiddenFromCommandLine()
{
// A matching IDE launch configuration both performs the owned tool invocation and can hide that plumbing from
// the dashboard, leaving only the ordinary program arguments in both observable argument lists.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(
static ctx => ctx.Args.Add("run"),
ownedByLaunchConfigurationType: "test",
showInCommandLine: false)
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
var displayArg = Assert.Single(displayArgs);
Assert.Equal("app-arg", displayArg.Argument);
Assert.Equal(0, displayArg.EffectiveArgumentIndex);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_CertificateCallbackCannotShiftLaunchToolPrefixBoundary()
{
// Certificate callbacks run after launch tool arguments are gathered and can mutate ordinary arguments.
// Keep the prefix in a separate segment so inserting at the front cannot change which arguments the IDE owns.
var builder = DistributedApplication.CreateBuilder();
using var certificate = CreateTestCertificate();
var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates")
.WithCertificate(certificate);
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(static ctx => ctx.Args.Add("run"), ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test")
.WithCertificateAuthorityCollection(certificateAuthorities)
.WithCertificateTrustConfiguration(static ctx =>
{
ctx.Arguments.Insert(0, "certificate-arg");
return Task.CompletedTask;
});
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["certificate-arg", "app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
Assert.Collection(displayArgs,
arg =>
{
Assert.Equal("run", arg.Argument);
Assert.Null(arg.EffectiveArgumentIndex);
},
arg =>
{
Assert.Equal("certificate-arg", arg.Argument);
Assert.Equal(0, arg.EffectiveArgumentIndex);
},
arg =>
{
Assert.Equal("app-arg", arg.Argument);
Assert.Equal(1, arg.EffectiveArgumentIndex);
});
}
[Fact]
public async Task PlainExecutable_ExtensionMode_EmptyLaunchToolArgs_DoesNotConfigureRuntimeFallback()
{
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable)
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task PlainExecutable_UnownedLaunchToolArgs_AreExecutedButCanBeHiddenFromTheCommandLine()
{
// Forward-compatibility coverage for https://github.com/microsoft/aspire/issues/18904: a tool-invocation
// prefix that is not a debugging concern at all (the `dotnet tool exec <pkg> --yes --` shape) declares no
// owning launch configuration type, so it is always executed, and opts out of the displayed command line
// because it is plumbing the user neither wrote nor can act on.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(
static ctx =>
{
ctx.Args.Add("tool");
ctx.Args.Add("exec");
},
showInCommandLine: false);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
Assert.Equal(["tool", "exec", "app-arg"], exe.Spec.Args);
// The prefix runs but is absent from the dashboard command line. It stays visible in the resource details
// pane regardless, because that pane reports the process's effective arguments.
Assert.True(exe.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs));
var displayArg = Assert.Single(displayArgs);
Assert.Equal("app-arg", displayArg.Argument);
Assert.Equal(2, displayArg.EffectiveArgumentIndex);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_UnownedLaunchToolArgs_AreNotWithheldFromTheLaunchedProgram()
{
// Launch tool arguments that name no owning launch configuration type are not a debugging concern, so an
// active launch configuration must not withhold them — otherwise the launched program would lose a prefix
// no debugger ever performs.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(static ctx => ctx.Args.Add("run"))
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["run", "app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_LaunchToolArgsDebugSupport_LaunchConfigFailure_FailsResource()
{
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(static ctx => ctx.Args.Add("run"), ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
ThrowingLaunchConfiguration,
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Same(resource, Assert.Single(failedResources));
static Task<ExecutableLaunchConfiguration> ThrowingLaunchConfiguration(string mode, CancellationToken cancellationToken)
{
throw new InvalidOperationException("Launch configuration failed.");
}
}
[Fact]
public async Task PlainExecutable_ExtensionMode_RestartLaunchConfigCancellationIsPropagated()
{
var builder = DistributedApplication.CreateBuilder();
var callbackCount = 0;
using var restartCancellation = new CancellationTokenSource();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithDebugSupport(
(mode, cancellationToken) =>
{
if (Interlocked.Increment(ref callbackCount) == 2)
{
restartCancellation.Cancel();
cancellationToken.ThrowIfCancellationRequested();
}
return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = mode });
},
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
var reference = appExecutor.GetResource(exe.Metadata.Name);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => appExecutor.StartResourceAsync(reference, restartCancellation.Token));
Assert.Equal(2, callbackCount);
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Equal([exe.Metadata.Name], kubernetesService.DeletedResources);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_NullLaunchToolArgument_DoesNotOmitApplicationArgument()
{
// Launch tool argument values can resolve to null and disappear from the final argument list. The resolved prefix
// boundary must shrink with them so the first ordinary argument remains executable.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(static ctx => ctx.Args.Add(NullValueProvider.Instance), ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task DotnetProjectExecutable_EmptyOwnedLaunchToolArgs_UsesApplicationArgumentsOnly()
{
// DotnetProjectResource suppresses its `dotnet run` scaffold when a custom launch configuration owns the
// tool invocation. An empty prefix therefore leaves only the application arguments for the IDE.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "custom")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("custom") { Mode = mode },
"custom");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["custom"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
Assert.Equal(["app-arg"], exe.Spec.Args);
Assert.Null(exe.Spec.FallbackExecutionTypes);
}
[Fact]
public async Task DotnetProjectExecutable_EmptyOwnedLaunchToolArgs_LaunchConfigFailureDoesNotRunBrokenProcessCommand()
{
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "custom")
.WithDebugSupport(
ThrowingLaunchConfiguration,
"custom");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["custom"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var resourceLoggerService = new ResourceLoggerService();
var failedResources = new List<(IResource Resource, string? ErrorMessage)>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add((context.Resource, context.ErrorMessage));
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
resourceLoggerService: resourceLoggerService,
events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "TestDotnetProject"));
var failure = Assert.Single(failedResources);
Assert.Same(resource, failure.Resource);
Assert.NotNull(failure.ErrorMessage);
Assert.Contains("Failed to apply launch configuration", failure.ErrorMessage);
Assert.Contains("does not retry launch configuration failures using DCP process fallback", failure.ErrorMessage);
var logLines = new List<LogLine>();
await foreach (var lines in resourceLoggerService.GetAllAsync(resource).DefaultTimeout())
{
logLines.AddRange(lines);
}
Assert.Contains(logLines, line =>
line.IsErrorMessage &&
line.Content.Contains("Launch configuration failed.", StringComparison.Ordinal) &&
line.Content.Contains("does not retry launch configuration failures using DCP process fallback", StringComparison.Ordinal));
static ExecutableLaunchConfiguration ThrowingLaunchConfiguration(string mode)
{
throw new InvalidOperationException("Launch configuration failed.");
}
}
[Fact]
public async Task DotnetProjectExecutable_EmptyOwnedLaunchToolArgs_LaunchConfigFailureOnRestartPropagatesFailureAndRetainsDiagnostic()
{
var builder = DistributedApplication.CreateBuilder();
var launchConfigurationCallCount = 0;
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithArgs("app-arg")
.WithLaunchToolArgs(static _ => { }, ownedByLaunchConfigurationType: "custom")
.WithDebugSupport(
mode =>
{
if (Interlocked.Increment(ref launchConfigurationCallCount) == 2)
{
throw new InvalidOperationException("Launch configuration failed.");
}
return new ExecutableLaunchConfiguration("custom") { Mode = mode };
},
"custom");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["custom"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var resourceLoggerService = new ResourceLoggerService();
var failedResources = new ConcurrentQueue<(IResource Resource, string? ErrorMessage)>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Enqueue((context.Resource, context.ErrorMessage));
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
resourceLoggerService: resourceLoggerService,
events: events);
await appExecutor.RunApplicationAsync();
var executable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestDotnetProject"));
var reference = appExecutor.GetResource(executable.Metadata.Name);
var exception = await Assert.ThrowsAsync<FailedToApplyEnvironmentException>(
() => appExecutor.StartResourceAsync(reference, CancellationToken.None));
Assert.Contains("Failed to apply launch configuration", exception.Message);
Assert.Contains("does not retry launch configuration failures using DCP process fallback", exception.Message);
Assert.Equal(2, launchConfigurationCallCount);
Assert.Equal([executable.Metadata.Name], kubernetesService.DeletedResources);
Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestDotnetProject"));
var failure = Assert.Single(failedResources);
Assert.Same(resource, failure.Resource);
Assert.NotNull(failure.ErrorMessage);
Assert.Contains("Failed to apply launch configuration", failure.ErrorMessage);
Assert.Contains("does not retry launch configuration failures using DCP process fallback", failure.ErrorMessage);
var logLines = new List<LogLine>();
await foreach (var lines in resourceLoggerService.GetAllAsync(resource).DefaultTimeout())
{
logLines.AddRange(lines);
}
Assert.Contains(logLines, line =>
line.IsErrorMessage &&
line.Content.Contains("Launch configuration failed.", StringComparison.Ordinal) &&
line.Content.Contains("does not retry launch configuration failures using DCP process fallback", StringComparison.Ordinal));
}
[Fact]
public async Task PlainExecutable_ExtensionMode_LaunchToolArgumentsAreRecomputedOnRestart()
{
// Restart invalidates launch tool callback caches without rerunning preparation. Vary the owned prefix
// across creations to prove each launch plan and dashboard projection use the newly resolved arguments.
var builder = DistributedApplication.CreateBuilder();
var callbackCount = 0;
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithLaunchToolArgs(
ctx =>
{
if (Interlocked.Increment(ref callbackCount) == 2)
{
ctx.Args.Add("run");
}
},
ownedByLaunchConfigurationType: "test")
.WithDebugSupport(
mode => new ExecutableLaunchConfiguration("test") { Mode = mode },
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe1 = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Null(exe1.Spec.FallbackExecutionTypes);
Assert.True(exe1.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs1));
Assert.Equal(["app-arg"], displayArgs1.Select(static argument => argument.Argument));
var reference = appExecutor.GetResource(exe1.Metadata.Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
var executables = GetCreatedExecutablesForResource(kubernetesService, "TestExecutable");
Assert.Equal(2, executables.Count);
var exe2 = executables[1];
Assert.Null(exe2.Spec.FallbackExecutionTypes);
Assert.True(exe2.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs2));
Assert.Equal(["run", "app-arg"], displayArgs2.Select(static argument => argument.Argument));
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
executables = GetCreatedExecutablesForResource(kubernetesService, "TestExecutable");
Assert.Equal(3, executables.Count);
var exe3 = executables[2];
Assert.Null(exe3.Spec.FallbackExecutionTypes);
Assert.True(exe3.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var displayArgs3));
Assert.Equal(["app-arg"], displayArgs3.Select(static argument => argument.Argument));
Assert.Equal(3, callbackCount);
}
[Fact]
public async Task PlainExecutable_ExtensionMode_LaunchConfigurationFailureDoesNotReusePriorPlanOnRestart()
{
var builder = DistributedApplication.CreateBuilder();
var callbackCount = 0;
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithArgs("app-arg")
.WithDebugSupport(
mode =>
{
if (Interlocked.Increment(ref callbackCount) == 2)
{
throw new InvalidOperationException("Launch configuration failed.");
}
return new ExecutableLaunchConfiguration("test") { Mode = mode };
},
"test");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe1 = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Equal(ExecutionType.IDE, exe1.Spec.ExecutionType);
Assert.Null(exe1.Spec.FallbackExecutionTypes);
var reference = appExecutor.GetResource(exe1.Metadata.Name);
var exception = await Assert.ThrowsAsync<FailedToApplyEnvironmentException>(
() => appExecutor.StartResourceAsync(reference, CancellationToken.None));
Assert.Contains("Failed to apply launch configuration", exception.Message);
Assert.Contains("does not retry launch configuration failures using DCP process fallback", exception.Message);
var executables = GetCreatedExecutablesForResource(kubernetesService, "TestExecutable");
Assert.Single(executables);
Assert.Equal(ExecutionType.IDE, exe1.Spec.ExecutionType);
Assert.Null(exe1.Spec.FallbackExecutionTypes);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
executables = GetCreatedExecutablesForResource(kubernetesService, "TestExecutable");
Assert.Equal(2, executables.Count);
var exe2 = executables[1];
Assert.Equal(ExecutionType.IDE, exe2.Spec.ExecutionType);
Assert.Null(exe2.Spec.FallbackExecutionTypes);
Assert.Equal(3, callbackCount);
}
[Fact]
public async Task PlainExecutable_ProjectDebugSupportWithoutProjectMetadata_FailsToStart()
{
// "project" is reserved for resources carrying IProjectMetadata, so a plain executable must fail with an
// actionable message instead of sending an incomplete launch configuration to the IDE.
var builder = DistributedApplication.CreateBuilder();
var resource = new TestExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode }, "project");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
var failedResources = new List<(IResource Resource, string? ErrorMessage)>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add((context.Resource, context.ErrorMessage));
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(kubernetesService.CreatedResources.OfType<Executable>());
var failure = Assert.Single(failedResources);
Assert.Same(resource, failure.Resource);
Assert.NotNull(failure.ErrorMessage);
Assert.Contains("project metadata", failure.ErrorMessage);
}
[Theory]
[InlineData(ExecutableLaunchMode.Debug, ExecutableLaunchMode.Debug)]
[InlineData(ExecutableLaunchMode.NoDebug, ExecutableLaunchMode.NoDebug)]
public async Task DotnetProjectExecutable_RespectsDebugSessionRunMode(string runMode, string expectedMode)
{
var builder = DistributedApplication.CreateBuilder();
var resource = new TestDotnetProjectExecutableResource("test-working-directory");
builder.AddResource(resource)
.WithAnnotation(new TestProjectWithLaunchSettings())
.WithAnnotation(new LaunchProfileAnnotation("http"))
.WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionRunMode] = runMode,
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject");
Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc));
Assert.Equal(expectedMode, plc.Mode);
}
[Theory]
[InlineData(true, null, "aspire.dev.internal")]
[InlineData(false, null, "host.docker.internal")]
[InlineData(true, "super.star", "aspire.dev.internal")]
[InlineData(false, "mega.mushroom", "mega.mushroom")]
public async Task EndpointsAllocatedCorrectly(bool useTunnel, string? containerHostName, string expectedContainerHost)
{
var builder = DistributedApplication.CreateBuilder();
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "proxied", targetPort: 1234, port: 5678, isProxied: true)
.WithEndpoint(name: "notProxied", port: 8765, isProxied: false);
var container = builder.AddContainer("aContainer", "image")
.WithEndpoint(name: "proxied", port: 15678, targetPort: 11234, isProxied: true)
.WithEndpoint(name: "notProxied", port: 18765, isProxied: false)
.WithEnvironment("EXE_PROXIED_PORT", executable.GetEndpoint("proxied").Property(EndpointProperty.Port))
.WithEnvironment("EXE_NOTPROXIED_PORT", executable.GetEndpoint("notProxied").Property(EndpointProperty.Port));
var containerWithAlias = builder.AddContainer("containerWithAlias", "image")
.WithEndpoint(name: "proxied", port: 25678, targetPort: 21234, isProxied: true)
.WithEndpoint(name: "notProxied", port: 28765, isProxied: false)
.WithContainerNetworkAlias("custom.alias")
.WithEnvironment("EXE_PROXIED_PORT", executable.GetEndpoint("proxied").Property(EndpointProperty.Port))
.WithEnvironment("EXE_NOTPROXIED_PORT", executable.GetEndpoint("notProxied").Property(EndpointProperty.Port));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var configDict = new Dictionary<string, string?>
{
["AppHost:ContainerHostname"] = containerHostName
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = useTunnel,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 5678);
await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 8765);
if (useTunnel)
{
await AssertTunneledPort(executable.Resource, "proxied", 5678);
await AssertTunneledPort(executable.Resource, "notProxied", 8765);
async ValueTask AssertTunneledPort(IResourceWithEndpoints resource, string endpointName, int hostPort)
{
var svcs = kubernetesService.CreatedResources
.OfType<Service>()
.Where(x => x.AppModelResourceName == resource.Name
&& x.EndpointName == endpointName
&& x.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName))
.ToList();
var svc = svcs.Single();
int port = svc.AllocatedPort!.Value;
await AssertEndpoint(executable.Resource, endpointName, KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, port);
await AssertEndpoint(executable.Resource, endpointName, KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, hostPort);
var dcpContainer = kubernetesService.CreatedResources
.OfType<Container>()
.Where(c => c.AppModelResourceName == container.Resource.Name)
.Single();
var exePortEnvVal = dcpContainer.Spec?.Env?.Where(e => e.Name == $"EXE_{endpointName.ToUpper()}_PORT").Single().Value;
Assert.Equal(port.ToString(), exePortEnvVal);
}
}
else
{
await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 5678);
await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 8765);
await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 5678);
await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 8765);
}
await AssertEndpoint(container.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 15678);
await AssertEndpoint(container.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 18765);
await AssertEndpoint(container.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{container.Resource.Name}.dev.internal", 11234);
await AssertEndpoint(container.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{container.Resource.Name}.dev.internal", 18765);
await AssertEndpoint(containerWithAlias.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 25678);
await AssertEndpoint(containerWithAlias.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 28765);
await AssertEndpoint(containerWithAlias.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{containerWithAlias.Resource.Name}.dev.internal", 21234);
await AssertEndpoint(containerWithAlias.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{containerWithAlias.Resource.Name}.dev.internal", 28765);
async ValueTask AssertEndpoint(IResourceWithEndpoints resource, string name, NetworkIdentifier network, string address, int port)
{
var endpoint = resource.GetEndpoint(name).EndpointAnnotation;
var allocatedEndpoints = endpoint.AllAllocatedEndpoints;
Assert.Contains(allocatedEndpoints, a => a.NetworkID == network);
var allocatedEndpoint = await endpoint.AllAllocatedEndpoints.Single(x => x.NetworkID == network).Snapshot.GetValueAsync().DefaultTimeout();
Assert.Equal(endpoint, allocatedEndpoint.Endpoint);
Assert.Equal(address, allocatedEndpoint.Address);
Assert.Equal(EndpointBindingMode.SingleAddress, allocatedEndpoint.BindingMode);
Assert.Equal(port, allocatedEndpoint.Port);
Assert.Equal(endpoint.UriScheme, allocatedEndpoint.UriScheme);
Assert.Equal($"{address}:{port}", allocatedEndpoint.EndPointString);
}
}
[Fact]
public async Task ContainerHostUrlWithoutMatchingHostEndpointUsesContainerHostBridge()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("aContainer", "image")
.WithEnvironment("URL", new HostUrl("https://localhost:17092/path"));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
var dcpContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
Assert.NotNull(dcpContainer.Spec.Env);
var url = Assert.Single(dcpContainer.Spec.Env, e => e.Name == "URL").Value;
Assert.Equal("https://host.docker.internal:17092/path", url);
Assert.DoesNotContain(kubernetesService.CreatedResources.OfType<Service>(),
s => s.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName));
}
[Fact]
public async Task ContainerHostUrlMatchingHostEndpointUsesTunnelPort()
{
var builder = DistributedApplication.CreateBuilder();
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
builder.AddContainer("aContainer", "image")
.WithEnvironment("URL", new HostUrl("https://localhost:5678/path"));
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var failedResources = new ConcurrentBag<string>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource.Name);
return Task.CompletedTask;
});
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync();
Assert.DoesNotContain("aContainer", failedResources);
var dcpContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
var tunnelService = Assert.Single(kubernetesService.CreatedResources.OfType<Service>(),
s => s.AppModelResourceName == executable.Resource.Name
&& s.EndpointName == "http"
&& s.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName));
Assert.NotNull(dcpContainer.Spec.Env);
var url = Assert.Single(dcpContainer.Spec.Env, e => e.Name == "URL").Value;
Assert.Equal($"https://{KnownHostNames.DefaultContainerTunnelHostName}:{tunnelService.AllocatedPort}/path", url);
}
// Verifies that environment value callbacks are invoked only once per container startup.
[Fact]
public async Task EnvironmentCallbacksInvokedOnceOnContainer()
{
var builder = DistributedApplication.CreateBuilder();
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
var callCount = 0;
builder.AddContainer("aContainer", "image")
.WithEnvironment(c =>
{
Interlocked.Increment(ref callCount);
c.EnvironmentVariables["EXEC_PORT"] = executable.GetEndpoint("http").Property(EndpointProperty.Port);
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, callCount);
}
// Ensures that environment value callbacks are invoked after the OnResourceStarting event is raised for the resource,
// allowing users to rely on any state set during that event in their environment callbacks.
[Fact]
public async Task EnvironmentCallbacksInvokedAfterBeforeResourceStartEvent()
{
var builder = DistributedApplication.CreateBuilder();
var envCallCount = 0;
var resourceStartingRaised = false;
var resourceStartingCalledBeforeEnvCallback = false;
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
builder.AddContainer("aContainer", "image")
.WithEnvironment(c =>
{
Interlocked.Increment(ref envCallCount);
c.EnvironmentVariables["EXEC_PORT"] = executable.GetEndpoint("http").Property(EndpointProperty.Port);
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(context =>
{
if (context.ResourceType == "Container")
{
resourceStartingRaised = true;
resourceStartingCalledBeforeEnvCallback = envCallCount == 0;
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, envCallCount);
Assert.True(resourceStartingRaised, "OnResourceStarting should be raised for the container");
Assert.True(resourceStartingCalledBeforeEnvCallback, "OnResourceStarting should be raised before the environment callback is invoked");
}
// Verifies that command-line argument callbacks are invoked only once per container startup.
[Fact]
public async Task ArgsCallbacksInvokedOnceOnContainer()
{
var builder = DistributedApplication.CreateBuilder();
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
var callCount = 0;
builder.AddContainer("aContainer", "image")
.WithArgs(c =>
{
Interlocked.Increment(ref callCount);
c.Args.Add("--port");
c.Args.Add(executable.GetEndpoint("http").Property(EndpointProperty.Port));
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, callCount);
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
Assert.True(container.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, container.Spec.Args);
}
[Fact]
public async Task ExecutionConfigurationCallbacksDeferredForExplicitStartExecutableUntilManualStart()
{
var builder = DistributedApplication.CreateBuilder();
var argsCallCount = 0;
var envCallCount = 0;
var resource = builder.AddExecutable("anExecutable", "command", "")
.WithExplicitStart()
.WithArgs(c =>
{
Interlocked.Increment(ref argsCallCount);
c.Args.Add("--deferred");
})
.WithEnvironment(c =>
{
Interlocked.Increment(ref envCallCount);
c.EnvironmentVariables["DEFERRED_ENV"] = "true";
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
Assert.Equal(0, argsCallCount);
Assert.Equal(0, envCallCount);
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "anExecutable"));
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
var startedExecutable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable"), e => e.Spec.Start == true);
Assert.Contains("--deferred", startedExecutable.Spec.Args!);
Assert.Contains(startedExecutable.Spec.Env!, e => e is { Name: "DEFERRED_ENV", Value: "true" });
Assert.True(startedExecutable.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Single(argAnnotations, a => a.Argument == "--deferred");
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, startedExecutable.Spec.Args);
}
[Fact]
public async Task ExecutionConfigurationCallbacksDeferredForExplicitStartContainerUntilManualStart()
{
var builder = DistributedApplication.CreateBuilder();
var argsCallCount = 0;
var envCallCount = 0;
var resource = builder.AddContainer("aContainer", "image")
.WithExplicitStart()
.WithArgs(c =>
{
Interlocked.Increment(ref argsCallCount);
c.Args.Add("--deferred");
})
.WithEnvironment(c =>
{
Interlocked.Increment(ref envCallCount);
c.EnvironmentVariables["DEFERRED_ENV"] = "true";
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
Assert.Equal(0, argsCallCount);
Assert.Equal(0, envCallCount);
Assert.DoesNotContain(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
var startedContainer = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer" && c.Spec.Start == true);
Assert.Contains("--deferred", startedContainer.Spec.Args!);
Assert.Contains(startedContainer.Spec.Env!, e => e is { Name: "DEFERRED_ENV", Value: "true" });
Assert.True(startedContainer.TryGetAnnotationAsObjectList<AppLaunchArgumentAnnotation>(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
Assert.Single(argAnnotations, a => a.Argument == "--deferred");
AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations, startedContainer.Spec.Args);
}
[Fact]
public async Task ExecutionConfigurationCallbacksNotReevaluatedWhenStartingCreatedExplicitStartPersistentExecutable()
{
var builder = DistributedApplication.CreateBuilder();
var argsCallCount = 0;
var envCallCount = 0;
var resource = builder.AddExecutable("anExecutable", "command", "")
.WithPersistentLifetime()
.WithExplicitStart()
.WithArgs(c =>
{
Interlocked.Increment(ref argsCallCount);
c.Args.Add("--persistent");
})
.WithEnvironment(c =>
{
Interlocked.Increment(ref envCallCount);
c.EnvironmentVariables["PERSISTENT_ENV"] = "true";
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
var executable = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable"));
Assert.False(executable.Spec.Start);
Assert.True(executable.Spec.Persistent);
Assert.Contains("--persistent", executable.Spec.Args!);
Assert.Contains(executable.Spec.Env!, e => e is { Name: "PERSISTENT_ENV", Value: "true" });
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
Assert.Empty(kubernetesService.DeletedResources);
Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "anExecutable"));
Assert.True(executable.Spec.Start);
}
[Fact]
public async Task ExecutionConfigurationCallbacksNotDeferredForExplicitStartPersistentContainer()
{
var builder = DistributedApplication.CreateBuilder();
var argsCallCount = 0;
var envCallCount = 0;
var resource = builder.AddContainer("aContainer", "image")
.WithPersistentLifetime()
.WithExplicitStart()
.WithArgs(c =>
{
Interlocked.Increment(ref argsCallCount);
c.Args.Add("--persistent");
})
.WithEnvironment(c =>
{
Interlocked.Increment(ref envCallCount);
c.EnvironmentVariables["PERSISTENT_ENV"] = "true";
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var configDict = new Dictionary<string, string?>
{
["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
Assert.False(container.Spec.Start);
Assert.True(container.Spec.Persistent);
Assert.Contains("--persistent", container.Spec.Args!);
Assert.Contains(container.Spec.Env!, e => e is { Name: "PERSISTENT_ENV", Value: "true" });
var reference = appExecutor.GetResource(DcpExecutor.GetDcpInstance(resource.Resource, instanceIndex: 0).Name);
await appExecutor.StartResourceAsync(reference, CancellationToken.None);
Assert.Equal(1, argsCallCount);
Assert.Equal(1, envCallCount);
Assert.Empty(kubernetesService.DeletedResources);
Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
Assert.True(container.Spec.Start);
}
// Ensures that command-line argument callbacks are invoked after the OnResourceStarting event is raised for the resource,
// allowing users to rely on any state set during that event in their argument callbacks.
[Fact]
public async Task ArgsCallbacksInvokedAfterBeforeResourceStartEvent()
{
var builder = DistributedApplication.CreateBuilder();
var argsCallCount = 0;
var resourceStartingRaised = false;
var resourceStartingCalledBeforeArgsCallback = false;
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
builder.AddContainer("aContainer", "image")
.WithArgs(c =>
{
Interlocked.Increment(ref argsCallCount);
c.Args.Add("--port");
c.Args.Add(executable.GetEndpoint("http").Property(EndpointProperty.Port));
});
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(context =>
{
if (context.ResourceType == "Container")
{
resourceStartingRaised = true;
resourceStartingCalledBeforeArgsCallback = argsCallCount == 0;
}
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
Assert.Equal(1, argsCallCount);
Assert.True(resourceStartingRaised, "OnResourceStarting should be raised for the container");
Assert.True(resourceStartingCalledBeforeArgsCallback, "OnResourceStarting should be raised before the args callback is invoked");
}
[Fact]
public async Task TunnelDependentAndIndependentContainersCanStartTogether()
{
var builder = DistributedApplication.CreateBuilder();
// An executable with an endpoint — containers that reference it will be tunnel-dependent.
var executable = builder.AddExecutable("anExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
// A container that references the executable's endpoint — this makes it tunnel-dependent.
builder.AddContainer("tunnelDependent", "image")
.WithEnvironment("EXEC_PORT", executable.GetEndpoint("http").Property(EndpointProperty.Port));
// A container that does NOT reference any host resource — this is tunnel-independent.
builder.AddContainer("tunnelIndependent", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions);
await appExecutor.RunApplicationAsync();
// Both containers should have been created successfully.
var createdContainers = kubernetesService.CreatedResources.OfType<Container>().ToList();
Assert.Single(createdContainers, c => c.AppModelResourceName == "tunnelDependent");
Assert.Single(createdContainers, c => c.AppModelResourceName == "tunnelIndependent");
}
[Fact]
public async Task WaitingTunnelDependentContainersDoNotBlockTunnelCreation()
{
var builder = DistributedApplication.CreateBuilder();
var executableA = builder.AddExecutable("executableA", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
var executableB = builder.AddExecutable("executableB", "command", "")
.WithEndpoint(name: "http", targetPort: 1235, port: 5679, isProxied: true);
var container = builder.AddContainer("container", "image")
.WithEnvironment("EXEC_A_PORT", executableA.GetEndpoint("http").Property(EndpointProperty.Port));
var waiting = builder.AddContainer("waiting", "image")
.WaitFor(container);
var waitingConsumingEndpoint = builder.AddContainer("waitingConsumingEndpoint", "image")
.WithEnvironment("EXEC_B_PORT", executableB.GetEndpoint("http").Property(EndpointProperty.Port))
.WaitFor(container);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(async context =>
{
if (context.Resource == waiting.Resource || context.Resource == waitingConsumingEndpoint.Resource)
{
while (!kubernetesService.CreatedResources.OfType<Container>().Any(c => c.AppModelResourceName == container.Resource.Name))
{
await Task.Delay(10, context.CancellationToken).ConfigureAwait(false);
}
}
});
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var createdContainers = kubernetesService.CreatedResources.OfType<Container>().ToList();
Assert.Single(createdContainers, c => c.AppModelResourceName == container.Resource.Name);
Assert.Single(createdContainers, c => c.AppModelResourceName == waiting.Resource.Name);
var waitingConsumingContainer = Assert.Single(createdContainers, c => c.AppModelResourceName == waitingConsumingEndpoint.Resource.Name);
var tunnelServices = kubernetesService.CreatedResources
.OfType<Service>()
.Where(s => s.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName))
.ToList();
Assert.Single(tunnelServices, s => s.AppModelResourceName == executableA.Resource.Name);
var executableBTunnelService = Assert.Single(tunnelServices, s => s.AppModelResourceName == executableB.Resource.Name);
Assert.NotNull(waitingConsumingContainer.Spec.Env);
var executableBPort = Assert.Single(waitingConsumingContainer.Spec.Env, e => e.Name == "EXEC_B_PORT").Value;
Assert.Equal(executableBTunnelService.AllocatedPort.ToString(), executableBPort);
var tunnelProxy = Assert.Single(kubernetesService.CreatedResources.OfType<ContainerNetworkTunnelProxy>());
Assert.Equal(2, tunnelProxy.Spec.Tunnels?.Count);
}
[Fact]
public async Task HostResourceCanWaitForTunnelDependentContainer()
{
var builder = DistributedApplication.CreateBuilder();
var upstreamExecutable = builder.AddExecutable("upstreamExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1234, port: 5678, isProxied: true);
var tunnelDependentContainer = builder.AddContainer("tunnelDependentContainer", "image")
.WithEnvironment("UPSTREAM_PORT", upstreamExecutable.GetEndpoint("http").Property(EndpointProperty.Port))
.WaitFor(upstreamExecutable);
var downstreamExecutable = builder.AddExecutable("downstreamExecutable", "command", "")
.WithEndpoint(name: "http", targetPort: 1235, port: 5679, isProxied: true)
.WaitFor(tunnelDependentContainer);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceStartingContext>(async context =>
{
if (context.Resource == tunnelDependentContainer.Resource)
{
while (!kubernetesService.CreatedResources.OfType<Executable>().Any(e => e.AppModelResourceName == upstreamExecutable.Resource.Name))
{
await Task.Delay(10, context.CancellationToken).ConfigureAwait(false);
}
}
if (context.Resource == downstreamExecutable.Resource)
{
while (!kubernetesService.CreatedResources.OfType<Container>().Any(c => c.AppModelResourceName == tunnelDependentContainer.Resource.Name))
{
await Task.Delay(10, context.CancellationToken).ConfigureAwait(false);
}
}
});
var dcpOptions = new DcpOptions
{
EnableAspireContainerTunnel = true,
};
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
await appExecutor.RunApplicationAsync().DefaultTimeout();
var createdResources = kubernetesService.CreatedResources.ToList();
var upstreamExecutableResource = Assert.Single(createdResources.OfType<Executable>(), e => e.AppModelResourceName == upstreamExecutable.Resource.Name);
var downstreamExecutableResource = Assert.Single(createdResources.OfType<Executable>(), e => e.AppModelResourceName == downstreamExecutable.Resource.Name);
var tunnelDependentDcpContainer = Assert.Single(createdResources.OfType<Container>(), c => c.AppModelResourceName == tunnelDependentContainer.Resource.Name);
Assert.True(
createdResources.IndexOf(tunnelDependentDcpContainer) < createdResources.IndexOf(downstreamExecutableResource),
"The downstream host resource should not be created until the tunnel-dependent container it waits for has been created.");
var tunnelServices = createdResources
.OfType<Service>()
.Where(s => s.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName))
.ToList();
var upstreamTunnelService = Assert.Single(tunnelServices, s => s.AppModelResourceName == upstreamExecutable.Resource.Name);
Assert.NotNull(tunnelDependentDcpContainer.Spec.Env);
var upstreamPort = Assert.Single(tunnelDependentDcpContainer.Spec.Env, e => e.Name == "UPSTREAM_PORT").Value;
Assert.Equal(upstreamTunnelService.AllocatedPort.ToString(), upstreamPort);
var tunnelProxy = Assert.Single(createdResources.OfType<ContainerNetworkTunnelProxy>());
Assert.Equal(1, tunnelProxy.Spec.Tunnels?.Count);
Assert.NotNull(upstreamExecutableResource);
}
[Fact]
public async Task EnvironmentCallbackThrows_OtherResourcesStillStart()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("failing", "image")
.WithEnvironment(c =>
{
throw new InvalidOperationException("env callback failure");
});
builder.AddContainer("healthy", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var failedResources = new List<string>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(c =>
{
failedResources.Add(c.Resource.Name);
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
// The healthy container should have been created successfully.
var createdContainers = kubernetesService.CreatedResources.OfType<Container>().ToList();
Assert.Single(createdContainers, c => c.AppModelResourceName == "healthy");
// The failing container should not have been created and should be reported as failed.
Assert.DoesNotContain(createdContainers, c => c.AppModelResourceName == "failing");
Assert.Single(failedResources, name => name == "failing");
}
[Fact]
public async Task ArgsCallbackThrows_OtherResourcesStillStart()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddContainer("failing", "image")
.WithArgs(c =>
{
throw new InvalidOperationException("args callback failure");
});
builder.AddContainer("healthy", "image");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var failedResources = new List<string>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(c =>
{
failedResources.Add(c.Resource.Name);
return Task.CompletedTask;
});
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, events: events);
await appExecutor.RunApplicationAsync();
// The healthy container should have been created successfully.
var createdContainers = kubernetesService.CreatedResources.OfType<Container>().ToList();
Assert.Single(createdContainers, c => c.AppModelResourceName == "healthy");
// The failing container should not have been created and should be reported as failed.
Assert.DoesNotContain(createdContainers, c => c.AppModelResourceName == "failing");
Assert.Single(failedResources, name => name == "failing");
}
private static void HasKnownCommandAnnotations(IResource resource)
{
var commandAnnotations = resource.Annotations.OfType<ResourceCommandAnnotation>().ToList();
Assert.Collection(commandAnnotations,
a => Assert.Equal(KnownResourceCommands.StartCommand, a.Name),
a => Assert.Equal(KnownResourceCommands.StopCommand, a.Name),
a => Assert.Equal(KnownResourceCommands.RestartCommand, a.Name));
}
private static void HasKnownProjectCommandAnnotations(IResource resource)
{
var commandAnnotations = resource.Annotations.OfType<ResourceCommandAnnotation>().ToList();
Assert.Collection(commandAnnotations,
a => Assert.Equal(KnownResourceCommands.StartCommand, a.Name),
a => Assert.Equal(KnownResourceCommands.StopCommand, a.Name),
a => Assert.Equal(KnownResourceCommands.RestartCommand, a.Name),
a => Assert.Equal(KnownResourceCommands.RebuildCommand, a.Name));
}
private static void AssertDefaultProjectProcessArgs(IReadOnlyList<string>? actualArgs, params string[] appArgs)
{
var expectedArgs = new List<string> { "run", "--project", "TestProject" };
if (GetTestAssemblyConfiguration() is { } configuration)
{
expectedArgs.AddRange(["--configuration", configuration]);
}
expectedArgs.Add("--no-launch-profile");
expectedArgs.AddRange(appArgs);
Assert.Equal(expectedArgs, actualArgs);
}
private static string? GetTestAssemblyConfiguration() =>
(Attribute.GetCustomAttribute(typeof(DcpExecutorTests).Assembly, typeof(System.Reflection.AssemblyConfigurationAttribute)) as System.Reflection.AssemblyConfigurationAttribute)?.Configuration;
[Fact]
public async Task PlainExecutable_LaunchConfigurationProducerThrows_FailsResource()
{
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport<TestExecutableResource, ExecutableLaunchConfiguration>(
_ => throw new InvalidOperationException("Test exception from launch configuration producer"),
"test");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["test"]
};
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "TestExecutable"));
Assert.Same(debuggableExecutable, Assert.Single(failedResources));
}
[Fact]
public async Task Project_NonProjectLaunchConfig_ExtensionMode_RunsInIde()
{
// Arrange: A ProjectResource with a non-"project" launch config type (like Azure Functions)
// should get ExecutionType.IDE and have its launch config applied in CreateExecutableAsync.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
// Remove the default "project" SupportsDebuggingAnnotation and replace with a non-"project" type
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["azure-functions"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
[KnownConfigNames.DebugSessionRunMode] = "Debug"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
// The launch config should have been applied in CreateExecutableAsync (not PrepareProjectExecutables)
Assert.True(exe.TryGetAnnotationAsObjectList<ExecutableLaunchConfiguration>(Executable.LaunchConfigurationsAnnotation, out var launchConfigs));
var config = Assert.Single(launchConfigs);
Assert.Equal("azure-functions", config.Type);
Assert.Equal(ExecutableLaunchMode.Debug, config.Mode);
}
[Fact]
public async Task Project_NonProjectLaunchConfig_AnnotatorThrows_FailsResource()
{
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport<ProjectResource, ExecutableLaunchConfiguration>(
_ => throw new InvalidOperationException("Test exception from launch configuration producer"),
"azure-functions");
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["azure-functions"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
var failedResources = new List<IResource>();
var events = new DcpExecutorEvents();
events.Subscribe<OnResourceFailedToStartContext>(context =>
{
failedResources.Add(context.Resource);
return Task.CompletedTask;
});
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(
distributedAppModel,
kubernetesService: kubernetesService,
configuration: configuration,
events: events);
await appExecutor.RunApplicationAsync();
Assert.Empty(kubernetesService.CreatedResources.OfType<Executable>());
Assert.Same(projectBuilder.Resource, Assert.Single(failedResources));
}
[Fact]
public async Task Project_NonProjectLaunchConfig_UnsupportedByExtension_RunsInProcess()
{
// Arrange: A ProjectResource with a non-"project" launch config type that the extension
// does not support should run as ExecutionType.Process.
var builder = DistributedApplication.CreateBuilder();
var projectBuilder = builder.AddProject<TestProject>("proj", launchProfileName: null);
// Remove the default "project" SupportsDebuggingAnnotation and replace with "azure-functions"
var annotationToRemove = projectBuilder.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().FirstOrDefault();
if (annotationToRemove is not null)
{
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions");
// Extension does NOT list "azure-functions" in SupportedLaunchConfigurations
var configDict = new Dictionary<string, string?>
{
[DcpExecutor.DebugSessionPortVar] = "12345",
[KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["project"] }),
[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build();
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration);
// Act
await appExecutor.RunApplicationAsync();
// Assert
var exe = GetCreatedExecutableForResource(kubernetesService, "proj");
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
}
private static Executable GetCreatedExecutableForResource(TestKubernetesService kubernetesService, string appModelResourceName)
{
return Assert.Single(GetCreatedExecutablesForResource(kubernetesService, appModelResourceName));
}
private static List<Executable> GetCreatedExecutablesForResource(TestKubernetesService kubernetesService, string appModelResourceName)
{
return [.. kubernetesService.CreatedResources
.OfType<Executable>()
.Where(e => e.AppModelResourceName == appModelResourceName)];
}
private static List<Container> GetCreatedContainersForResource(TestKubernetesService kubernetesService, string appModelResourceName)
{
return [.. kubernetesService.CreatedResources
.OfType<Container>()
.Where(c => c.AppModelResourceName == appModelResourceName)];
}
[Fact]
public async Task Project_WithTerminal_PopulatesPerReplicaTerminalSpec()
{
// When a project resource is configured with WithTerminal(), each replica's
// Executable spec should carry a Terminal block whose UdsPath matches the
// per-replica producer path from TerminalHostLayout.ProducerUdsPaths.
var builder = DistributedApplication.CreateBuilder();
var resource = builder.AddProject<Projects.ServiceA>("ServiceA")
.WithReplicas(2)
.WithTerminal(options =>
{
options.Columns = 100;
options.Rows = 30;
})
.Resource;
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// The per-replica TerminalHostResources are now materialized inside BeforeStartEvent
// (see TerminalResourceBuilderExtensions.WithTerminal). DcpExecutor.RunApplicationAsync
// does not raise that event itself, so the test publishes it manually before running
// the executor — otherwise TerminalAnnotation.TerminalHosts would still be empty when
// ExecutableCreator looks up the per-replica producer UDS path.
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel));
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exes = kubernetesService.CreatedResources.OfType<Executable>()
.Where(e => e.AppModelResourceName == "ServiceA")
.OrderBy(e => e.Metadata.Annotations?[CustomResource.ResourceReplicaIndex], StringComparer.Ordinal)
.ToList();
Assert.Equal(2, exes.Count);
// Each parent replica gets its own per-replica TerminalHostResource — the
// annotation now carries the list, and the Executable for replica i must
// dial the producer UDS owned by TerminalHosts[i].
var hosts = resource.Annotations.OfType<TerminalAnnotation>().Single().TerminalHosts;
Assert.Equal(2, hosts.Count);
for (var i = 0; i < exes.Count; i++)
{
var spec = exes[i].Spec.Terminal;
Assert.NotNull(spec);
Assert.Equal(hosts[i].Layout.ProducerUdsPath, spec!.UdsPath);
Assert.Equal(100, spec.Cols);
Assert.Equal(30, spec.Rows);
// Aspire's terminal host owns the listener at UdsPath, so DCP must dial it.
// Changing this to "listen" (the DCP default) would silently break attach.
Assert.Equal("connect", spec.SocketMode);
}
}
[Fact]
public async Task Project_WithoutTerminal_HasNullTerminalSpec()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddProject<Projects.ServiceA>("ServiceA");
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "ServiceA");
Assert.Null(exe.Spec.Terminal);
}
[Fact]
public async Task PlainExecutable_WithTerminal_PopulatesTerminalSpec()
{
// Regression test: plain executables added via AddExecutable() were missing
// the ResourceReplicaIndex/ResourceReplicaCount annotations, which caused
// BuildExecutableConfiguration to skip the spec.Terminal wire-up entirely
// (the per-replica producer UDS path lookup in TerminalHostLayout was guarded
// by a successful TryGetReplicaIndex).
var builder = DistributedApplication.CreateBuilder();
var resource = builder.AddExecutable("shell", "cmd.exe", ".")
.WithTerminal(options =>
{
options.Columns = 100;
options.Rows = 30;
})
.Resource;
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// BeforeStartEvent is where the per-replica TerminalHostResources are now
// materialized. See the matching note in Project_WithTerminal_PopulatesPerReplicaTerminalSpec.
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel));
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "shell");
// Plain executables are always single-replica — there's exactly one host
// at index 0, owning the only producer UDS.
var host = Assert.Single(resource.Annotations.OfType<TerminalAnnotation>().Single().TerminalHosts);
Assert.NotNull(exe.Spec.Terminal);
Assert.Equal(host.Layout.ProducerUdsPath, exe.Spec.Terminal!.UdsPath);
Assert.Equal(100, exe.Spec.Terminal.Cols);
Assert.Equal(30, exe.Spec.Terminal.Rows);
// Aspire's terminal host owns the listener, so DCP must dial it. The DCP
// default is "listen", so we have to explicitly opt into "connect".
Assert.Equal("connect", exe.Spec.Terminal.SocketMode);
// Plain executables are always single-replica today; both annotations must
// be present for the per-replica lookup to succeed.
Assert.Equal("1", exe.Metadata.Annotations?[CustomResource.ResourceReplicaCount]);
Assert.Equal("0", exe.Metadata.Annotations?[CustomResource.ResourceReplicaIndex]);
}
[Fact]
public async Task Container_WithTerminal_PopulatesTerminalSpec()
{
// Containers are always single-replica today, so ContainerCreator wires
// spec.Terminal from TerminalHosts[0]. This guards the DCP wire-up so a
// future refactor that drops the assignment (or changes SocketMode away
// from "connect") gets caught here rather than at attach time.
var builder = DistributedApplication.CreateBuilder();
var resource = builder.AddContainer("aContainer", "image")
.WithTerminal(options =>
{
options.Columns = 100;
options.Rows = 30;
})
.Resource;
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// BeforeStartEvent materializes the per-replica TerminalHostResources;
// see the matching note in Project_WithTerminal_PopulatesPerReplicaTerminalSpec.
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, distributedAppModel));
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>(), c => c.AppModelResourceName == "aContainer");
var host = Assert.Single(resource.Annotations.OfType<TerminalAnnotation>().Single().TerminalHosts);
Assert.NotNull(container.Spec.Terminal);
Assert.Equal(host.Layout.ProducerUdsPath, container.Spec.Terminal!.UdsPath);
Assert.Equal(100, container.Spec.Terminal.Cols);
Assert.Equal(30, container.Spec.Terminal.Rows);
// Aspire's terminal host owns the listener, so DCP must dial it.
Assert.Equal("connect", container.Spec.Terminal.SocketMode);
}
[Fact]
public void TerminalSpec_SerializesToDcpWireContract()
{
// The DCP terminal contract is a JSON document with camelCase property names
// (driven by [JsonPropertyName] attributes on TerminalSpec). DCP parses the
// payload into api/v1/terminal_types.go; field names and SocketMode values
// must stay in lockstep with the Go side. This test guards the wire shape so
// that a rename or attribute removal in the Aspire model gets caught here
// rather than at runtime when DCP rejects the spec.
var spec = new TerminalSpec
{
UdsPath = "/tmp/aspire/term.sock",
SocketMode = "connect",
Cols = 100,
Rows = 30
};
var json = JsonSerializer.Serialize(spec);
Assert.Equal(
"{\"udsPath\":\"/tmp/aspire/term.sock\",\"socketMode\":\"connect\",\"cols\":100,\"rows\":30}",
json);
}
[Fact]
public void MonitorTimestamps_SerializeToDcpMicroTimeWireContract()
{
var wholeSecondTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30), DateTimeKind.Utc);
var fractionalSecondTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30).AddMilliseconds(123), DateTimeKind.Utc);
AssertMonitorTimestamp(new ContainerSpec { MonitorPid = 1234, MonitorTimestamp = wholeSecondTimestamp }, "0001-01-01T00:06:30.000000Z");
AssertMonitorTimestamp(new ContainerSpec { MonitorPid = 1234, MonitorTimestamp = fractionalSecondTimestamp }, "0001-01-01T00:06:30.123000Z");
AssertMonitorTimestamp(new ExecutableSpec { MonitorPid = 1234, MonitorTimestamp = wholeSecondTimestamp }, "0001-01-01T00:06:30.000000Z");
AssertMonitorTimestamp(new ExecutableSpec { MonitorPid = 1234, MonitorTimestamp = fractionalSecondTimestamp }, "0001-01-01T00:06:30.123000Z");
static void AssertMonitorTimestamp<T>(T spec, string expected)
{
// DCP models monitorTimestamp as Kubernetes metav1.MicroTime:
// "0001-01-01T00:06:30.000000Z"
// Kubernetes requires exactly six fractional digits, while System.Text.Json's
// default DateTime converter trims trailing zeroes and can produce values
// such as "0001-01-01T00:06:30Z" that DCP rejects.
var json = JsonSerializer.Serialize(spec);
using var document = JsonDocument.Parse(json);
Assert.Equal(expected, document.RootElement.GetProperty("monitorTimestamp").GetString());
}
}
[Fact]
public void MonitorTimestamps_DeserializeFromDcpMicroTimeWireContract()
{
var containerSpec = JsonSerializer.Deserialize<ContainerSpec>("""{"monitorPid":1234,"monitorTimestamp":"0001-01-01T00:06:30.123000Z"}""");
var executableSpec = JsonSerializer.Deserialize<ExecutableSpec>("""{"monitorPid":1234,"monitorTimestamp":"0001-01-01T00:06:30.123000Z"}""");
var expectedTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30).AddMilliseconds(123), DateTimeKind.Utc);
Assert.NotNull(containerSpec);
Assert.Equal(expectedTimestamp, containerSpec.MonitorTimestamp);
Assert.NotNull(executableSpec);
Assert.Equal(expectedTimestamp, executableSpec.MonitorTimestamp);
}
private static DcpExecutor CreateAppExecutor(
DistributedApplicationModel distributedAppModel,
IHostEnvironment? hostEnvironment = null,
IConfiguration? configuration = null,
IKubernetesService? kubernetesService = null,
DcpOptions? dcpOptions = null,
IUserSecretsManager? userSecretsManager = null,
ResourceLoggerService? resourceLoggerService = null,
DcpExecutorEvents? events = null,
Hosting.Eventing.IDistributedApplicationEventing? distributedApplicationEventing = null,
ILogger<ContainerCreator>? containerCreatorLogger = null,
ILogger<DcpExecutor>? logger = null,
DistributedApplicationOptions? distributedApplicationOptions = null)
{
if (configuration == null)
{
var builder = new ConfigurationBuilder();
builder.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost",
["AppHost:BrowserToken"] = "TestBrowserToken!",
["AppHost:OtlpApiKey"] = "TestOtlpApiKey!"
});
configuration = builder.Build();
}
resourceLoggerService ??= new ResourceLoggerService();
dcpOptions ??= new DcpOptions { DashboardPath = "./dashboard" };
var developerCertificateService = new TestDeveloperCertificateService(new List<X509Certificate2>(), false, false, false);
var nameGenerator = new DcpNameGenerator(configuration, Options.Create(dcpOptions));
var executionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
{
Services = new TestServiceProvider(configuration)
.AddService<IDeveloperCertificateService>(developerCertificateService)
.AddService(distributedAppModel)
.AddService(Options.Create(dcpOptions))
.AddService(resourceLoggerService)
});
var ks = kubernetesService ?? new TestKubernetesService();
var dcpEvts = events ?? new DcpExecutorEvents();
var fileSystemService = new FileSystemService(configuration);
var locations = new Locations(fileSystemService);
var aspireStoreDirectory = configuration[AspireStore.AspireStorePathKeyName];
if (string.IsNullOrWhiteSpace(aspireStoreDirectory))
{
aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store").Path;
}
var aspireStore = new AspireStore(Path.Join(aspireStoreDirectory, ".aspire"), fileSystemService);
var hostEnv = hostEnvironment ?? new TestHostEnvironment();
var dcpDependencyCheckService = new TestDcpDependencyCheckService();
var appResources = new DcpAppResourceStore();
var proxylessEndpointPortAllocator = new ProxylessEndpointPortAllocator(Options.Create(dcpOptions));
var applicationOptions = distributedApplicationOptions ?? new DistributedApplicationOptions();
var executableConfigurationResolver = new ExecutableConfigurationResolver(executionContext, locations, aspireStore);
var executableLaunchPolicy = new ExecutableLaunchPolicy(configuration);
var executableCreator = new ExecutableCreator(
nameGenerator,
distributedAppModel,
appResources,
executableConfigurationResolver,
configuration,
applicationOptions,
executableLaunchPolicy,
NullLogger<ExecutableCreator>.Instance);
var containerCreator = new ContainerCreator(
configuration,
Options.Create(dcpOptions),
nameGenerator,
distributedAppModel,
executionContext,
resourceLoggerService,
dcpDependencyCheckService,
hostEnv,
containerCreatorLogger ?? NullLogger<ContainerCreator>.Instance,
appResources);
return new DcpExecutor(
logger ?? NullLogger<DcpExecutor>.Instance,
NullLogger<DistributedApplication>.Instance,
distributedAppModel,
ks,
configuration,
distributedApplicationEventing ?? new Hosting.Eventing.DistributedApplicationEventing(),
Options.Create(dcpOptions),
executionContext,
resourceLoggerService,
dcpDependencyCheckService,
nameGenerator,
dcpEvts,
appResources,
executableCreator,
containerCreator,
new ProfilingTelemetry(configuration),
proxylessEndpointPortAllocator,
userSecretsManager ?? NoopUserSecretsManager.Instance);
}
private static async Task<string?> GetPlainExecutableSslCertDirAsync(Action<IResourceBuilder<TestExecutableResource>>? configure = null)
{
var builder = DistributedApplication.CreateBuilder();
using var certificate = CreateTestCertificate();
var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates")
.WithCertificate(certificate);
var executable = new TestExecutableResource("test-working-directory");
var executableBuilder = builder.AddResource(executable)
.WithCertificateAuthorityCollection(certificateAuthorities);
configure?.Invoke(executableBuilder);
var kubernetesService = new TestKubernetesService();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
await appExecutor.RunApplicationAsync();
var exe = Assert.Single(kubernetesService.CreatedResources.OfType<Executable>(), e => e.AppModelResourceName == "TestExecutable");
return Assert.Single(exe.Spec.Env!, e => e.Name == "SSL_CERT_DIR").Value;
}
private static void AssertPortAllocatedFromProxylessEndpointAllocatorRange(int port)
{
var defaultOptions = new DcpOptions();
Assert.InRange(port, defaultOptions.ProxylessEndpointPortRangeStart, defaultOptions.ProxylessEndpointPortRangeEnd);
}
private static (int First, int Second) GetAvailableConsecutivePortPair()
{
// Tests configure single-port allocation ranges, so the helper must agree exactly with
// the allocator on what "available" means. Reuse the allocator's IPv4+IPv6 probe instead
// of a separate IPv4-only bind that could return a port the allocator later rejects.
//
// Scan from a random offset rather than always starting at the bottom of the range. Test
// classes run in parallel and a deterministic start makes concurrent runs converge on the
// same low ports, where a transient probe collision throws against a zero-slack range.
const int rangeStart = 10000;
const int rangeEndExclusive = 32767; // Leave room for port + 1 within the proxyless default range.
var span = rangeEndExclusive - rangeStart;
var offset = Random.Shared.Next(span);
for (var i = 0; i < span; i++)
{
var port = rangeStart + ((offset + i) % span);
if (ProxylessEndpointPortAllocator.TryProbePort(port, ProtocolType.Tcp) &&
ProxylessEndpointPortAllocator.TryProbePort(port + 1, ProtocolType.Tcp))
{
return (port, port + 1);
}
}
throw new InvalidOperationException("Could not find two consecutive available ports.");
}
private static bool RetryTillTrueOrTimeout(Func<bool> check, int timeoutMilliseconds)
{
var retry = new ResiliencePipelineBuilder<bool>()
.AddRetry(new RetryStrategyOptions<bool>
{
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromMilliseconds(200),
MaxDelay = TimeSpan.FromSeconds(2),
MaxRetryAttempts = int.MaxValue,
ShouldHandle = args => ValueTask.FromResult(!args.Outcome.Result)
})
.AddTimeout(TimeSpan.FromMilliseconds(timeoutMilliseconds))
.Build();
return retry.Execute(check);
}
private static void AssertEffectiveArgumentIndexesMatchSpecArgs(IReadOnlyList<AppLaunchArgumentAnnotation> argAnnotations, IReadOnlyList<string>? specArgs)
{
foreach (var annotation in argAnnotations)
{
if (annotation.EffectiveArgumentIndex is not int index)
{
continue;
}
Assert.NotNull(specArgs);
Assert.InRange(index, 0, specArgs.Count - 1);
Assert.Equal(annotation.Argument, specArgs[index]);
}
}
private static Aspire.Hosting.Dcp.ResourceSnapshotBuilder CreateSnapshotBuilder(DistributedApplicationModel model)
{
return new(new DcpResourceState(model.Resources.ToDictionary(r => r.Name), []));
}
private static CustomResourceSnapshot CreatePreviousSnapshot()
{
return new()
{
ResourceType = "resource",
Properties = []
};
}
private static ResourcePropertySnapshot GetProperty(CustomResourceSnapshot snapshot, string name)
{
return Assert.Single(snapshot.Properties, p => p.Name == name);
}
private static IEnumerable<T> GetEnumerablePropertyValue<T>(CustomResourceSnapshot snapshot, string name)
{
var property = GetProperty(snapshot, name);
return Assert.IsAssignableFrom<IEnumerable<T>>(property.Value);
}
private static X509Certificate2 CreateTestCertificate()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
new X500DistinguishedName("CN=test"),
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var serialNumber = new byte[16];
RandomNumberGenerator.Fill(serialNumber);
var generator = X509SignatureGenerator.CreateForRSA(rsa, RSASignaturePadding.Pkcs1);
return request.Create(
request.SubjectName,
generator,
DateTimeOffset.Now,
DateTimeOffset.Now.AddYears(1),
serialNumber);
}
private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory);
private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory);
private sealed class TestContainerResource(string name) : Resource(name), IResourceWithEndpoints;
private sealed class NullValueProvider : IValueProvider
{
public static NullValueProvider Instance { get; } = new();
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<string?>(null);
}
}
// Models a DotnetProjectResource: a plain ExecutableResource (launches `dotnet`) that carries
// IProjectMetadata and a "project" SupportsDebuggingAnnotation. Used to verify the DCP project-launch
// generalization without taking a dependency on Aspire.Hosting.Dotnet.
private sealed class TestDotnetProjectExecutableResource(string directory) : ExecutableResource("TestDotnetProject", "dotnet", directory);
private sealed class TestHostEnvironment : IHostEnvironment
{
public string ApplicationName { get; set; } = default!;
public IFileProvider ContentRootFileProvider { get; set; } = default!;
public string ContentRootPath { get; set; } = default!;
public string EnvironmentName { get; set; } = default!;
}
private sealed class TestProject : IProjectMetadata
{
public string ProjectPath => "TestProject";
public LaunchSettings LaunchSettings { get; } = new();
}
private sealed class TestFileBasedProject(string projectPath) : IProjectMetadata
{
public string ProjectPath { get; } = projectPath;
public LaunchSettings LaunchSettings { get; } = new();
}
private sealed class TestMauiLaunchConfiguration : ExecutableLaunchConfiguration
{
public TestMauiLaunchConfiguration() : base("maui")
{
}
[JsonPropertyName("project_path")]
public string ProjectPath { get; set; } = string.Empty;
[JsonPropertyName("target_framework")]
public string TargetFramework { get; set; } = string.Empty;
[JsonPropertyName("platform")]
public string Platform { get; set; } = string.Empty;
[JsonPropertyName("target_kind")]
public string TargetKind { get; set; } = string.Empty;
[JsonPropertyName("msbuild_properties")]
public Dictionary<string, string>? MsBuildProperties { get; set; }
}
private sealed class TestProjectWithLaunchSettings : IProjectMetadata
{
public string ProjectPath => "TestProjectWithLaunchSettings";
public LaunchSettings LaunchSettings { get; } = CreateLaunchSettings();
private static LaunchSettings CreateLaunchSettings()
{
var settings = new LaunchSettings();
settings.Profiles["Foo"] = new LaunchProfile
{
CommandName = "Project",
LaunchUrl = "http://localhost:5000",
ApplicationUrl = "http://localhost:5000;https://localhost:5001",
EnvironmentVariables = new Dictionary<string, string>
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
};
settings.Profiles["http"] = new LaunchProfile
{
CommandName = "Project",
LaunchUrl = "http://localhost:5003",
ApplicationUrl = "http://localhost:5003;",
EnvironmentVariables = new Dictionary<string, string>
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
};
return settings;
}
}
private sealed class TestProjectWithLaunchProfileCommandLineArgs : IProjectMetadata
{
public string ProjectPath => "TestProjectWithLaunchProfileCommandLineArgs";
public LaunchSettings LaunchSettings { get; } = CreateLaunchSettings();
private static LaunchSettings CreateLaunchSettings()
{
var settings = new LaunchSettings();
settings.Profiles["http"] = new LaunchProfile
{
CommandName = "Project",
CommandLineArgs = "--profile-arg \"profile value\""
};
return settings;
}
}
private sealed class TestProjectNoProfiles : IProjectMetadata
{
public string ProjectPath => "TestProjectNoProfiles";
public LaunchSettings LaunchSettings { get; } = new();
}
private sealed class TestProjectMultiProfileOrder : IProjectMetadata
{
public string ProjectPath => "TestProjectMultiProfileOrder";
public LaunchSettings LaunchSettings { get; } = CreateLaunchSettings();
private static LaunchSettings CreateLaunchSettings()
{
var settings = new LaunchSettings();
// Intentionally non-alphabetical insertion order to verify iteration order.
settings.Profiles["Zed"] = new LaunchProfile { CommandName = "Project", ApplicationUrl = "http://localhost:6001" };
settings.Profiles["Alpha"] = new LaunchProfile { CommandName = "Project", ApplicationUrl = "http://localhost:6002" };
settings.Profiles["Beta"] = new LaunchProfile { CommandName = "Project", ApplicationUrl = "http://localhost:6003" };
return settings;
}
}
private sealed class CustomChildResource(string name, IResource parent) : Resource(name), IResourceWithParent
{
public IResource Parent => parent;
}
private sealed class TestProjectWithExecutableLaunchProfile : IProjectMetadata
{
public string ProjectPath => "TestProjectWithExecutableLaunchProfile";
public LaunchSettings LaunchSettings { get; } = CreateLaunchSettings();
private static LaunchSettings CreateLaunchSettings()
{
var settings = new LaunchSettings();
settings.Profiles["Aspire_TestFunction"] = new LaunchProfile
{
CommandName = "Executable",
ExecutablePath = "dotnet",
CommandLineArgs = "exec --depsfile ./TestLib.deps.json --runtimeconfig ./TestLib.runtimeconfig.json $(HOME)/.dotnet/tools/TestTool.dll TestLib::TestLib.Functions::Handler"
};
return settings;
}
}
}