Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
31 changes: 31 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ContainerExecutableResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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.CodeAnalysis;
using System.Runtime.CompilerServices;

namespace Aspire.Hosting.ApplicationModel;

internal class ContainerExecutableResource(string name, ContainerResource containerResource, string command, string? workingDirectory)
: Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithEndpoints, IResourceWithWaitSupport
Copy link
Member

Choose a reason for hiding this comment

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

@DeagleGross this merged in before I got a chance to fully review it - but one thing we should do here is ditch the TargetContainerResource property and make this resource implement the IResourceWithParent interface.

Copy link
Member

Choose a reason for hiding this comment

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

We should also get rid of the Args property and instead make it work similar to the ExecutableResource. Because you are implementing IResourceWithArgs it means that this resource should be able to work with the WithArgs method - and it should honor all of the semantics around deferred evaluation etc.

{
/// <summary>
/// Gets the command associated with this executable resource.
/// </summary>
public string Command { get; } = ThrowIfNullOrEmpty(command);

/// <summary>
/// Gets the working directory for the executable resource.
/// </summary>
public string? WorkingDirectory { get; } = workingDirectory;

public ICollection<string>? Args { get; init; }

public ContainerResource? TargetContainerResource { get; } = containerResource ?? throw new ArgumentNullException(nameof(containerResource));

private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
ArgumentException.ThrowIfNullOrEmpty(argument, paramName);
return argument;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C
nameGenerator.EnsureDcpInstancesPopulated(executable);
}

foreach (var containerExec in beforeStartEvent.Model.GetContainerExecutableResources())
{
nameGenerator.EnsureDcpInstancesPopulated(containerExec);
}

foreach (var project in beforeStartEvent.Model.GetProjectResources())
{
nameGenerator.EnsureDcpInstancesPopulated(project);
Expand Down
24 changes: 24 additions & 0 deletions src/Aspire.Hosting/ContainerExecutableResourceExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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;

/// <summary>
/// Provides extension methods for working with <see cref="ExecutableResource"/> objects.
/// </summary>
internal static class ContainerExecutableResourceExtensions
{
/// <summary>
/// Returns an enumerable collection of executable resources from the specified distributed application model.
/// </summary>
/// <param name="model">The distributed application model to retrieve executable resources from.</param>
/// <returns>An enumerable collection of executable resources.</returns>
public static IEnumerable<ContainerExecutableResource> GetContainerExecutableResources(this DistributedApplicationModel model)
{
ArgumentNullException.ThrowIfNull(model);

return model.Resources.OfType<ContainerExecutableResource>();
}
}
152 changes: 145 additions & 7 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public void EnsureDcpInstancesPopulated(IResource resource)
var (name, suffix) = GetContainerName(resource);
AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
}
else if (resource is ExecutableResource)
else if (resource is ExecutableResource or ContainerExecutableResource)
{
var (name, suffix) = GetExecutableName(resource);
AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Dcp/DcpResourceState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal sealed class DcpResourceState(Dictionary<string, IResource> application
{
public readonly ConcurrentDictionary<string, Container> ContainersMap = [];
public readonly ConcurrentDictionary<string, Executable> ExecutablesMap = [];
public readonly ConcurrentDictionary<string, ContainerExec> ContainerExecsMap = [];
public readonly ConcurrentDictionary<string, Service> ServicesMap = [];
public readonly ConcurrentDictionary<string, Endpoint> EndpointsMap = [];
public readonly ConcurrentDictionary<(string, string), List<string>> ResourceAssociatedServicesMap = [];
Expand Down
7 changes: 6 additions & 1 deletion src/Aspire.Hosting/Dcp/Model/ContainerExec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,23 @@ public ContainerExec(ContainerExecSpec spec) : base(spec) { }
/// <param name="name">Resource name of the ContainerExec instance</param>
/// <param name="containerName">Resource name of the Container to run the command in</param>
/// <param name="command">The command name to run</param>
/// <param name="args">Arguments of the command to run</param>
/// <param name="workingDirectory">Container working directory to run the command in</param>
/// <returns>A new ContainerExec instance</returns>
public static ContainerExec Create(string name, string containerName, string command)
public static ContainerExec Create(string name, string containerName, string command, List<string>? args = null, string? workingDirectory = null)
{
var containerExec = new ContainerExec(new ContainerExecSpec
{
ContainerName = containerName,
Command = command,
Args = args,
WorkingDirectory = workingDirectory
})
{
Kind = Dcp.ContainerExecKind,
ApiVersion = Dcp.GroupVersion.ToString()
};

containerExec.Metadata.Name = name;
containerExec.Metadata.NamespaceProperty = string.Empty;

Expand Down
39 changes: 39 additions & 0 deletions src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ ContainerLifetime GetContainerLifetime()
}
}

public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourceSnapshot previous)
{
IResource? appModelResource = null;
_ = executable.AppModelResourceName is not null && _resourceState.ApplicationModel.TryGetValue(executable.AppModelResourceName, out appModelResource);

var state = executable.AppModelInitialState is "Hidden" ? "Hidden" : executable.Status?.State;
var urls = GetUrls(executable, executable.Status?.State);
var environment = GetEnvironmentVariables(executable.Status?.EffectiveEnv, executable.Spec.Env);

var relationships = ImmutableArray<RelationshipSnapshot>.Empty;
if (appModelResource != null)
{
relationships = ApplicationModel.ResourceSnapshotBuilder.BuildRelationships(appModelResource);
}

var launchArguments = GetLaunchArgs(executable);

return previous with
{
ResourceType = KnownResourceTypes.Executable,
State = state,
ExitCode = executable.Status?.ExitCode,
Properties = previous.Properties.SetResourcePropertyRange([
// new(KnownProperties.Executable.Path, executable.Spec.ExecutablePath),
new(KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory),
new(KnownProperties.Executable.Args, executable.Status?.EffectiveArgs ?? []) { IsSensitive = true },
// new(KnownProperties.Executable.Pid, executable.Status?.ProcessId),
new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false },
new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false },
]),
EnvironmentVariables = environment,
CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(),
StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(),
StopTimeStamp = executable.Status?.FinishTimestamp?.ToUniversalTime(),
Urls = urls,
Relationships = relationships
};
}

