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
2 changes: 1 addition & 1 deletion .github/skills/cli-e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ ASPIRE_E2E_ARCHIVE=/tmp/aspire-e2e.tar.gz \
-- --filter-method "*.YourTestName"

# 4. If it fails, check the asciinema recording
# Recordings are saved to $TMPDIR/aspire-cli-e2e/recordings/
# Recordings are saved under the test output TestResults/recordings/ directory
# Play with: asciinema play /path/to/YourTestName.cast

# 5. Fix and repeat from step 1 or 2
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ jobs:
# PR metadata and token for CLI E2E tests that download artifacts from a PR
GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }}
GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (60s interval to reduce log noise)
Expand Down Expand Up @@ -378,6 +379,7 @@ jobs:
# PR metadata and token for CLI E2E tests that download artifacts from a PR
GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }}
GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (output goes to console directly)
Expand Down Expand Up @@ -427,6 +429,7 @@ jobs:
# PR metadata and token for CLI E2E tests that download artifacts from a PR
GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }}
GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (60s interval to reduce log noise)
Expand Down Expand Up @@ -477,6 +480,7 @@ jobs:
# PR metadata and token for CLI E2E tests that download artifacts from a PR
GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
ASPIRE_CLI_WORKFLOW_RUN_ID: ${{ inputs.requiresCliArchive && github.run_id || '' }}
GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (output goes to console directly)
Expand Down
26 changes: 21 additions & 5 deletions localhive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,17 @@ if [[ $USE_COPY -eq 1 ]]; then
log "Populating hive '$HIVE_NAME' by copying .nupkg files (version suffix: $VERSION_SUFFIX)"
mkdir -p "$HIVE_PATH"
# Only copy packages matching the current version suffix to avoid accumulating stale packages
copied_packages=0
shopt -s nullglob
for pkg in "$PKG_DIR"/*"$VERSION_SUFFIX"*.nupkg; do
[ -f "$pkg" ] && cp -f "$pkg" "$HIVE_PATH"/
pkg_name="$(basename "$pkg")"
if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then
cp -f "$pkg" "$HIVE_PATH"/
copied_packages=$((copied_packages + 1))
fi
done
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)."
shopt -u nullglob
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)."
else
log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR"
mkdir -p "$HIVE_ROOT"
Expand All @@ -290,8 +297,17 @@ else
else
warn "Symlink not supported; copying .nupkg files instead"
mkdir -p "$HIVE_PATH"
cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)."
copied_packages=0
shopt -s nullglob
for pkg in "$PKG_DIR"/*.nupkg; do
pkg_name="$(basename "$pkg")"
if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then
cp -f "$pkg" "$HIVE_PATH"/
copied_packages=$((copied_packages + 1))
fi
done
shopt -u nullglob
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)."
fi
fi

Expand Down Expand Up @@ -411,7 +427,7 @@ if [[ $ARCHIVE -eq 1 ]]; then
else
ARCHIVE_PATH="${ARCHIVE_BASE}.tar.gz"
log "Creating archive: $ARCHIVE_PATH"
tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" .
COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" .
fi
log "Archive created: $ARCHIVE_PATH"
fi
Expand Down
83 changes: 80 additions & 3 deletions src/Aspire.Cli/DotNet/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -569,14 +569,25 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client);

// NOTE: The change to @ over :: for template version separator (now enforced in .NET 10.0 SDK).
List<string> cliArgs = ["new", "install", $"{packageName}@{version}"];
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;
var localPackagePath = ResolveLocalTemplatePackagePath(packageName, version, nugetSource, workingDirectory);

// dotnet new install <path>.nupkg --force can register duplicate template packages for the same
// local file. Refresh local packages by uninstalling first, then reinstalling without --force.
if (localPackagePath is not null && force)
{
await UninstallTemplateAsync(packageName, workingDirectory, cancellationToken);
force = false;
}

List<string> cliArgs = ["new", "install", localPackagePath?.FullName ?? $"{packageName}@{version}"];

if (force)
{
cliArgs.Add("--force");
}

if (nugetSource is not null)
if (localPackagePath is null && nugetSource is not null)
{
cliArgs.Add("--nuget-source");
cliArgs.Add(nugetSource);
Expand All @@ -602,7 +613,6 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
// folder as the working directory for the command. If we are using an implicit channel
// then we just use the current execution context for the CLI and inherit whatever
// NuGet.configs that may or may not be laying around.
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;

var exitCode = await ExecuteAsync(
args: [.. cliArgs],
Expand Down Expand Up @@ -634,6 +644,11 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
}
else
{
if (localPackagePath is not null)
{
return (exitCode, version);
}

if (stdout is null)
{
logger.LogError("Failed to read stdout from the process. This should never happen.");
Expand All @@ -659,6 +674,68 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
}
}

private async Task UninstallTemplateAsync(string packageName, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
var exitCode = await ExecuteAsync(
args: ["new", "uninstall", packageName],
env: new Dictionary<string, string>
{
[KnownConfigNames.DotnetCliUiLanguage] = "en-US"
},
projectFile: null,
workingDirectory: workingDirectory,
backchannelCompletionSource: null,
options: new ProcessInvocationOptions
{
SuppressLogging = true
},
cancellationToken: cancellationToken);

if (exitCode != 0)
{
logger.LogDebug("dotnet new uninstall {PackageName} returned {ExitCode} before local reinstall.", packageName, exitCode);
}
}

private static FileInfo? ResolveLocalTemplatePackagePath(string packageName, string version, string? nugetSource, DirectoryInfo workingDirectory)
{
if (string.IsNullOrWhiteSpace(nugetSource))
{
return null;
}

string sourcePath;
if (Uri.TryCreate(nugetSource, UriKind.Absolute, out var uri))
{
if (!uri.IsFile)
{
return null;
}

sourcePath = uri.LocalPath;
}
else
{
sourcePath = Path.GetFullPath(nugetSource, workingDirectory.FullName);
}

if (File.Exists(sourcePath) && string.Equals(Path.GetExtension(sourcePath), ".nupkg", StringComparison.OrdinalIgnoreCase))
{
return new FileInfo(sourcePath);
}

if (!Directory.Exists(sourcePath))
{
return null;
}

var expectedFileName = $"{packageName}.{version}.nupkg";
var packagePath = Directory.EnumerateFiles(sourcePath, "*.nupkg", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgnoreCase));

return packagePath is null ? null : new FileInfo(packagePath);
}

internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version)
{
var lines = stdout.Split(Environment.NewLine);
Expand Down
76 changes: 56 additions & 20 deletions src/Aspire.Cli/DotNet/ProcessExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ namespace Aspire.Cli.DotNet;
/// </summary>
internal sealed class ProcessExecution : IProcessExecution
{
private static readonly TimeSpan s_forwarderIdleTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_forwarderPollInterval = TimeSpan.FromMilliseconds(100);

private readonly Process _process;
private readonly ILogger _logger;
private readonly ProcessInvocationOptions _options;
private Task? _stdoutForwarder;
private Task? _stderrForwarder;
private long _lastForwarderActivityTimestamp = Stopwatch.GetTimestamp();

internal ProcessExecution(Process process, ILogger logger, ProcessInvocationOptions options)
{
Expand Down Expand Up @@ -52,6 +56,7 @@ public bool Start()
}

_logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", FileName, _process.Id, _process.StartInfo.WorkingDirectory);
RecordForwarderActivity();

// Start stream forwarders
_stdoutForwarder = Task.Run(async () =>
Expand Down Expand Up @@ -90,30 +95,26 @@ public async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
_logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode);
}

// Explicitly close the streams to unblock any pending ReadLineAsync calls.
// In some environments (particularly CI containers), the stream handles may not
// be automatically closed when the process exits, causing ReadLineAsync to block
// indefinitely. Disposing the streams forces them to close.
_logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams", FileName, _process.Id);
_process.StandardOutput.Close();
_process.StandardError.Close();

// Wait for all the stream forwarders to finish so we know we've got everything
// fired off through the callbacks. Use a timeout as a safety net in case
// something else is unexpectedly holding the streams open.
// Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit.
RecordForwarderActivity();

// Wait for the stream forwarders to drain naturally first so we don't cut off the
// tail of the process output. In some environments the stream handles can stay open
// after the process exits, so we fall back to closing them only if the forwarders
// stop making progress for the idle timeout.
if (_stdoutForwarder is not null && _stderrForwarder is not null)
{
var forwarderTimeout = Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]);

var completedTask = await Task.WhenAny(forwardersCompleted, forwarderTimeout);
if (completedTask == forwarderTimeout)
{
_logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within timeout after stream close", FileName, _process.Id);
}
else
if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id);
_logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder idle timeout", FileName, _process.Id);
_process.StandardOutput.Close();
_process.StandardError.Close();

if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false))
{
_logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within idle timeout after stream close", FileName, _process.Id);
}
}
}

Expand Down Expand Up @@ -146,6 +147,8 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
RecordForwarderActivity();

if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace(
Expand All @@ -157,6 +160,7 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi
);
}
lineCallback?.Invoke(line);
RecordForwarderActivity();
}
}
catch (ObjectDisposedException)
Expand All @@ -165,4 +169,36 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi
_logger.LogDebug("{FileName}({ProcessId}) {Identifier} stream forwarder completed - stream was closed", FileName, _process.Id, identifier);
}
}

private async Task<bool> WaitForForwardersAsync(Task forwardersCompleted, CancellationToken cancellationToken)
{
while (true)
{
if (forwardersCompleted.IsCompleted)
{
await forwardersCompleted.ConfigureAwait(false);
_logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id);
return true;
}

if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastForwarderActivityTimestamp)) >= s_forwarderIdleTimeout)
{
return false;
}

try
{
await Task.Delay(s_forwarderPollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
}
}

private void RecordForwarderActivity()
{
Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp());
}
}
36 changes: 34 additions & 2 deletions src/Aspire.Cli/Packaging/PackagingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Aspire.Cli.NuGet;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Semver;
using System.Reflection;

namespace Aspire.Cli.Packaging;
Expand Down Expand Up @@ -43,13 +44,16 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
foreach (var prHive in prHives)
{
// The packages subdirectory contains the actual .nupkg files
var packagesDirectory = new DirectoryInfo(Path.Combine(prHive.FullName, "packages"));
var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory);

// Use forward slashes for cross-platform NuGet config compatibility
var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/');
var packagesPath = packagesDirectory.FullName.Replace('\\', '/');
var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Both, new[]
{
new PackageMapping("Aspire*", packagesPath),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nuGetPackageCache);
}, nuGetPackageCache, pinnedVersion: pinnedVersion);

prPackageChannels.Add(prChannel);
}
Expand Down Expand Up @@ -179,4 +183,32 @@ private PackageChannelQuality GetStagingQuality()
var plusIndex = cliVersion.IndexOf('+');
return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion;
}

// Local hive channels point at a flat directory of .nupkg files instead of a searchable feed.
// Derive a concrete Aspire version from the hive contents and pin the channel to it so template
// and package resolution stays on the same locally built version instead of asking NuGet for "latest".
// Prefer Aspire.ProjectTemplates because it drives `aspire new`, then fall back to common packages
// that are still present when the templates package is absent.
private static string? GetLocalHivePinnedVersion(DirectoryInfo packagesDirectory)
{
if (!packagesDirectory.Exists)
{
return null;
}

return FindHighestVersion("Aspire.ProjectTemplates")
?? FindHighestVersion("Aspire.Hosting")
?? FindHighestVersion("Aspire.AppHost.Sdk");

string? FindHighestVersion(string packageId)
{
return packagesDirectory
.EnumerateFiles($"{packageId}.*.nupkg")
.Select(static file => file.Name)
.Select(fileName => fileName[(packageId.Length + 1)..^".nupkg".Length])
.Where(version => SemVersion.TryParse(version, SemVersionStyles.Strict, out _))
.OrderByDescending(version => SemVersion.Parse(version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
.FirstOrDefault();
}
}
}
1 change: 0 additions & 1 deletion src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
</ItemGroup>
</Target>

<!-- Replaces the versions referenced by the templates projects to use the version of the packages being live-built -->
<Target Name="ReplacePackageVersionOnTemplates"
DependsOnTargets="CopyTemplatesToIntermediateOutputPath">

Expand Down
Loading
Loading