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
17 changes: 17 additions & 0 deletions extension/schemas/aspire-global-settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,23 @@
"sdkVersion": {
"description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.",
"type": "string"
},
"overrideStagingFeed": {
"description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.",
"type": "string"
},
"overrideStagingQuality": {
"description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".",
"type": "string",
"enum": [
"Stable",
"Prerelease",
"Both"
]
},
"stagingVersionPrefix": {
"description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.",
"type": "string"
}
},
"additionalProperties": false
Expand Down
17 changes: 17 additions & 0 deletions extension/schemas/aspire-settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,23 @@
"sdkVersion": {
"description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.",
"type": "string"
},
"overrideStagingFeed": {
"description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.",
"type": "string"
},
"overrideStagingQuality": {
"description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".",
"type": "string",
"enum": [
"Stable",
"Prerelease",
"Both"
]
},
"stagingVersionPrefix": {
"description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.",
"type": "string"
}
},
"additionalProperties": false
Expand Down
8 changes: 7 additions & 1 deletion src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Diagnostics;
#endif

using Aspire.Cli.Bundles;
using Aspire.Cli.Commands.Sdk;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
Expand Down Expand Up @@ -131,6 +132,7 @@ public RootCommand(
SdkCommand sdkCommand,
SetupCommand setupCommand,
ExtensionInternalCommand extensionInternalCommand,
IBundleService bundleService,
IFeatures featureFlags,
IInteractionService interactionService)
: base(RootCommandStrings.Description)
Expand Down Expand Up @@ -208,7 +210,11 @@ public RootCommand(
Subcommands.Add(agentCommand);
Subcommands.Add(telemetryCommand);
Subcommands.Add(docsCommand);
Subcommands.Add(setupCommand);

if (bundleService.IsBundle)
{
Subcommands.Add(setupCommand);
}

if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false))
{
Expand Down
25 changes: 18 additions & 7 deletions src/Aspire.Cli/Packaging/PackageChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,28 @@

namespace Aspire.Cli.Packaging;

internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null)
{
public string Name { get; } = name;
public PackageChannelQuality Quality { get; } = quality;
public PackageMapping[]? Mappings { get; } = mappings;
public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit;
public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder;
public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl;
public SemVersion? VersionPrefix { get; } = versionPrefix;

public string SourceDetails { get; } = ComputeSourceDetails(mappings);

private bool MatchesVersionPrefix(SemVersion semVer)
{
if (VersionPrefix is null)
{
return true;
}

return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor;
}

private static string ComputeSourceDetails(PackageMapping[]? mappings)
{
if (mappings is null)
Expand Down Expand Up @@ -69,7 +80,7 @@ public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryI
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
}).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version)));

return filteredPackages;
}
Expand Down Expand Up @@ -104,7 +115,7 @@ public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(Directo
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
}).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version)));

return filteredPackages;
}
Expand Down Expand Up @@ -159,7 +170,7 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken);

return packages;
return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version)));
}

// When doing a `dotnet package search` the the results may include stable packages even when searching for
Expand All @@ -170,14 +181,14 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
}).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version)));

return filteredPackages;
}

public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null)
{
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl);
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix);
}

public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache)
Expand Down
42 changes: 38 additions & 4 deletions src/Aspire.Cli/Packaging/PackagingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Microsoft.Extensions.Configuration;
using Semver;
using System.Reflection;

namespace Aspire.Cli.Packaging;
Expand Down Expand Up @@ -75,24 +76,34 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc

private PackageChannel? CreateStagingChannel()
{
var stagingFeedUrl = GetStagingFeedUrl();
var stagingQuality = GetStagingQuality();
var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]);

// When quality is Prerelease or Both and no explicit feed override is set,
// use the shared daily feed instead of the SHA-specific feed. SHA-specific
// darc-pub-* feeds are only created for stable-quality builds, so a non-Stable
// quality without an explicit feed override can only work with the shared feed.
var useSharedFeed = !hasExplicitFeedOverride &&
stagingQuality is not PackageChannelQuality.Stable;

var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed);
if (stagingFeedUrl is null)
{
return null;
}

