Skip to content
Draft
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
23 changes: 21 additions & 2 deletions src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<UsingTask Condition="'$(_AspireUseTaskHostFactory)' == 'true'" TaskName="GetNonExecutableReferences" AssemblyFile="$(_AspireTasksAssembly)" TaskFactory="TaskHostFactory" />
<UsingTask Condition="'$(_AspireUseTaskHostFactory)' != 'true'" TaskName="ResolveAspireCliBundle" AssemblyFile="$(_AspireTasksAssembly)" />
<UsingTask Condition="'$(_AspireUseTaskHostFactory)' == 'true'" TaskName="ResolveAspireCliBundle" AssemblyFile="$(_AspireTasksAssembly)" TaskFactory="TaskHostFactory" />
<UsingTask Condition="'$(_AspireUseTaskHostFactory)' != 'true'" TaskName="GetAspireCliVersion" AssemblyFile="$(_AspireTasksAssembly)" />
<UsingTask Condition="'$(_AspireUseTaskHostFactory)' == 'true'" TaskName="GetAspireCliVersion" AssemblyFile="$(_AspireTasksAssembly)" TaskFactory="TaskHostFactory" />

<ItemGroup>
<ProjectCapability Include="Aspire" Condition=" '$(IsAspireHost)' == 'true' " />
Expand Down Expand Up @@ -228,19 +230,36 @@ namespace Projects%3B
<PropertyGroup>
<_AspireCliRunHookSuppressed Condition="'$(_AspireSuppressCliRunHook)' == 'true' or '$(_AspireSuppressCliRunHook)' == '1'">true</_AspireCliRunHookSuppressed>
<_AspireCliRunHookSuppressed Condition="'$(ASPIRE_SUPPRESS_CLI_RUN_HOOK)' == 'true' or '$(ASPIRE_SUPPRESS_CLI_RUN_HOOK)' == '1'">true</_AspireCliRunHookSuppressed>
<_AspireMinimumCliVersion>13.5.0</_AspireMinimumCliVersion>
<_AspireCliVersionSupportsRunHook>false</_AspireCliVersionSupportsRunHook>
</PropertyGroup>

<!-- Determines if the resolved CLI version is sufficient for the run hook delegation. -->
<Target Name="_AspireCheckCliVersion"
Condition="'$(IsAspireHost)' == 'true' and '$(AspireUseCliBundle)' == 'true' and '$(_AspireCliRunHookSuppressed)' != 'true' and Exists('$(_AspireTasksAssembly)')">
<GetAspireCliVersion AspireCliPath="$(AspireCliPath)">
<Output TaskParameter="AspireCliVersion" PropertyName="_AspireResolvedCliVersion" />
</GetAspireCliVersion>

<PropertyGroup>
<_AspireCliVersionSupportsRunHook Condition="'$(_AspireResolvedCliVersion)' != '' and $([MSBuild]::VersionGreaterThanOrEquals('$(_AspireResolvedCliVersion)', '$(_AspireMinimumCliVersion)'))">true</_AspireCliVersionSupportsRunHook>
</PropertyGroup>

<Message Text="Aspire CLI version check: $(_AspireResolvedCliVersion) (minimum required: $(_AspireMinimumCliVersion), supported: $(_AspireCliVersionSupportsRunHook))" Importance="Low" />
</Target>

<Target Name="_AspireComputeRunArguments"
BeforeTargets="ComputeRunArguments"
DependsOnTargets="_AspireCheckCliVersion"
Condition="'$(IsAspireHost)' == 'true' and '$(AspireUseCliBundle)' == 'true' and '$(_AspireCliRunHookSuppressed)' != 'true'">
<ItemGroup Condition="'$(FileBasedProgram)' == 'true'">
<ItemGroup Condition="'$(_AspireCliVersionSupportsRunHook)' == 'true' and '$(FileBasedProgram)' == 'true'">
<_AspireFileBasedAppHostPath Include="%(RuntimeHostConfigurationOption.Value)"
Condition="'%(RuntimeHostConfigurationOption.Identity)' == 'EntryPointFilePath'" />
<_AspireFileBasedAppHostDirectory Include="%(RuntimeHostConfigurationOption.Value)"
Condition="'%(RuntimeHostConfigurationOption.Identity)' == 'EntryPointFileDirectoryPath'" />
</ItemGroup>

<PropertyGroup>
<PropertyGroup Condition="'$(_AspireCliVersionSupportsRunHook)' == 'true'">
<_AspireOriginalRunArguments>$(RunArguments)</_AspireOriginalRunArguments>
<_AspireRunProject>$(MSBuildProjectFullPath)</_AspireRunProject>
<!-- File-based dotnet run uses a synthetic apphost.csproj for MSBuild, but the Aspire CLI needs the original .cs file. -->
Expand Down
242 changes: 242 additions & 0 deletions src/Aspire.Hosting.Tasks/GetAspireCliVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Build.Framework;

