Skip to content

Migrate DotnetToolResource to use RequiredCommandValidator#15265

Closed
afscrome wants to merge 1 commit intomicrosoft:release/13.2from
afscrome:dotnettool-requiredcommand
Closed

Migrate DotnetToolResource to use RequiredCommandValidator#15265
afscrome wants to merge 1 commit intomicrosoft:release/13.2from
afscrome:dotnettool-requiredcommand

Conversation

@afscrome
Copy link
Contributor

Description

  • Migrated dotnet tool version validation to use RequiredCommandValidator
  • Updated RequiredCommandValidator to include the callback in the cache key, so that resources for the same tool name, but different validation callbacks have the correct callback executed.

Fixes #14304

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
  • Did you add public API?
    • No
  • Does the change make any security assumptions or guarantees?
    • No
  • Does the change require an update in our Aspire docs?
    • No

@afscrome afscrome requested a review from mitchdenny as a code owner March 15, 2026 21:13
Copilot AI review requested due to automatic review settings March 15, 2026 21:13
@github-actions
Copy link
Contributor

github-actions bot commented Mar 15, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 15265

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 15265"

@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Mar 15, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates DotnetToolResource’s .NET SDK version check to the shared RequiredCommandValidator flow and adjusts the validator’s caching behavior to account for different validation callbacks per command.

Changes:

  • Add a WithRequiredCommand("dotnet", ...) validation to DotnetToolResource and remove the prior before-start SDK version check.
  • Update RequiredCommandValidator caching to key by (command, validationCallback) instead of just command.
  • Update/add tests for required-command caching behavior and for AddDotnetTool emitting the required-command annotation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs Adds coverage for caching behavior across identical vs different validation callbacks.
tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs Removes direct SDK version validation unit tests; adds an assertion that AddDotnetTool includes a RequiredCommandAnnotation.
src/Aspire.Hosting/DotnetToolResourceExtensions.cs Moves SDK version validation into a required-command validation callback and removes the prior before-start validation call.
src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs Changes validation caching key to include the callback and introduces a cache-key type.
Comments suppressed due to low confidence (1)

src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs:106

  • Including ValidationCallback in the cache key means missing-command failures (where resolved is null and the callback is never invoked) will no longer be coalesced across resources if they supply different callbacks for the same command. That can produce multiple identical warnings/notifications for a single missing executable. Consider splitting caching so command resolution/notification is keyed only by command, while callback execution results are cached separately (e.g., per-command state that tracks per-callback validation).
        // Get or create state for this command/callback combination.
        var cacheKey = new CommandValidationCacheKey(command, annotation.ValidationCallback);
        var state = _commandStates.GetOrAdd(cacheKey, _ => new CommandValidationState());

        await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            // If validation already failed for this command, just log and return the cached failure
            if (state.ErrorMessage is not null)
            {
                _logger.LogWarning("Resource '{ResourceName}' may fail to start: {Message}", resource.Name, state.ErrorMessage);
                return RequiredCommandValidationResult.Failure(state.ErrorMessage);
            }

            // Check if already validated successfully
            if (state.ResolvedPath is not null)
            {
                _logger.LogDebug("Required command '{Command}' for resource '{ResourceName}' already validated, resolved to '{ResolvedPath}'.", command, resource.Name, state.ResolvedPath);
                return RequiredCommandValidationResult.Success();
            }

            // Perform validation
            var resolved = ResolveCommand(command);
            var isValid = true;
            string? validationMessage = null;

            if (resolved is not null && annotation.ValidationCallback is not null)
            {
                var context = new RequiredCommandValidationContext(resolved, _serviceProvider, cancellationToken);
                var result = await annotation.ValidationCallback(context).ConfigureAwait(false);
                isValid = result.IsValid;
                validationMessage = result.ValidationMessage;
            }

            if (resolved is null || !isValid)
            {
                var link = annotation.HelpLink;

                // Build the message for logging and exceptions (includes inline link if available)
                var message = (link, validationMessage) switch
                {
                    (null, not null) => validationMessage,
                    (not null, not null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandValidationFailedWithLink, command, validationMessage, link),
                    (not null, null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFoundWithLink, command, link),
                    _ => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFound, command)

Comment on lines 24 to 65
@@ -59,8 +59,9 @@ public async Task<RequiredCommandValidationResult> ValidateAsync(
throw new InvalidOperationException($"Required command on resource '{resource.Name}' cannot be null or empty.");
}

// Get or create state for this command
var state = _commandStates.GetOrAdd(command, _ => new CommandValidationState());
// Get or create state for this command/callback combination.
var cacheKey = new CommandValidationCacheKey(command, annotation.ValidationCallback);
var state = _commandStates.GetOrAdd(cacheKey, _ => new CommandValidationState());

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the command name have been case insensitive in the first place? Yes on windows commands are case insensitive, but that's not true for linux/mac.

Comment on lines +205 to +214
internal static async Task<RequiredCommandValidationResult> ValidateDotnetSdkVersionAsync(RequiredCommandValidationContext _, string workingDirectory)
{
if (version is null)
{
// This most likely means something is majorly wrong with the dotnet sdk
// Which will show up as an error once dcp runs `dotnet tool exec`
return;
}
var version = await DotnetSdkUtils.TryGetVersionAsync(workingDirectory).ConfigureAwait(false);

if (version.Major < 10)
if (version?.Major < 10)
{
throw new DistributedApplicationException($"DotnetToolResource requires dotnet SDK 10 or higher to run. Detected version: {version} for working directory {workingDirectory}");
return RequiredCommandValidationResult.Failure($"DotnetToolResource requires dotnet SDK 10 or higher to run. Detected version: {version}");
}

return RequiredCommandValidationResult.Success();
Comment on lines +186 to +190
private readonly record struct CommandValidationCacheKey(string command, Func<RequiredCommandValidationContext, Task<RequiredCommandValidationResult>>? callback)
{
public string Command { get; } = command;

public Func<RequiredCommandValidationContext, Task<RequiredCommandValidationResult>>? Callback { get; } = callback;
- Migrated `dotnet tool` version validation to use `RequiredCommandValidator`
- Updated `RequiredCommandValidator` to include the callback in teh cache key, so that dotnet tools resources with different working directories are evaluated separately.
@afscrome afscrome force-pushed the dotnettool-requiredcommand branch from 1cdb197 to 27010fc Compare March 15, 2026 22:26
@afscrome afscrome changed the base branch from main to release/13.2 March 15, 2026 22:28
@afscrome afscrome closed this Mar 15, 2026
@afscrome afscrome reopened this Mar 15, 2026
@dotnet-policy-service dotnet-policy-service bot added this to the 13.2 milestone Mar 15, 2026
@afscrome afscrome closed this Mar 15, 2026
@dotnet-policy-service dotnet-policy-service bot modified the milestone: 13.2 Mar 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Should .Net 10 sdk checks be moved to WithRequiredCommand

2 participants