public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSnapshot previous)
{
string? projectPath = null;
Expand Down
36 changes: 33 additions & 3 deletions src/Aspire.Hosting/Exec/ExecResourceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public async IAsyncEnumerable<CommandOutput> StreamExecResourceLogs([EnumeratorC

// hack: https://github.com/dotnet/aspire/issues/10245
// workarounds the race-condition between streaming all logs from the resource, and resource completion
await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false);
await Task.Delay(2000, CancellationToken.None).ConfigureAwait(false);

_resourceLoggerService.Complete(dcpExecResourceName); // complete stops the `WatchAsync` async-foreach below
}, cancellationToken);
Expand Down Expand Up @@ -187,12 +187,13 @@ IResource BuildResource(IResource targetExecResource)
{
return targetExecResource switch
{
ProjectResource prj => BuildAgainstProjectResource(prj),
ProjectResource prj => BuildAgainstResource(prj),
ContainerResource container => BuildAgainstResource(container),
_ => throw new InvalidOperationException($"Target resource {targetExecResource.Name} does not support exec mode.")
};
}

private IResource BuildAgainstProjectResource(ProjectResource project)
private IResource BuildAgainstResource(ProjectResource project)
{
var projectMetadata = project.GetProjectMetadata();
var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath) ?? throw new InvalidOperationException("Project path is invalid.");
Expand Down Expand Up @@ -238,4 +239,33 @@ annotation is EnvironmentAnnotation or EnvironmentCallbackAnnotation
return CommandLineArgsParser.ParseCommand(commandUnwrapped);
}
}