namespace Aspire.Hosting.Tasks;

/// <summary>
/// Gets the version of the Aspire CLI executable.
/// </summary>
public sealed class GetAspireCliVersion : Microsoft.Build.Utilities.Task
{
/// <summary>
/// Optional path to the Aspire CLI executable. When unset, the Aspire CLI is resolved from PATH.
/// </summary>
public string? AspireCliPath { get; set; }

/// <summary>
/// The resolved Aspire CLI version (e.g., "13.5.0").
/// Empty string if the version cannot be determined.
/// </summary>
[Output]
public string AspireCliVersion { get; set; } = string.Empty;

public override bool Execute()
{
try
{
var version = GetCliVersion(AspireCliPath);
AspireCliVersion = version ?? string.Empty;

if (!string.IsNullOrEmpty(version))
{
Log.LogMessage(MessageImportance.Low, $"Resolved Aspire CLI version: {version}");
}
else
{
Log.LogMessage(MessageImportance.Low, "Could not determine Aspire CLI version.");
}

return true;
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
Log.LogMessage(MessageImportance.Low, $"Error getting Aspire CLI version: {ex.Message}");
return true;
}
}

private static string? GetCliVersion(string? cliPath)
{
if (!string.IsNullOrWhiteSpace(cliPath) && !File.Exists(cliPath))
{
return null;
}

using var process = Process.Start(CreateStartInfo(cliPath));
if (process is null)
{
return null;
}

// Read both streams concurrently to avoid deadlock when a pipe buffer fills.
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();

if (!process.WaitForExit(5000))
{
TryKill(process);
TryWaitForExit(process);
ObserveDrainTasks(outputTask, errorTask);
return null;
}

if (!TryReadOutput(outputTask, errorTask, out var output))
{
return null;
}

if (process.ExitCode != 0)
{
return null;
}

// Aspire CLI version output is a bare informational version, for example:
// 13.5.0-preview.1.26319.9+gabcdef
// Strip prerelease and build metadata suffixes because System.Version only accepts numeric version components.
var suffixIndex = output.IndexOfAny(['-', '+']);
var version = suffixIndex >= 0 ? output.Substring(0, suffixIndex) : output;

return Version.TryParse(version, out _) ? version : null;
}

private static ProcessStartInfo CreateStartInfo(string? cliPath)
{
if (string.IsNullOrWhiteSpace(cliPath))
{
return IsWindows()
? CreateStartInfo(GetCommandPromptPath(), "/D /C aspire --version")
: CreateStartInfo("aspire", "--version");
}

return IsWindowsCommandScript(cliPath)
? CreateStartInfo(GetCommandPromptPath(), $"/D /C \"\"{cliPath}\" --version\"")
: CreateStartInfo(cliPath, "--version");
}

private static ProcessStartInfo CreateStartInfo(string fileName, string arguments) => new()
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

private static string GetCommandPromptPath()
{
var comSpec = Environment.GetEnvironmentVariable("ComSpec");

return string.IsNullOrWhiteSpace(comSpec) ? "cmd" : comSpec;
}

private static bool IsWindowsCommandScript(string path)
{
var extension = Path.GetExtension(path);

return IsWindows()
&& (string.Equals(extension, ".cmd", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".bat", StringComparison.OrdinalIgnoreCase));
}

private static void TryKill(Process process)
{
try
{
#if NET5_0_OR_GREATER
process.Kill(entireProcessTree: true);
#else
process.Kill();
#endif
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
}
}

private static void TryWaitForExit(Process process)
{
try
{
process.WaitForExit(1000);
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
}
}

private static bool TryReadOutput(Task<string> outputTask, Task<string> errorTask, out string output)
{
output = string.Empty;

try
{
if (!Task.WaitAll(new[] { outputTask, errorTask }, 1000))
{
ObserveDrainTasks(outputTask, errorTask);
return false;
}

output = outputTask.GetAwaiter().GetResult().Trim();
_ = errorTask.GetAwaiter().GetResult();
return true;
}
catch (AggregateException ex) when (ex.InnerExceptions.All(IsVersionQueryException))
{
return false;
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
return false;
}
}

private static void ObserveDrainTasks(params Task<string>[] tasks)
{
try
{
Task.WaitAll(tasks, 1000);
}
catch (AggregateException ex) when (ex.InnerExceptions.All(IsVersionQueryException))
{
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
}

foreach (var task in tasks)
{
ObserveDrainTask(task);
}
}

private static void ObserveDrainTask(Task<string> task)
{
if (!task.IsCompleted)
{
_ = task.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return;
}

try
{
_ = task.GetAwaiter().GetResult();
}
catch (Exception ex) when (IsVersionQueryException(ex))
{
}
}

private static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

private static bool IsVersionQueryException(Exception ex)
{
return ex is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or PathTooLongException
or System.Security.SecurityException
or Win32Exception
or InvalidOperationException;
}
}
Loading