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
13 changes: 13 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
<DefineConstants>$(DefineConstants);CLI</DefineConstants>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<!-- Controls whether the CLI binary should perform self-extraction of embedded bundle at runtime.
Defaults to false; set to true only for dotnet-tool distribution (Aspire.Cli.Tool.csproj). -->
<SelfExtractingBundle Condition="'$(SelfExtractingBundle)' == ''">false</SelfExtractingBundle>
</PropertyGroup>

<!-- Set the nuget properties when building as a global tool. -->
Expand All @@ -39,12 +42,22 @@
To do this we make sure it doesn't PublishAOT, and ensure that it doesn't publish for any RIDs at all. -->
<PublishAot>false</PublishAot>
<RuntimeIdentifiers></RuntimeIdentifiers>
<!-- dotnet-tool distribution needs self-extraction because NuGet enforces a fixed package layout. -->
<SelfExtractingBundle>true</SelfExtractingBundle>
</PropertyGroup>

<ItemGroup Condition="'$(PublishAot)' == 'true'">
<LinkerArg Condition="'$(RuntimeIdentifier)' == 'linux-musl-x64'" Include="--target=x86_64-alpine-linux-musl" />
</ItemGroup>

<!-- Bake the SelfExtractingBundle flag into the assembly so BundleService can read it at runtime. -->
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadata">
<_Parameter1>SelfExtractingBundle</_Parameter1>
<_Parameter2>$(SelfExtractingBundle)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
<PackageReference Include="Spectre.Console" />
Expand Down
23 changes: 23 additions & 0 deletions src/Aspire.Cli/Bundles/BundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using System.Reflection;
using Aspire.Cli.Layout;
using Aspire.Cli.Utils;
using Aspire.Shared;
Expand All @@ -26,9 +27,18 @@ internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger<Bu
private static readonly bool s_isBundle =
typeof(BundleService).Assembly.GetManifestResourceInfo(PayloadResourceName) is not null;

private static readonly bool s_isSelfExtracting = string.Equals(
typeof(BundleService).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(m => string.Equals(m.Key, "SelfExtractingBundle", StringComparison.OrdinalIgnoreCase))?.Value,
"true",
StringComparison.OrdinalIgnoreCase);

/// <inheritdoc/>
public bool IsBundle => s_isBundle;

/// <inheritdoc/>
public bool IsSelfExtracting => s_isSelfExtracting;

/// <summary>
/// Opens a read-only stream over the embedded bundle payload.
/// Returns <see langword="null"/> if no payload is embedded.
Expand All @@ -54,6 +64,19 @@ public async Task EnsureExtractedAsync(CancellationToken cancellationToken = def
return;
}

if (!IsSelfExtracting)
{
// Non-self-extracting distributions (archive/installer) expect the layout
// to already be on disk. Only skip extraction if the layout is actually present.
if (layoutDiscovery.DiscoverLayout() is not null)
{
logger.LogDebug("Self-extraction is disabled and layout is already on disk, skipping extraction.");
return;
}

logger.LogDebug("Self-extraction is disabled but no layout found on disk, proceeding with extraction as fallback.");
}

var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/Bundles/IBundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ internal interface IBundleService
/// </summary>
bool IsBundle { get; }

/// <summary>
/// Gets whether the CLI binary should perform self-extraction of the embedded bundle at runtime.
/// When <see langword="false"/>, the layout is expected to already be on disk (e.g. archive/installer distributions).
/// </summary>
bool IsSelfExtracting { get; }

/// <summary>
/// Ensures the bundle is extracted for the current CLI binary if it contains an embedded payload.
/// No-ops if no payload is embedded, or if the layout is already extracted and up to date.
Expand Down
37 changes: 37 additions & 0 deletions tests/Aspire.Cli.Tests/BundleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Cli.Tests;

Expand Down Expand Up @@ -74,4 +76,39 @@ public void GetCurrentVersion_ReturnsNonNull()
Assert.NotNull(version);
Assert.NotEqual("unknown", version);
}

[Fact]
public void IsSelfExtracting_ReturnsFalse_WhenMetadataNotSetToTrue()
{
// Test assembly builds with default SelfExtractingBundle=false,
// so IsSelfExtracting should be false.
var layoutDiscovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance);
var service = new BundleService(layoutDiscovery, NullLogger<BundleService>.Instance);
Assert.False(service.IsSelfExtracting);
}

[Fact]
public async Task EnsureExtractedAndGetLayoutAsync_ReturnsNull_WhenNoBundleAndNoLayout()
{
// Test assembly has no embedded bundle and IsSelfExtracting=false.
// EnsureExtractedAsync should no-op and DiscoverLayout should return null.
var layoutDiscovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance);
var service = new BundleService(layoutDiscovery, NullLogger<BundleService>.Instance);

var layout = await service.EnsureExtractedAndGetLayoutAsync();
Assert.Null(layout);
}

[Fact]
public async Task EnsureExtractedAsync_DoesNotThrow_WhenNotSelfExtractingAndNoLayout()
{
// When IsSelfExtracting=false and no layout exists, EnsureExtractedAsync
// should not throw — it should gracefully no-op (since IsBundle is also false
// in the test assembly). This verifies the method doesn't unconditionally
// short-circuit in a way that prevents DiscoverLayout from being called.
var layoutDiscovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance);
var service = new BundleService(layoutDiscovery, NullLogger<BundleService>.Instance);

await service.EnsureExtractedAsync();
}
}
6 changes: 5 additions & 1 deletion tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,8 @@ internal sealed class NullBundleService : IBundleService
{
public bool IsBundle => false;

public bool IsSelfExtracting => false;

public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;

public Task<BundleExtractResult> ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default)
Expand All @@ -618,10 +620,12 @@ public Task<BundleExtractResult> ExtractAsync(string destinationPath, bool force
/// <summary>
/// A configurable bundle service for testing bundle-dependent behavior.
/// </summary>
internal sealed class TestBundleService(bool isBundle) : IBundleService
internal sealed class TestBundleService(bool isBundle, bool isSelfExtracting = false) : IBundleService
{
public bool IsBundle => isBundle;

public bool IsSelfExtracting => isSelfExtracting;

public Layout.LayoutConfiguration? Layout { get; set; }

public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
Expand Down
Loading