Skip to content

Commit b57f266

Browse files
committed
Self-extracting bundle: trailer format, lazy extraction, setup command, build integration
- Add BundleTrailer (src/Shared/) with read/write/extract helpers for the 32-byte trailer appended to native AOT CLI binaries - Change IAppHostServerProjectFactory.Create() → CreateAsync() and update all 8 call sites (GuestAppHostProject, SdkGenerateCommand, SdkDumpCommand, ScaffoldingService, AppHostServerSession) - Add EnsureBundleAsync in AppHostServerProjectFactory that lazily extracts the embedded tar.gz payload on first polyglot command (run/publish/add) - Add 'aspire setup' command for explicit extraction with --install-path and --force options - Add --embed-in-cli option to CreateLayout tool that appends tar.gz payload + trailer to the native CLI binary - Update Bundle.proj to pass --embed-in-cli pointing to the layout CLI binary
1 parent a6b68c7 commit b57f266

File tree

14 files changed

+548
-15
lines changed

14 files changed

+548
-15
lines changed

eng/Bundle.proj

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,13 @@
118118
<!-- Remove trailing slashes for command line arguments -->
119119
<_BundleOutputDirArg>$(BundleOutputDir.TrimEnd('\').TrimEnd('/'))</_BundleOutputDirArg>
120120
<_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/'))</_ArtifactsDirArg>
121-
<_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --archive --verbose --download-runtime</_CreateLayoutArgs>
121+
122+
<!-- Determine CLI executable name and path for self-extracting bundle -->
123+
<_CliExeName Condition="$(TargetRid.StartsWith('win'))">aspire.exe</_CliExeName>
124+
<_CliExeName Condition="!$(TargetRid.StartsWith('win'))">aspire</_CliExeName>
125+
<_CliOutputPath>$(_BundleOutputDirArg)$([System.IO.Path]::DirectorySeparatorChar)$(_CliExeName)</_CliOutputPath>
126+
127+
<_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --archive --verbose --download-runtime --embed-in-cli "$(_CliOutputPath)"</_CreateLayoutArgs>
122128
</PropertyGroup>
123129

124130
<Message Importance="high" Text="Creating bundle layout..." />

src/Aspire.Cli/Aspire.Cli.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353

5454
<ItemGroup>
5555
<Compile Include="$(SharedDir)BundleDiscovery.cs" Link="Layout\BundleDiscovery.cs" />
56+
<Compile Include="$(SharedDir)BundleTrailer.cs" Link="Layout\BundleTrailer.cs" />
5657
<Compile Include="$(SharedDir)KnownConfigNames.cs" Link="Utils\KnownConfigNames.cs" />
5758
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
5859
<Compile Include="$(SharedDir)CircularBuffer.cs" Link="Utils\CircularBuffer.cs" />

src/Aspire.Cli/Commands/RootCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ public RootCommand(
128128
TelemetryCommand telemetryCommand,
129129
DocsCommand docsCommand,
130130
SdkCommand sdkCommand,
131+
SetupCommand setupCommand,
131132
ExtensionInternalCommand extensionInternalCommand,
132133
IFeatures featureFlags,
133134
IInteractionService interactionService)
@@ -205,6 +206,7 @@ public RootCommand(
205206
Subcommands.Add(agentCommand);
206207
Subcommands.Add(telemetryCommand);
207208
Subcommands.Add(docsCommand);
209+
Subcommands.Add(setupCommand);
208210

209211
if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false))
210212
{

src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ private async Task<int> DumpCapabilitiesAsync(
124124
// TODO: Support bundle mode by using DLL references instead of project references.
125125
// In bundle mode, we'd need to add integration DLLs to the probing path rather than
126126
// using additionalProjectReferences. For now, SDK dump only works with .NET SDK.
127-
var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir);
127+
var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);
128128
if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject)
129129
{
130130
InteractionService.DisplayError("SDK dump is only available with .NET SDK installed.");

src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ private async Task<int> GenerateSdkAsync(
123123
// TODO: Support bundle mode by using DLL references instead of project references.
124124
// In bundle mode, we'd need to add integration DLLs to the probing path rather than
125125
// using additionalProjectReferences. For now, SDK generation only works with .NET SDK.
126-
var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir);
126+
var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);
127127
if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject)
128128
{
129129
InteractionService.DisplayError("SDK generation is only available with .NET SDK installed.");
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
using Aspire.Cli.Configuration;
6+
using Aspire.Cli.Interaction;
7+
using Aspire.Cli.Layout;
8+
using Aspire.Cli.Projects;
9+
using Aspire.Cli.Telemetry;
10+
using Aspire.Cli.Utils;
11+
using Aspire.Shared;
12+
using Microsoft.Extensions.Logging;
13+
14+
namespace Aspire.Cli.Commands;
15+
16+
/// <summary>
17+
/// Extracts the embedded bundle payload from a self-extracting Aspire CLI binary.
18+
/// </summary>
19+
internal sealed class SetupCommand : BaseCommand
20+
{
21+
private readonly ILayoutDiscovery _layoutDiscovery;
22+
private readonly ILogger<SetupCommand> _logger;
23+
24+
private static readonly Option<string?> s_installPathOption = new("--install-path")
25+
{
26+
Description = "Directory to extract the bundle into. Defaults to the parent of the CLI binary's directory."
27+
};
28+
29+
private static readonly Option<bool> s_forceOption = new("--force")
30+
{
31+
Description = "Force extraction even if the layout already exists."
32+
};
33+
34+
public SetupCommand(
35+
ILayoutDiscovery layoutDiscovery,
36+
IFeatures features,
37+
ICliUpdateNotifier updateNotifier,
38+
CliExecutionContext executionContext,
39+
IInteractionService interactionService,
40+
AspireCliTelemetry telemetry,
41+
ILogger<SetupCommand> logger)
42+
: base("setup", "Extract the embedded bundle to set up the Aspire CLI runtime.", features, updateNotifier, executionContext, interactionService, telemetry)
43+
{
44+
_layoutDiscovery = layoutDiscovery;
45+
_logger = logger;
46+
47+
Options.Add(s_installPathOption);
48+
Options.Add(s_forceOption);
49+
}
50+
51+
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
52+
{
53+
var installPath = parseResult.GetValue(s_installPathOption);
54+
var force = parseResult.GetValue(s_forceOption);
55+
56+
var processPath = Environment.ProcessPath;
57+
if (string.IsNullOrEmpty(processPath))
58+
{
59+
InteractionService.DisplayError("Could not determine the CLI executable path.");
60+
return ExitCodeConstants.FailedToBuildArtifacts;
61+
}
62+
63+
var trailer = BundleTrailer.TryRead(processPath);
64+
if (trailer is null)
65+
{
66+
InteractionService.DisplayMessage(":information:", "This CLI binary does not contain an embedded bundle. No extraction needed.");
67+
return ExitCodeConstants.Success;
68+
}
69+
70+
// Determine extraction directory
71+
if (string.IsNullOrEmpty(installPath))
72+
{
73+
var cliDir = Path.GetDirectoryName(processPath);
74+
installPath = !string.IsNullOrEmpty(cliDir) ? Path.GetDirectoryName(cliDir) ?? cliDir : cliDir;
75+
}
76+
77+
if (string.IsNullOrEmpty(installPath))
78+
{
79+
InteractionService.DisplayError("Could not determine the installation path.");
80+
return ExitCodeConstants.FailedToBuildArtifacts;
81+
}
82+
83+
// Check if layout already exists
84+
if (!force && _layoutDiscovery.DiscoverLayout() is not null)
85+
{
86+
InteractionService.DisplayMessage(":white_check_mark:", "Bundle is already extracted. Use --force to re-extract.");
87+
return ExitCodeConstants.Success;
88+
}
89+
90+
// Extract with spinner
91+
var exitCode = await InteractionService.ShowStatusAsync(
92+
":package: Extracting Aspire bundle...",
93+
async () =>
94+
{
95+
await AppHostServerProjectFactory.ExtractPayloadAsync(processPath, trailer, installPath, cancellationToken);
96+
return ExitCodeConstants.Success;
97+
});
98+
99+
if (_layoutDiscovery.DiscoverLayout() is not null)
100+
{
101+
InteractionService.DisplayMessage(":white_check_mark:", $"Bundle extracted to {installPath}");
102+
}
103+
else
104+
{
105+
InteractionService.DisplayError($"Bundle was extracted to {installPath} but layout validation failed.");
106+
return ExitCodeConstants.FailedToBuildArtifacts;
107+
}
108+
109+
return exitCode;
110+
}
111+
}

src/Aspire.Cli/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, Dictionar
375375
builder.Services.AddTransient<SdkCommand>();
376376
builder.Services.AddTransient<SdkGenerateCommand>();
377377
builder.Services.AddTransient<SdkDumpCommand>();
378+
builder.Services.AddTransient<SetupCommand>();
378379
builder.Services.AddTransient<RootCommand>();
379380
builder.Services.AddTransient<ExtensionInternalCommand>();
380381

src/Aspire.Cli/Projects/AppHostServerProject.cs

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Formats.Tar;
5+
using System.IO.Compression;
46
using System.Security.Cryptography;
57
using System.Text;
68
using Aspire.Cli.Configuration;
79
using Aspire.Cli.DotNet;
810
using Aspire.Cli.Layout;
911
using Aspire.Cli.NuGet;
1012
using Aspire.Cli.Packaging;
13+
using Aspire.Shared;
1114
using Microsoft.Extensions.Logging;
1215

1316
namespace Aspire.Cli.Projects;
@@ -17,7 +20,7 @@ namespace Aspire.Cli.Projects;
1720
/// </summary>
1821
internal interface IAppHostServerProjectFactory
1922
{
20-
IAppHostServerProject Create(string appPath);
23+
Task<IAppHostServerProject> CreateAsync(string appPath, CancellationToken cancellationToken = default);
2124
}
2225

2326
/// <summary>
@@ -32,7 +35,7 @@ internal sealed class AppHostServerProjectFactory(
3235
BundleNuGetService bundleNuGetService,
3336
ILoggerFactory loggerFactory) : IAppHostServerProjectFactory
3437
{
35-
public IAppHostServerProject Create(string appPath)
38+
public async Task<IAppHostServerProject> CreateAsync(string appPath, CancellationToken cancellationToken = default)
3639
{
3740
// Normalize the path
3841
var normalizedPath = Path.GetFullPath(appPath);
@@ -71,7 +74,10 @@ public IAppHostServerProject Create(string appPath)
7174
loggerFactory.CreateLogger<DotNetBasedAppHostServerProject>());
7275
}
7376

74-
// Priority 2: Check if we have a bundle layout with a pre-built AppHost server
77+
// Priority 2: Ensure bundle is extracted if we have an embedded payload
78+
await EnsureBundleAsync(cancellationToken);
79+
80+
// Priority 3: Check if we have a bundle layout with a pre-built AppHost server
7581
var layout = layoutDiscovery.DiscoverLayout();
7682
if (layout is not null && layout.GetAppHostServerPath() is string serverPath && File.Exists(serverPath))
7783
{
@@ -91,6 +97,145 @@ public IAppHostServerProject Create(string appPath)
9197
"with a valid bundle layout.");
9298
}
9399

