Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents a command annotation for a resource.
/// </summary>
[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")]
public sealed class ResourceExecCommandAnnotation : IResourceAnnotation
{
/// <summary>
/// Initializes a new instance of the <see cref="ResourceExecCommandAnnotation"/> class.
/// </summary>
public ResourceExecCommandAnnotation(
string name,
string displayName,
string command,
string? workingDirectory)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(displayName);
ArgumentNullException.ThrowIfNull(command);

Name = name;
DisplayName = displayName;
Command = command;
WorkingDirectory = workingDirectory;
}

/// <summary>
/// The name of the command.
/// </summary>
public string Name { get; }

/// <summary>
/// The display name of the command.
/// </summary>
public string DisplayName { get; }

/// <summary>
/// The command to execute.
/// </summary>
public string Command { get; }

/// <summary>
/// The working directory in which the command will be executed.
/// </summary>
public string? WorkingDirectory { get; set; }
}
80 changes: 59 additions & 21 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -919,30 +919,36 @@ private void PrepareContainerExecutables()
var modelContainerExecutableResources = _model.GetContainerExecutableResources();
foreach (var containerExecutable in modelContainerExecutableResources)
{
EnsureRequiredAnnotations(containerExecutable);
var exeInstance = GetDcpInstance(containerExecutable, instanceIndex: 0);

// Container exec runs against a dcp container resource, so its required to resolve a DCP name of the resource
// since this is ContainerExec resource, we will run against one of the container instances
var containerDcpName = containerExecutable.TargetContainerResource!.GetResolvedResourceName();

var containerExec = ContainerExec.Create(
name: exeInstance.Name,
containerName: containerDcpName,
command: containerExecutable.Command,
args: containerExecutable.Args?.ToList(),
workingDirectory: containerExecutable.WorkingDirectory);

containerExec.Annotate(CustomResource.OtelServiceNameAnnotation, containerExecutable.Name);
containerExec.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, exeInstance.Suffix);
containerExec.Annotate(CustomResource.ResourceNameAnnotation, containerExecutable.Name);
SetInitialResourceState(containerExecutable, containerExec);

var exeAppResource = new AppResource(containerExecutable, containerExec);
_appResources.Add(exeAppResource);
PrepareContainerExecutableResource(containerExecutable);
}
}

private AppResource PrepareContainerExecutableResource(ContainerExecutableResource containerExecutable)
{
EnsureRequiredAnnotations(containerExecutable);
var exeInstance = GetDcpInstance(containerExecutable, instanceIndex: 0);

// Container exec runs against a dcp container resource, so its required to resolve a DCP name of the resource
// since this is ContainerExec resource, we will run against one of the container instances
var containerDcpName = containerExecutable.TargetContainerResource!.GetResolvedResourceName();

var containerExec = ContainerExec.Create(
name: exeInstance.Name,
containerName: containerDcpName,
command: containerExecutable.Command,
args: containerExecutable.Args?.ToList(),
workingDirectory: containerExecutable.WorkingDirectory);

containerExec.Annotate(CustomResource.OtelServiceNameAnnotation, containerExecutable.Name);
containerExec.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, exeInstance.Suffix);
containerExec.Annotate(CustomResource.ResourceNameAnnotation, containerExecutable.Name);
SetInitialResourceState(containerExecutable, containerExec);

var exeAppResource = new AppResource(containerExecutable, containerExec);
_appResources.Add(exeAppResource);
return exeAppResource;
}

private void PreparePlainExecutables()
{
var modelExecutableResources = _model.GetExecutableResources();
Expand Down Expand Up @@ -1877,6 +1883,38 @@ async Task EnsureResourceDeletedAsync<T>(string resourceName) where T : CustomRe
}
}

/// <inheritdoc/>
public async Task<AppResource> RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken)
{
switch (ephemeralResource)
{
case ContainerExecutableResource containerExecutableResource:
{
// prepare adds resource to the _appResources collection
var appResource = PrepareContainerExecutableResource(containerExecutableResource);

// we need to add it to the resource state manually, so that all infra monitoring works
_resourceState.Add(appResource);

_logger.LogInformation("Starting ephemeral ContainerExec resource {DcpResourceName}", appResource.DcpResourceName);
await CreateContainerExecutablesAsync([appResource], cancellationToken).ConfigureAwait(false);
return appResource;
}

default: throw new InvalidOperationException($"Resource '{ephemeralResource.Name}' is not supported to run dynamically.");
}
}

