Skip to content
Merged
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
212 changes: 192 additions & 20 deletions src/Aspire.Cli/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;

namespace Aspire.Cli.Commands;
Expand Down Expand Up @@ -279,10 +280,30 @@ private async Task<int> DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingD
InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Created package sources file");
}

// Drop aspire.config.json
var configResult = DropAspireConfig(workingDirectory, "apphost.cs", language: null);
// Generate one set of ports so aspire.config.json (used by `aspire run`) and
// apphost.run.json (used by `dotnet run apphost.cs`) agree on the dashboard /
// OTLP / resource service endpoints.
var ports = AppHostProfilePortGenerator.Generate(Random.Shared);

Comment on lines +283 to +287
// Drop aspire.config.json. The returned ports are whatever ended up effective
// in aspire.config.json — newly generated, or pre-existing if the file already
// had a `profiles` section. Use the SAME ports for apphost.run.json so the two
// files always agree on dashboard / OTLP / resource service endpoints.
var (configResult, effectivePorts) = DropAspireConfig(workingDirectory, "apphost.cs", language: null, ports);
if (configResult != ExitCodeConstants.Success)
{
return configResult;
}

// Drop apphost.run.json so `dotnet run apphost.cs` picks up the dashboard /
// OTLP / resource service env vars from the file-based launch profile. Without
// this file the AppHost crashes at startup because DashboardOptions validation
// requires ASPNETCORE_URLS and ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL to be set
// (these env vars are otherwise injected by the Aspire CLI when running via
// `aspire run`, but `dotnet run apphost.cs` does not go through that path).
DropAppHostRunJson(workingDirectory, effectivePorts);

return configResult;
return ExitCodeConstants.Success;
}

private async Task<int> DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken)
Expand Down Expand Up @@ -402,7 +423,7 @@ private async Task<int> DropPolyglotSkeletonAsync(string languageId, DirectoryIn
return ExitCodeConstants.Success;
}

private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language)
private (int ExitCode, AppHostProfilePorts EffectivePorts) DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language, AppHostProfilePorts? ports = null)
{
var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName);

Expand All @@ -426,7 +447,7 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string
{
InteractionService.DisplayError($"Failed to parse existing {AspireConfigFile.FileName} at '{configPath}': {ex.Message}");
InteractionService.DisplayMessage(KnownEmojis.Warning, $"Fix or remove {AspireConfigFile.FileName} and re-run `aspire init`.");
return ExitCodeConstants.FailedToCreateNewProject;
return (ExitCodeConstants.FailedToCreateNewProject, default);
}
}
}
Expand All @@ -451,42 +472,193 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string
appHost["language"] = language;
}

// Write default profiles with random ports for dashboard/OTLP/resource service.
// Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json).
// Normally scaffolding + codegen creates these, but our thin init skips scaffolding.
if (settings["profiles"] is null)
// Resolve the effective ports. Three cases:
// 1. profiles is null → write fresh profiles, return those ports
// 2. profiles exists and parses cleanly → adopt those ports, return them (so
// apphost.run.json stays in sync with what `aspire run` will use)
// 3. profiles exists but doesn't match the expected 6-port shape (user-customized
// or older format) → PRESERVE the existing profiles untouched and just generate
// fresh ports for apphost.run.json. This is strictly safer than overwriting,
// even if the two files end up disagreeing on dashboard ports — the user has
// already opted into a custom config and we shouldn't trash their data.
AppHostProfilePorts effectivePorts;
var existingProfilesObject = settings["profiles"] as JsonObject;
if (existingProfilesObject is not null && TryReadAppHostProfilePorts(existingProfilesObject, out var readPorts))
{
effectivePorts = readPorts;
}
else if (existingProfilesObject is not null)
{
// Existing profiles can't be parsed into our expected shape — leave them alone
// and just generate fresh ports for apphost.run.json. We deliberately don't
// overwrite the user's customizations, even though it means the two files may
// bind to different dashboard URLs in this edge case.
effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);
}
else
{
var ports = AppHostProfilePortGenerator.Generate(Random.Shared);
// Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json).
// Normally scaffolding + codegen creates these, but our thin init skips scaffolding.
effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);