100+
/// <summary>
101+
/// Extracts the embedded bundle payload if the CLI binary is a self-extracting bundle
102+
/// and no valid layout has been discovered yet.
103+
/// </summary>
104+
private async Task EnsureBundleAsync(CancellationToken cancellationToken)
105+
{
106+
// If a layout already exists, nothing to do
107+
if (layoutDiscovery.DiscoverLayout() is not null)
108+
{
109+
return;
110+
}
111+
112+
// Check if the current process has an embedded bundle payload
113+
var processPath = Environment.ProcessPath;
114+
if (string.IsNullOrEmpty(processPath))
115+
{
116+
return;
117+
}
118+
119+
var trailer = BundleTrailer.TryRead(processPath);
120+
if (trailer is null)
121+
{
122+
return; // No embedded payload (dev build or already-extracted CLI)
123+
}
124+
125+
// Extract to the parent directory of the CLI binary's directory.
126+
// If CLI is at ~/.aspire/bin/aspire, extract to ~/.aspire/ so layout discovery
127+
// finds components via the bin/ layout pattern ({layout}/bin/aspire + {layout}/runtime/).
128+
var cliDir = Path.GetDirectoryName(processPath);
129+
if (string.IsNullOrEmpty(cliDir))
130+
{
131+
return;
132+
}
133+
134+
var extractDir = Path.GetDirectoryName(cliDir) ?? cliDir;
135+
var logger = loggerFactory.CreateLogger<AppHostServerProjectFactory>();
136+
logger.LogInformation("Extracting embedded bundle to {Path}...", extractDir);
137+
138+
await ExtractPayloadAsync(processPath, trailer, extractDir, cancellationToken);
139+
140+
// Verify extraction succeeded
141+
if (layoutDiscovery.DiscoverLayout() is null)
142+
{
143+
logger.LogWarning("Bundle extraction completed but layout discovery still failed");
144+
}
145+
}
146+
147+
/// <summary>
148+
/// Extracts the embedded tar.gz payload from the CLI binary to the specified directory.
149+
/// The tarball contains a top-level directory which is stripped during extraction.
150+
/// </summary>
151+
internal static async Task ExtractPayloadAsync(string processPath, BundleTrailerInfo trailer, string destinationPath, CancellationToken cancellationToken)
152+
{
153+
Directory.CreateDirectory(destinationPath);
154+
155+
using var payloadStream = BundleTrailer.OpenPayload(processPath, trailer);
156+
await using var gzipStream = new GZipStream(payloadStream, CompressionMode.Decompress);
157+
await using var tarReader = new TarReader(gzipStream);
158+
159+
while (await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken) is { } entry)
160+
{
161+
// Strip the top-level directory (equivalent to tar --strip-components=1)
162+
var name = entry.Name;
163+
var slashIndex = name.IndexOf('/');
164+
if (slashIndex < 0)
165+
{
166+
continue; // Top-level directory entry itself, skip
167+
}
168+
169+
var relativePath = name[(slashIndex + 1)..];
170+
if (string.IsNullOrEmpty(relativePath))
171+
{
172+
continue;
173+
}
174+
175+
var fullPath = Path.Combine(destinationPath, relativePath);
176+
177+
switch (entry.EntryType)
178+
{
179+
case TarEntryType.Directory:
180+
Directory.CreateDirectory(fullPath);
181+
break;
182+
183+
case TarEntryType.RegularFile:
184+
var dir = Path.GetDirectoryName(fullPath);
185+
if (dir is not null)
186+
{
187+
Directory.CreateDirectory(dir);
188+
}
189+
await entry.ExtractToFileAsync(fullPath, overwrite: true, cancellationToken);
190+
break;
191+
192+
case TarEntryType.SymbolicLink:
193+
var linkDir = Path.GetDirectoryName(fullPath);
194+
if (linkDir is not null)
195+
{
196+
Directory.CreateDirectory(linkDir);
197+
}
198+
if (File.Exists(fullPath))
199+
{
200+
File.Delete(fullPath);
201+
}
202+
File.CreateSymbolicLink(fullPath, entry.LinkName);
203+
break;
204+
}
205+
}
206+
207+
// Set executable permissions on Unix for key binaries
208+
if (!OperatingSystem.IsWindows())
209+
{
210+
SetExecutablePermissions(destinationPath);
211+
}
212+
}
213+
214+
/// <summary>
215+
/// Sets executable permissions on key binaries after extraction on Unix systems.
216+
/// </summary>
217+
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
218+
private static void SetExecutablePermissions(string layoutPath)
219+
{
220+
var muxerName = BundleDiscovery.GetDotNetExecutableName();
221+
string[] executablePaths =
222+
[
223+
Path.Combine(layoutPath, BundleDiscovery.RuntimeDirectoryName, muxerName),
224+
Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName, "dcp"),
225+
];
226+
227+
foreach (var path in executablePaths)
228+
{
229+
if (File.Exists(path))
230+
{
231+
File.SetUnixFileMode(path,
232+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
233+
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
234+
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
235+
}
236+
}
237+
}
238+
94239
/// <summary>
95240
/// Detects the Aspire repository root for dev mode.
96241
/// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable

src/Aspire.Cli/Projects/AppHostServerSession.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public async Task<AppHostServerSessionResult> CreateAsync(
105105
bool debug,
106106
CancellationToken cancellationToken)
107107
{
108-
var appHostServerProject = _projectFactory.Create(appHostPath);
108+
var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken);
109109

110110
// Prepare the server (create files + build for dev mode, restore packages for prebuilt mode)
111111
var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken);

0 commit comments

Comments
 (0)