/// <inheritdoc/>
public Task DeleteEphemeralResourceAsync(AppResource ephemeralResource)
{
_logger.LogInformation("Removing {DcpResourceName}", ephemeralResource.DcpResourceName);
_resourceState.Remove(ephemeralResource);
_appResources.Remove(ephemeralResource);

return Task.CompletedTask;
}

private async Task<(List<(string Value, bool IsSensitive)>, bool)> BuildArgsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken)
{
var failedToApplyArgs = false;
Expand Down
22 changes: 22 additions & 0 deletions src/Aspire.Hosting/Dcp/DcpResourceState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,26 @@ internal sealed class DcpResourceState(Dictionary<string, IResource> application

public Dictionary<string, IResource> ApplicationModel { get; } = applicationModel;
public List<AppResource> AppResources { get; } = appResources;

public void Remove(AppResource appResource)
{
ApplicationModel.Remove(appResource.ModelResource.Name);
AppResources.Remove(appResource);

_ = appResource.DcpResource switch
{
ContainerExec c => ContainerExecsMap.TryRemove(c.Metadata.Name, out _),
_ => false
};
}

public void Add(AppResource appResource)
{
var modelResource = appResource.ModelResource;
ApplicationModel.TryAdd(modelResource.Name, modelResource);
if (!AppResources.Contains(appResource))
{
AppResources.Add(appResource);
}
}
}
18 changes: 18 additions & 0 deletions src/Aspire.Hosting/Dcp/IDcpExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Dcp;

internal interface IDcpExecutor
Expand All @@ -10,4 +12,20 @@ internal interface IDcpExecutor
IResourceReference GetResource(string resourceName);
Task StartResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken);
Task StopResourceAsync(IResourceReference resourceReference, CancellationToken cancellationToken);

/// <summary>
/// Runs a resource which did not exist at the application start time.
/// Adds the resource to the infra to allow monitoring via <see cref="ResourceNotificationService"/> and <see cref="ResourceLoggerService"/>
/// </summary>
/// <param name="ephemeralResource">The aspire model resource definition.</param>
/// <param name="cancellationToken">The token to cancel run.</param>
/// <returns>The appResource containing the appHost resource and dcp resource.</returns>
Task<AppResource> RunEphemeralResourceAsync(IResource ephemeralResource, CancellationToken cancellationToken);

/// <summary>
/// Deletes the ephemeral resource created via <see cref="RunEphemeralResourceAsync"/>.
/// It's up to the caller to ensure that the resource has finished and is will not be used anymore.
/// </summary>
/// <param name="ephemeralResource">The resource to delete.</param>
Task DeleteEphemeralResourceAsync(AppResource ephemeralResource);
}
1 change: 1 addition & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
_innerBuilder.Services.AddSingleton<ResourceNotificationService>();
_innerBuilder.Services.AddSingleton<ResourceLoggerService>();
_innerBuilder.Services.AddSingleton<ResourceCommandService>(s => new ResourceCommandService(s.GetRequiredService<ResourceNotificationService>(), s.GetRequiredService<ResourceLoggerService>(), s));
_innerBuilder.Services.AddSingleton<IContainerExecService, ContainerExecService>();
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
_innerBuilder.Services.AddSingleton<InteractionService>();
_innerBuilder.Services.AddSingleton<IInteractionService>(sp => sp.GetRequiredService<InteractionService>());
Expand Down
146 changes: 146 additions & 0 deletions src/Aspire.Hosting/Exec/ContainerExecService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Exec;

