Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion playground/Stress/Stress.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;

#pragma warning disable ASPIREDOTNETTOOL
#pragma warning disable ASPIREINTERACTION001
#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.

var builder = DistributedApplication.CreateBuilder(args);
Expand All @@ -18,6 +19,10 @@
var manualArgSource = builder.AddExecutable("manual-arg-source", "dotnet", Environment.CurrentDirectory)
.WithHttpEndpoint(targetPort: 8088);
var manualArgEndpoint = manualArgSource.GetEndpoint("http");
var stressEmptyProjectPath = new Projects.Stress_Empty().ProjectPath;
const string interactionContainerImage = "alpine";
const string interactionContainerEntrypoint = "/bin/sh";
string[] interactionContainerArgs = ["-c", "while true; do sleep 3600; done"];

manualArgSource.WithArgs("--dashboard-port")
.WithArgs(c => c.Args.Add(manualArgEndpoint.Property(EndpointProperty.Port)));
Expand All @@ -36,6 +41,45 @@
.WithArgs(c => c.Args.Add(manualArgEndpoint.Property(EndpointProperty.Port)))
.WithExplicitStart();

builder.AddExecutable("manual-environment-interaction", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build")
.WithExplicitStart()
.WithEnvironment(context => PromptForEnvironmentValueAsync(
context,
"MANUAL_ENVIRONMENT_INTERACTION_VALUE",
"Explicit start executable environment callback",
"This prompt should only appear after manually starting the session-scoped executable."));

builder.AddExecutable("persistent-environment-interaction", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build")
.WithPersistentLifetime()
.WithExplicitStart()
.WithEnvironment(context => PromptForEnvironmentValueAsync(
context,
"PERSISTENT_EXECUTABLE_ENVIRONMENT_INTERACTION_VALUE",
"Persistent executable environment callback",
"This prompt appears during startup because persistent executable resources are registered with DCP immediately."));

builder.AddContainer("manual-container-environment-interaction", interactionContainerImage)
.WithEntrypoint(interactionContainerEntrypoint)
.WithArgs(interactionContainerArgs)
.WithExplicitStart()
.WithEnvironment(context => PromptForEnvironmentValueAsync(
context,
"MANUAL_CONTAINER_ENVIRONMENT_INTERACTION_VALUE",
"Explicit start container environment callback",
"This prompt should only appear after manually starting the session-scoped container."));

builder.AddContainer("persistent-container-environment-interaction", interactionContainerImage)
.WithContainerName("stress-persistent-container-environment-interaction")
.WithEntrypoint(interactionContainerEntrypoint)
.WithArgs(interactionContainerArgs)
.WithPersistentLifetime()
.WithExplicitStart()
.WithEnvironment(context => PromptForEnvironmentValueAsync(
context,
"PERSISTENT_CONTAINER_ENVIRONMENT_INTERACTION_VALUE",
"Persistent container environment callback",
"This prompt appears during startup because persistent container resources are registered with DCP immediately."));

for (var i = 0; i < 2; i++)
{
var name = $"test-{i:0000}";
Expand Down Expand Up @@ -100,7 +144,6 @@
builder.AddExecutable("executableWithSingleArg", "dotnet", Environment.CurrentDirectory, "--version");
builder.AddExecutable("executableWithSingleEscapedArg", "dotnet", Environment.CurrentDirectory, "one two");
builder.AddExecutable("executableWithMultipleArgs", "dotnet", Environment.CurrentDirectory, "--version", "one two");
var stressEmptyProjectPath = new Projects.Stress_Empty().ProjectPath;
builder.AddExecutable("persistentExecutable", "dotnet", Environment.CurrentDirectory, "run", "--project", stressEmptyProjectPath, "--no-build")
.WithPersistentLifetime();

Expand Down Expand Up @@ -128,3 +171,23 @@
builder.AddNoStatusResource("no-status-resource");

builder.Build().Run();

static async Task PromptForEnvironmentValueAsync(
EnvironmentCallbackContext context,
string environmentVariableName,
string title,
string message)
{
var interactionService = context.ExecutionContext.ServiceProvider.GetRequiredService<IInteractionService>();
var result = await interactionService.PromptInputAsync(
title: title,
message: message,
inputLabel: "Environment value",
placeHolder: "Value from WithEnvironment callback",
cancellationToken: context.CancellationToken);

if (!result.Canceled && result.Data.Value is { } value)
{
context.EnvironmentVariables[environmentVariableName] = value;
}
}
4 changes: 1 addition & 3 deletions src/Aspire.Hosting/Dcp/ContainerCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,7 @@ static bool IsContainerTunnelContainerName(string name)

public bool IsReadyToCreate(RenderedModelResource<Container> resource, ContainerCreationContext cctx)
{
// Containers are always "created" (submitted to DCP), they just get Spec.Start = false initially
// if explicit startup is used.
return true;
return !DcpModelUtilities.ShouldDeferCreateForExplicitStart(resource.ModelResource, resource.DcpResource.Spec.Start);
}

public async Task CreateObjectAsync(RenderedModelResource<Container> cr, ContainerCreationContext cctx, ILogger logger, IDcpObjectFactory factory, CancellationToken cancellationToken)
Expand Down
43 changes: 30 additions & 13 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ internal static string GetResourceType<T>(T resource, IResource appModelResource
Task IDcpObjectFactory.UpdateWithEffectiveAddressInfo(IEnumerable<Service> services, CancellationToken cancellationToken, TimeSpan? timeout)
=> UpdateWithEffectiveAddressInfo(services, cancellationToken, timeout);

// Watches DCP object updates via a Kubernetes watch wrapped in the supplied retry pipeline,
// Watches DCP object updates via a Kubernetes watch wrapped in the supplied retry pipeline,
// till all objects reach desired state or a timeout occurs.
// Returns names of objects that did not reach the desired state.
private async Task<HashSet<string>> WatchUntilDesiredStateAsync<TDcpResource>(
Expand Down Expand Up @@ -591,7 +591,11 @@ private Task CreateAllDcpObjectsAsync<RT>(CancellationToken cancellationToken) w
Task IDcpObjectFactory.CreateDcpObjectsAsync<T>(IEnumerable<T> objects, CancellationToken cancellationToken)
=> CreateDcpObjectsAsync(objects, cancellationToken);

async Task<T> IDcpObjectFactory.PatchDcpObjectAsync<T>(T obj, Action<T> change, CancellationToken cancellationToken)
Task<T> IDcpObjectFactory.PatchDcpObjectAsync<T>(T obj, Action<T> change, CancellationToken cancellationToken)
=> PatchDcpObjectAsync(obj, change, cancellationToken);

private async Task<T> PatchDcpObjectAsync<T>(T obj, Action<T> change, CancellationToken cancellationToken)
where T : CustomResource, IKubernetesStaticMetadata
{
var patch = CreatePatch(obj, change);
var result = await _kubernetesService.PatchAsync(obj, patch, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -1067,15 +1071,25 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance
await appResource.Initialized.WaitAsync(cancellationToken).ConfigureAwait(false);
using var _ = await ConcurrencyUtils.AcquireAllAsync([appResource.SerializedOpSemaphore], cancellationToken).ConfigureAwait(false);

// Reset cached callback results so they are re-evaluated on restart.
ForgetCachedCallbackResults(resourceReference.ModelResource);

// Raise event after resource has been deleted. This is required because the event sets the status to "Starting" and resources being
// deleted will temporarily override the status to a terminal state, such as "Exited".
// For resources that need delete/recreate startup, raise the starting event after deletion. This is required because
// deleting the existing DCP object temporarily overrides the status with a terminal state, such as "Exited".
switch (resourceReference)
{
// We need to handle explicit start persistent resources specially on first launch as they may already be running, so we need to register them with DCP to discover their status.
case RenderedModelResource<Container> { DcpResource.Spec.Start: false } cr when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(cr.ModelResource, cr.DcpResource.Spec.Start):
await PublishConnectionStringAvailableEventAsync(cr.ModelResource, cancellationToken).ConfigureAwait(false);
await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, cr.ModelResource, cr.DcpResourceName)).ConfigureAwait(false);
await PatchDcpObjectAsync(cr.DcpResource, static c => c.Spec.Start = true, cancellationToken).ConfigureAwait(false);
break;

case RenderedModelResource<Executable> { DcpResource.Spec.Start: false } er when !DcpModelUtilities.ShouldDeferCreateForExplicitStart(er.ModelResource, er.DcpResource.Spec.Start):
await PublishConnectionStringAvailableEventAsync(er.ModelResource, cancellationToken).ConfigureAwait(false);
await _executorEvents.PublishAsync(new OnResourceStartingContext(cancellationToken, resourceType, er.ModelResource, er.DcpResourceName)).ConfigureAwait(false);
await PatchDcpObjectAsync(er.DcpResource, static e => e.Spec.Start = true, cancellationToken).ConfigureAwait(false);
break;

case RenderedModelResource<Container> cr:
await EnsureResourceDeletedAsync<Container>(resourceReference.DcpResourceName, cancellationToken).ConfigureAwait(false);
await EnsureResourceDeletedAsync<Container>(resourceReference, cancellationToken).ConfigureAwait(false);

// Ensure we explicitly start the container even if original container was created in "delay-start" mode.
cr.DcpResource.Spec.Start = true;
Expand All @@ -1086,7 +1100,7 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance
await _containerCreator.CreateObjectAsync(cr, cctx, resourceLogger, this, cancellationToken).ConfigureAwait(false);
break;
case RenderedModelResource<Executable> er:
await EnsureResourceDeletedAsync<Executable>(resourceReference.DcpResourceName, cancellationToken).ConfigureAwait(false);
await EnsureResourceDeletedAsync<Executable>(resourceReference, cancellationToken).ConfigureAwait(false);

// Ensure we explicitly start the executable even if original executable was created in "delay-start" mode.
er.DcpResource.Spec.Start = true;
Expand Down Expand Up @@ -1116,9 +1130,12 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance
}
}

private async Task EnsureResourceDeletedAsync<T>(string resourceName, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata
private async Task EnsureResourceDeletedAsync<T>(IResourceReference resource, CancellationToken cancellationToken) where T : CustomResource, IKubernetesStaticMetadata
{
_logger.LogDebug("Ensuring '{ResourceName}' is deleted.", resourceName);
_logger.LogDebug("Ensuring '{ResourceName}' is deleted.", resource.DcpResourceName);

// Reset cached callback results so they are re-evaluated on restart.
ForgetCachedCallbackResults(resource.ModelResource);

var result = await DeleteResourceRetryPipeline.ExecuteAsync(async (resourceName, attemptCancellationToken) =>
{
Expand Down Expand Up @@ -1162,11 +1179,11 @@ private async Task EnsureResourceDeletedAsync<T>(string resourceName, Cancellati
// Success.
return true;
}
}, resourceName, cancellationToken).ConfigureAwait(false);
}, resource.DcpResourceName, cancellationToken).ConfigureAwait(false);

if (!result)
{
throw new DistributedApplicationException($"Failed to delete '{resourceName}' successfully before restart.");
throw new DistributedApplicationException($"Failed to delete '{resource.DcpResourceName}' successfully before restart.");
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/Aspire.Hosting/Dcp/DcpModelUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ namespace Aspire.Hosting.Dcp;
/// </summary>
internal static class DcpModelUtilities
{
/// <summary>
/// Determines whether DCP object creation should be deferred until an explicit manual start.
/// </summary>
internal static bool ShouldDeferCreateForExplicitStart(IResource modelResource, bool? start)
{
// Explicit-start, non-persistent resources use manual snapshots for dashboard visibility.
// Do not create corresponding DCP objects until the manual start path flips Spec.Start=true; creation
// evaluates callbacks that can prompt for input or depend on start-time state.
return start == false &&
modelResource.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _) &&
modelResource.GetLifetimeType() != Lifetime.Persistent;
Comment thread
danegsta marked this conversation as resolved.
}

/// <summary>
/// Examines the Aspire resource annotations and adds equivalent ServiceProducerAnnotations to the corresponding DCP resource.
/// </summary>
Expand Down
3 changes: 1 addition & 2 deletions src/Aspire.Hosting/Dcp/ExecutableCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ public IEnumerable<RenderedModelResource<Executable>> PrepareObjects()

public bool IsReadyToCreate(RenderedModelResource<Executable> resource, EmptyCreationContext context)
{
// Executables are always created. When explicit startup is used, DCP receives Spec.Start = false.
return true;
return !DcpModelUtilities.ShouldDeferCreateForExplicitStart(resource.ModelResource, resource.DcpResource.Spec.Start);
}

public async Task CreateObjectAsync(RenderedModelResource<Executable> er, EmptyCreationContext context, ILogger resourceLogger, IDcpObjectFactory factory, CancellationToken cancellationToken)
Expand Down
Loading
Loading