private IResource BuildAgainstResource(ContainerResource container)
{
var (exe, args) = ParseCommand();
string execResourceName = container.Name + "-exec";

// we cant resolve dcp name of container resource here - too early in the startup pipeline
// it will be resolved later in the Dcp layer
var containerExecutable = new ContainerExecutableResource(execResourceName, container, exe, workingDirectory: null)
{
Args = args
};

containerExecutable.Annotations.Add(new WaitAnnotation(container, waitType: WaitType.WaitUntilHealthy));

_logger.LogDebug("Exec container resource '{ResourceName}' will run command '{Command}' with {ArgsCount} args '{Args}'.", execResourceName, exe, args?.Length ?? 0, string.Join(' ', args ?? []));
return containerExecutable;

(string exe, string[] args) ParseCommand()
{
// cli wraps the command into the string with quotes
// to keep the command as a single argument
var command = _execOptions.Command;
var commandUnwrapped = command.AsSpan(1, command.Length - 2).ToString();
Debug.Assert(command[0] == '"' && command[^1] == '"');

return CommandLineArgsParser.ParseCommand(commandUnwrapped);
}
}
}
1 change: 1 addition & 0 deletions src/Shared/Model/KnownResourceTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Aspire.Dashboard.Model;
internal static class KnownResourceTypes
{
public const string Executable = "Executable";
public const string ContainerExec = "ContainerExec";
public const string Project = "Project";
public const string Container = "Container";
public const string Parameter = "Parameter";
Expand Down
6 changes: 0 additions & 6 deletions tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@
<ProjectReference Include="..\..\src\Aspire.Hosting\Aspire.Hosting.csproj" />
<ProjectReference Include="..\..\src\Aspire.Cli\Aspire.Cli.csproj" />
<ProjectReference Include="..\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj" />
<ProjectReference Include="..\Aspire.TestUtilities\Aspire.TestUtilities.csproj" IsAspireProjectResource="false" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\playground\DatabaseMigration\DatabaseMigration.ApiService\DatabaseMigration.ApiService.csproj" />
<ProjectReference Include="..\..\playground\DatabaseMigration\DatabaseMigration.AppHost\DatabaseMigration.AppHost.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
82 changes: 82 additions & 0 deletions tests/Aspire.Cli.Tests/E2E/ExecContainerResourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Utils;
using Aspire.TestUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Projects;
using Xunit;

namespace Aspire.Cli.Tests.E2E;

public class ExecContainerResourceTests(ITestOutputHelper output)
{
private static string ContainersAppHostProjectPath =>
Path.Combine(Containers_AppHost.ProjectPath, "Containers.AppHost.csproj");

[Fact]
[RequiresDocker]
public async Task Exec_ListFilesInDirectory_ShouldProduceLogs()
{
Environment.SetEnvironmentVariable("DCP_DIAGNOSTICS_LOG_LEVEL", "debug");
Environment.SetEnvironmentVariable("DCP_DIAGNOSTICS_LOG_FOLDER", @"D:\.other\dcp-logs");

string[] args = [
"--operation", "run",
"--project", ContainersAppHostProjectPath,
"--resource", "nginx",
"--command", "\"ls\""
];

var app = await BuildAppAsync(args);
var logs = await ExecAndCollectLogsAsync(app, timeoutSec: /* TODO remove after debugging */ 6000);

Assert.True(logs.Count > 0, "No logs were produced during the exec operation.");
}

private async Task<List<CommandOutput>> ExecAndCollectLogsAsync(DistributedApplication app, int timeoutSec = 30)
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec));

var appHostRpcTarget = app.Services.GetRequiredService<AppHostRpcTarget>();
var outputStream = appHostRpcTarget.ExecAsync(cts.Token);

var logs = new List<CommandOutput>();
var startTask = app.StartAsync(cts.Token);
await foreach (var message in outputStream)
{
var logLevel = message.IsErrorMessage ? LogLevel.Error : LogLevel.Information;
var log = $"Received output: #{message.LineNumber} [level={logLevel}] [type={message.Type}] {message.Text}";

logs.Add(message);
output.WriteLine(log);
}

await startTask;
return logs;
}

private async Task<DistributedApplication> BuildAppAsync(string[] args, Action<DistributedApplicationOptions, HostApplicationBuilderSettings>? configureBuilder = null)
{
configureBuilder ??= (appOptions, _) => { };
var builder = DistributedApplicationTestingBuilder.Create(args, configureBuilder, typeof(DatabaseMigration_AppHost).Assembly)
.WithTestAndResourceLogging(output);

builder.Services.Configure<DcpOptions>(options =>
{
options.ContainerRuntime = "docker"; // This should use PATH lookup instead of full path
});

var apiService = builder.AddProject<Containers_ApiService>("apiservice");
var nginx = builder.AddContainer("nginx", "nginx", "1.25");
var executable = builder.AddExecutable("cmd", "dotnet build", "C:\\code");

return await builder.BuildAsync();
}
}
Loading
Loading