// Two profiles (https + http) so `aspire run` can pick either based on user choice.
// Each carries the dashboard URL (applicationUrl) plus the OTLP and resource-service
// endpoint env vars consumed by DashboardOptionsValidator at AppHost startup.
settings["profiles"] = new JsonObject
{
["https"] = new JsonObject
{
["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}",
["applicationUrl"] = $"https://localhost:{effectivePorts.DashboardHttpsPort};http://localhost:{effectivePorts.DashboardHttpPort}",
["environmentVariables"] = new JsonObject
{
["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{ports.OtlpHttpsPort}",
["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{ports.ResourceServiceHttpsPort}"
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{effectivePorts.OtlpHttpsPort}",
[KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{effectivePorts.ResourceServiceHttpsPort}"
}
},
["http"] = new JsonObject
{
["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}",
["applicationUrl"] = $"http://localhost:{effectivePorts.DashboardHttpPort}",
["environmentVariables"] = new JsonObject
{
["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{ports.OtlpHttpPort}",
["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{ports.ResourceServiceHttpPort}",
["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{effectivePorts.OtlpHttpPort}",
[KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{effectivePorts.ResourceServiceHttpPort}",
[KnownConfigNames.AllowUnsecuredTransport] = "true"
}
}
};
}

var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(configPath, settings.ToJsonString(jsonOptions));
File.WriteAllText(configPath, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));

InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {AspireConfigFile.FileName}");
return ExitCodeConstants.Success;
return (ExitCodeConstants.Success, effectivePorts);
}

// Best-effort extraction of the dashboard / OTLP / resource service ports from an
// existing `profiles` section. Returns true only if every expected port can be parsed
// from the https + http profiles, otherwise the caller falls back to fresh ports.
private static bool TryReadAppHostProfilePorts(JsonObject profiles, out AppHostProfilePorts ports)
{
ports = default;

if (profiles["https"] is not JsonObject https || profiles["http"] is not JsonObject http)
{
return false;
}

var httpsEnv = https["environmentVariables"] as JsonObject;
var httpEnv = http["environmentVariables"] as JsonObject;
if (httpsEnv is null || httpEnv is null)
{
return false;
}

if (!TryParseHostPort(https["applicationUrl"]?.GetValue<string>(), "https", out var dashboardHttps)
|| !TryParseHostPort(http["applicationUrl"]?.GetValue<string>(), "http", out var dashboardHttp)
|| !TryParseHostPort(httpsEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "https", out var otlpHttps)
|| !TryParseHostPort(httpEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "http", out var otlpHttp)
|| !TryParseHostPort(httpsEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "https", out var resourceServiceHttps)
|| !TryParseHostPort(httpEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "http", out var resourceServiceHttp))
{
return false;
}

ports = new AppHostProfilePorts(
DashboardHttpsPort: dashboardHttps,
DashboardHttpPort: dashboardHttp,
OtlpHttpsPort: otlpHttps,
OtlpHttpPort: otlpHttp,
ResourceServiceHttpsPort: resourceServiceHttps,
ResourceServiceHttpPort: resourceServiceHttp);
return true;
}

// Parses the first `<scheme>://host:<port>` segment from a (possibly semicolon-
// separated) URL list. Returns false if no segment with the requested scheme is found
// or the port can't be parsed.
private static bool TryParseHostPort(string? value, string scheme, out int port)
{
port = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}

foreach (var raw in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
{
continue;
}

if (string.Equals(uri.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && uri.Port > 0)
{
port = uri.Port;
return true;
}
}

return false;
}

// Writes apphost.run.json next to the single-file AppHost so that
// `dotnet run apphost.cs` (.NET file-based runner) picks up the dashboard / OTLP /
// resource service launch profile env vars. Mirrors the structure shipped by the
// aspire-apphost-singlefile MSBuild template. Skips if the file already exists.
private void DropAppHostRunJson(DirectoryInfo directory, AppHostProfilePorts ports)
Comment on lines +604 to +608
{
const string fileName = "apphost.run.json";
var path = Path.Combine(directory.FullName, fileName);
if (File.Exists(path))
{
return;
}

// Shape mirrors a Properties/launchSettings.json (the schema the .NET file-based
// runner inherits for `[file].run.json`): a `profiles` map with `commandName: Project`
// entries. The https / http pair gives `dotnet run apphost.cs` a working dashboard
// URL plus the OTLP and resource-service endpoint env vars that DashboardOptionsValidator
// requires — without these the AppHost crashes at startup (see #15986).
var settings = new JsonObject
{
["$schema"] = "https://json.schemastore.org/launchsettings.json",
["profiles"] = new JsonObject
{
["https"] = new JsonObject
{
["commandName"] = "Project",
["dotnetRunMessages"] = true,
["launchBrowser"] = true,
["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}",
["environmentVariables"] = new JsonObject
{
["ASPNETCORE_ENVIRONMENT"] = "Development",
["DOTNET_ENVIRONMENT"] = "Development",
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{ports.OtlpHttpsPort}",
[KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{ports.ResourceServiceHttpsPort}"
}
},
["http"] = new JsonObject
{
["commandName"] = "Project",
["dotnetRunMessages"] = true,
["launchBrowser"] = true,
["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}",
["environmentVariables"] = new JsonObject
{
["ASPNETCORE_ENVIRONMENT"] = "Development",
["DOTNET_ENVIRONMENT"] = "Development",
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{ports.OtlpHttpPort}",
[KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{ports.ResourceServiceHttpPort}",
[KnownConfigNames.AllowUnsecuredTransport] = "true"
}
}
}
};

File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));

InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {fileName}");
}

}
Loading
Loading