/// <summary>
/// A service to execute container exec commands.
/// </summary>
internal class ContainerExecService : IContainerExecService
{
private readonly ResourceNotificationService _resourceNotificationService;
private readonly ResourceLoggerService _resourceLoggerService;

private readonly IDcpExecutor _dcpExecutor;
private readonly DcpNameGenerator _dcpNameGenerator;

public ContainerExecService(
ResourceNotificationService resourceNotificationService,
ResourceLoggerService resourceLoggerService,
IDcpExecutor dcpExecutor,
DcpNameGenerator dcpNameGenerator)
{
_resourceNotificationService = resourceNotificationService;
_resourceLoggerService = resourceLoggerService;

_dcpExecutor = dcpExecutor;
_dcpNameGenerator = dcpNameGenerator;
}

/// <summary>
/// Execute a command for the specified resource.
/// </summary>
/// <param name="resourceId">The specific id of the resource instance.</param>
/// <param name="commandName">The command name.</param>
/// <returns>The <see cref="ExecuteCommandResult" /> indicates command success or failure.</returns>
public ExecCommandRun ExecuteCommand(string resourceId, string commandName)
{
if (!_resourceNotificationService.TryGetCurrentState(resourceId, out var resourceEvent))
{
return new()
{
ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {resourceId}"))
};
}

var resource = resourceEvent.Resource;
if (resource is not ContainerResource containerResource)
{
throw new ArgumentException("Resource is not a container resource.", nameof(resourceId));
}

return ExecuteCommand(containerResource, commandName);
}

public ExecCommandRun ExecuteCommand(ContainerResource containerResource, string commandName)
{
var annotation = containerResource.Annotations.OfType<ResourceExecCommandAnnotation>().SingleOrDefault(a => a.Name == commandName);
if (annotation is null)
{
return new()
{
ExecuteCommand = token => Task.FromResult(CommandResults.Failure($"Failed to get the resource {containerResource.Name}"))
};
}

return ExecuteCommandCore(containerResource, annotation.Name, annotation.Command, annotation.WorkingDirectory);
}

/// <summary>
/// Executes a command for the specified resource.
/// </summary>
/// <param name="resource">The resource to execute a command in.</param>
/// <param name="commandName"></param>
/// <param name="command"></param>
/// <param name="workingDirectory"></param>
/// <returns></returns>
private ExecCommandRun ExecuteCommandCore(
ContainerResource resource,
string commandName,
string command,
string? workingDirectory)
{
var resourceId = resource.GetResolvedResourceNames().First();

var logger = _resourceLoggerService.GetLogger(resourceId);
logger.LogInformation("Starting command '{Command}' on resource {ResourceId}", command, resourceId);

var containerExecResource = new ContainerExecutableResource(commandName, resource, command, workingDirectory);
_dcpNameGenerator.EnsureDcpInstancesPopulated(containerExecResource);
var dcpResourceName = containerExecResource.GetResolvedResourceName();

Func<CancellationToken, Task<ExecuteCommandResult>> commandResultTask = async (CancellationToken cancellationToken) =>
{
await _dcpExecutor.RunEphemeralResourceAsync(containerExecResource, cancellationToken).ConfigureAwait(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems awkward that the call to ContainerExecService.ExecuteCommand() does not, in fact, executes the command.

Consider making this method an async method and taking the call to RunEphemenralResourceAsync() out of the result-producing task. This will guarantee that when ExecuteCommandCore() returns without exception, the command is started. The returned run object would then expose GetResultAsync() method for waiting on the result.

This change also guarantees that calling GetOutputStream() on the returned run object always makes sense. Without it, it is possible to ask for output stream without actually starting the command, which will result in waiting forever (until the cancellation token expires)

await _resourceNotificationService.WaitForResourceAsync(containerExecResource.Name, targetStates: KnownResourceStates.TerminalStates, cancellationToken).ConfigureAwait(false);

if (!_resourceNotificationService.TryGetCurrentState(dcpResourceName, out var resourceEvent))
{
return CommandResults.Failure("Failed to fetch command results.");
}

// resource completed execution, so we can complete the log stream
_resourceLoggerService.Complete(dcpResourceName);

var snapshot = resourceEvent.Snapshot;
return snapshot.ExitCode is 0
? CommandResults.Success()
: CommandResults.Failure($"Command failed with exit code {snapshot.ExitCode}. Final state: {resourceEvent.Snapshot.State?.Text}.");
};

return new ExecCommandRun
{
ExecuteCommand = commandResultTask,
GetOutputStream = token => GetResourceLogsStreamAsync(dcpResourceName, token)
};
}

private async IAsyncEnumerable<LogLine> GetResourceLogsStreamAsync(string dcpResourceName, [EnumeratorCancellation] CancellationToken cancellationToken)
{
IAsyncEnumerable<IReadOnlyList<LogLine>> source;
if (_resourceNotificationService.TryGetCurrentState(dcpResourceName, out var resourceEvent)
&& resourceEvent.Snapshot.ExitCode is not null)
{
// If the resource is already in a terminal state, we can just return the logs that were already collected.
source = _resourceLoggerService.GetAllAsync(dcpResourceName);
}
else
{
// resource is still running, so we can stream the logs as they come in.
source = _resourceLoggerService.WatchAsync(dcpResourceName);
}

await foreach (var batch in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
yield return logLine;
}
}
}
}
Loading
Loading