var stagingQuality = GetStagingQuality();
var versionPrefix = GetStagingVersionPrefix();

var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[]
{
new PackageMapping("Aspire*", stagingFeedUrl),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nuGetPackageCache, configureGlobalPackagesFolder: true, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily");
}, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix);

return stagingChannel;
}

private string? GetStagingFeedUrl()
private string? GetStagingFeedUrl(bool useSharedFeed)
{
// Check for configuration override first
var overrideFeed = configuration["overrideStagingFeed"];
Expand All @@ -107,6 +118,12 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
// Invalid URL, fall through to default behavior
}

// Use the shared daily feed when builds aren't marked stable
if (useSharedFeed)
{
return "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json";
}

// Extract commit hash from assembly version to build staging feed URL
// Staging feed URL template: https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-aspire-{commitHash}/nuget/v3/index.json
var assembly = Assembly.GetExecutingAssembly();
Expand Down Expand Up @@ -148,4 +165,21 @@ private PackageChannelQuality GetStagingQuality()
// Default to Stable if not specified or invalid
return PackageChannelQuality.Stable;
}

private SemVersion? GetStagingVersionPrefix()
{
var versionPrefixValue = configuration["stagingVersionPrefix"];
if (string.IsNullOrEmpty(versionPrefixValue))
{
return null;
}

// Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison
if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion))
{
return semVersion;
}

return null;
}
}
23 changes: 5 additions & 18 deletions src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
// - if in PublishMode
// - if a compute resource has RoleAssignmentAnnotations, use them
// - if the resource doesn't, copy the DefaultRoleAssignments to RoleAssignmentAnnotations to apply the defaults
var resourceSnapshot = appModel.Resources.ToArray(); // avoid modifying the collection while iterating
var resourceSnapshot = appModel.GetComputeResources()
.Concat(appModel.Resources
.OfType<AzureUserAssignedIdentityResource>()
.Where(r => !r.IsExcludedFromPublish()))
.ToArray(); // avoid modifying the collection while iterating
foreach (var resource in resourceSnapshot)
{
if (resource.IsExcludedFromPublish())
{
continue;
}

if (!IsResourceValidForRoleAssignments(resource))
{
continue;
}

var azureReferences = await GetAzureReferences(resource, cancellationToken).ConfigureAwait(false);

var azureReferencesWithRoleAssignments =
Expand Down Expand Up @@ -231,13 +225,6 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
{
CreateGlobalRoleAssignments(appModel, globalRoleAssignments);
}

// We can derive role assignments for compute resources and declared
// AzureUserAssignedIdentityResources
static bool IsResourceValidForRoleAssignments(IResource resource)
{
return resource.IsContainer() || resource is ProjectResource || resource is AzureUserAssignedIdentityResource;
}
}

private static Dictionary<AzureProvisioningResource, IEnumerable<RoleDefinition>> GetAllRoleAssignments(IResource resource)
Expand Down
2 changes: 0 additions & 2 deletions src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,6 @@ private static void CollectDependenciesFromValue(object? value, HashSet<IResourc
{
newDependencies.Add(resource);
}
CollectAnnotationDependencies(resource, dependencies, newDependencies);
}

// Resource builder wrapping a resource
Expand All @@ -1426,7 +1425,6 @@ private static void CollectDependenciesFromValue(object? value, HashSet<IResourc
{
newDependencies.Add(resourceBuilder.Resource);
}
CollectAnnotationDependencies(resourceBuilder.Resource, dependencies, newDependencies);
value = resourceBuilder.Resource;
}

Expand Down
29 changes: 29 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,33 @@ public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun()
Assert.True(sentinel.WasCreated);
}

[Fact]
public void SetupCommand_NotAvailable_WhenBundleIsNotAvailable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<RootCommand>();
var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup");

Assert.False(hasSetupCommand);
}

[Fact]
public void SetupCommand_Available_WhenBundleIsAvailable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.BundleServiceFactory = _ => new TestBundleService(isBundle: true);
});
var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<RootCommand>();
var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup");

Assert.True(hasSetupCommand);
}

}
Loading
Loading