diff --git a/Aspire.slnx b/Aspire.slnx index 2bcb3d8dc11..cc3f5853484 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -357,6 +357,10 @@ + + + + diff --git a/playground/ResourceSubstitution/.vscode/launch.json b/playground/ResourceSubstitution/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/ResourceSubstitution/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs b/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs new file mode 100644 index 00000000000..d36d21fe5bd --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs @@ -0,0 +1,15 @@ +using System.Runtime.InteropServices; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +var app = builder.Build(); + +app.MapGet("/", () => $""" + 👋🌍 + 🏷️ Host: {Environment.MachineName} + 💻 OS: { RuntimeInformation.OSDescription } + 🪪 PID: {Environment.ProcessId} + """); + +app.Run(); \ No newline at end of file diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json b/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json new file mode 100644 index 00000000000..a16ae47d2d1 --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5450;http://localhost:5451", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:5451", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj b/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj new file mode 100644 index 00000000000..3b820cba91a --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + \ No newline at end of file diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs new file mode 100644 index 00000000000..c7c4194fcea --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs @@ -0,0 +1,51 @@ +#pragma warning disable ASPIRECSHARPAPPS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +var builder = DistributedApplication.CreateBuilder(args); + +var projectPath = new Projects.ResourceSubstitution_Api().ProjectPath; + +// Normal project resource +// Overriding ports from launch settings to avoid conflicting with the substituted resource +builder.AddCSharpApp("project", projectPath) + .WithEndpoint("http", x => x.Port = null) + .WithEndpoint("https", x => x.Port = null) + .WithHttpHealthCheck(); + +//A regular container resource +builder.AddContainer("container", "aspire/resourcesubstitution.apphost/container-from-project") + .WithImageTag("aspire-image-build") + .WithHttpEndpoint(targetPort: 8080, env: "ASPNETCORE_HTTP_PORTS") + .WithHttpsEndpoint(targetPort: 8443, env: "ASPNETCORE_HTTPS_PORTS") + .WithHttpHealthCheck() + .WithHttpsCertificateConfiguration(ctx => + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath; + ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath; + if (ctx.Password is not null) + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + } + + return Task.CompletedTask; + }); + +// A container resource, we change to running a project +// Including picking up ports from launch settings +builder.AddContainer("project-from-container", "doesnt-matter") + .RunAsProject(projectPath) + .WithHttpHealthCheck(); + +// A project resource that we run as a container +// Overriding ports as the launch settings port so as to not conflict with `project-from-container` +builder.AddProject("container-from-project") + .WithEndpoint("http", x => x.Port = null) + .WithEndpoint("https", x => x.Port = null) + .WithHttpHealthCheck() + .RunAsContainer(); + +// A project resource that we run as a .NET tool +builder.AddProject("tool-from-project") + .RunAsTool(); + +builder.Build().Run(); \ No newline at end of file diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs new file mode 100644 index 00000000000..5c23fc6a71b --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs @@ -0,0 +1,295 @@ +#pragma warning disable ASPIRECSHARPAPPS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECONTAINERRUNTIME001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using Aspire.Dashboard.Model; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +public static class Extensions +{ + public static IResourceBuilder RunAsContainer(this IResourceBuilder builder) + where T : ProjectResource + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + var imagePublisher = AddContainerPublisher(); + + TransmuteResourceAnnotations(); + FixEndpoints(); + + return builder + .WaitForCompletion(imagePublisher) + .WithDotnetContainerDefaults() + .WithInitialState(new CustomResourceSnapshot { ResourceType = KnownResourceTypes.Container, Properties = [] }); + + void TransmuteResourceAnnotations() + { + if (!builder.Resource.TryGetLastAnnotation(out var projectMetadata)) + { + throw new InvalidOperationException("RunAsContainer can only be used on resources with project metadata."); + } + builder.Resource.Annotations.Remove(projectMetadata); + + if (builder.Resource.TryGetLastAnnotation(out var executableAnnotation)) + { + builder.Resource.Annotations.Remove(executableAnnotation); + } + + var appHostName = builder.ApplicationBuilder.AppHostAssembly!.GetName().Name!.ToLowerInvariant(); + builder.WithAnnotation(new ContainerImageAnnotation + { + Image = $"aspire/{appHostName}/{builder.Resource.Name}", + Tag = "aspire-image-build", + Registry = "" // Use local registry + }, ResourceAnnotationMutationBehavior.Replace); + } + + // As a project, the target port is left null. + // For executables, DCP will allocate it's own port if the target port is null + // This does not happen for containers, so we give those endpoints explicit ports + void FixEndpoints() + { + //TODO: logic isn't complete - this doesn't currently consider Kestrel endpoint configuration. + var http = builder.GetEndpoint("http"); + if (http.Exists && http.EndpointAnnotation.TargetPort is null) + { + http.EndpointAnnotation.TargetPort = 8000; + builder.WithEnvironment("ASPNETCORE_HTTP_PORTS", http.Property(EndpointProperty.TargetPort)); + } + + var https = builder.GetEndpoint("https"); + if (https.Exists && https.EndpointAnnotation.TargetPort is null) + { + https.EndpointAnnotation.TargetPort = 8443; + builder.WithEnvironment("ASPNETCORE_HTTPS_PORTS", https.Property(EndpointProperty.TargetPort)); + } + + // `ASPNETCORE_URLS` typically has `localhost` as the host + // But for containers, we need to bind to all interfaces so the tunnel can access it + builder.WithEnvironment(ctx => ctx.EnvironmentVariables.Remove("ASPNETCORE_URLS")); + } + + // This could potentially use `IResourceContainerImageManager` instead, but this mirrors + // the tool publishing approach, and is easier to troubleshoot errors in run mode. + IResourceBuilder AddContainerPublisher() + { + return builder.ApplicationBuilder.AddExecutable($"{builder.Resource.Name}-publisher", "dotnet", ".") + .WithArgs("publish", builder.Resource.GetProjectMetadata().ProjectPath, "/t:PublishContainer") + .WithIconName("BoxToolbox") + .WithParentRelationship(builder) + .WaitForContainerRuntime() + .WithArgs(ctx => + { + // Lazy set the image metadata in case someone mutates the image annotation + if (builder.Resource.TryGetLastAnnotation(out var imageAnnotation)) + { + ctx.Args.Add($"/p:ContainerRepository=\"{imageAnnotation.Image}\""); + ctx.Args.Add($"/p:ContainerImageTags=\"{imageAnnotation.Tag}\""); + ctx.Args.Add($"/p:ContainerRegistry=\"{imageAnnotation.Registry}\""); + } + }); + } + } + + public static IResourceBuilder RunAsProject(this IResourceBuilder builder, string projectPath) + where T : ContainerResource + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + TransmuteAnnotations(); + FixEndpoints(); + return builder; + + void TransmuteAnnotations() + { + if (builder.Resource.TryGetLastAnnotation(out var containerAnnotation)) + { + builder.Resource.Annotations.Remove(containerAnnotation); + } + if (builder.Resource.TryGetLastAnnotation(out var executableAnnotation)) + { + builder.Resource.Annotations.Remove(executableAnnotation); + } + if (builder.Resource.TryGetLastAnnotation(out var dotnetToolAnnotation)) + { + builder.Resource.Annotations.Remove(dotnetToolAnnotation); + } + + // For now, create a dummy csharp app resource, then copy it's annotations to our new resource + // + // Exposing ProjectResourceBuilderExtensions.WithProjectDefaults may be a cleaner approach in the long run + // And making it usable on any `IResource` + var newProject = builder.ApplicationBuilder.AddCSharpApp($"temp-{Guid.NewGuid()}", projectPath); + builder.ApplicationBuilder.Resources.Remove(newProject.Resource); + + // TODO: A clever merge approach may be needed here + foreach (var annotation in newProject.Resource.Annotations) + { + builder.Resource.Annotations.Add(annotation); + } + } + + void FixEndpoints() + { + // The endpoint references on the temp project resource have a reference back to the temp resource + // Which will never become available. + // If using `WithProjectDefaults`, this should no longer not be necessary + builder.WithEnvironment(ctx => + { + ctx.EnvironmentVariables.Remove("ASPNETCORE_URLS"); + + foreach (var endpointName in new[] { "http", "https" }) + { + var endpoint = builder.GetEndpoint(endpointName); + if (endpoint.Exists) + { + ctx.EnvironmentVariables[$"ASPNETCORE_{endpointName.ToUpperInvariant()}_PORTS"] = endpoint.Property(EndpointProperty.TargetPort); + } + } + }); + } + } + + // This is a slightly silly example - not sure why you'd ever want to run a project as a tool + // But it helps to prove the model out. + public static IResourceBuilder RunAsTool(this IResourceBuilder builder) + where T : ProjectResource + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + var toolPublisher = AddToolPublisher(); + + TransmuteResource(); + + return builder + .WaitForCompletion(toolPublisher); + + void TransmuteResource() + { + if (builder.Resource.TryGetLastAnnotation(out var projectMetadataAnnotation)) + { + builder.Resource.Annotations.Remove(projectMetadataAnnotation); + } + if (builder.Resource.TryGetLastAnnotation(out var containerImageAnnotation)) + { + builder.Resource.Annotations.Remove(containerImageAnnotation); + } + + // again, rather than copy + var newTool = builder.ApplicationBuilder.AddDotnetTool($"temp-{Guid.NewGuid()}", builder.Resource.Name) + .WithToolIgnoreExistingFeeds() + .WithToolPrerelease(); + + builder.ApplicationBuilder.Resources.Remove(newTool.Resource); + + foreach (var annotation in newTool.Resource.Annotations) + { + builder.Resource.Annotations.Add(annotation); + } + + builder.OnBeforeResourceStarted((resource, evt, ct) => + { + var outputPath = GetToolPackageOutputPath(evt.Services); + newTool.WithToolSource(outputPath); + return Task.CompletedTask; + }); + } + + IResourceBuilder AddToolPublisher() + { + var projectPath = builder.Resource.GetProjectMetadata().ProjectPath; + return builder.ApplicationBuilder.AddExecutable($"{builder.Resource.Name}-tool-publisher", "dotnet", ".") + .WithArgs(ctx => + { + ctx.Args.Add("pack"); + ctx.Args.Add(projectPath); + ctx.Args.Add("--no-build"); + ctx.Args.Add("-p:IsPackable=true"); + ctx.Args.Add("-p:PackAsTool=true"); + ctx.Args.Add($"-p:PackageId=\"{builder.Resource.Name}\""); + ctx.Args.Add("--output"); + ctx.Args.Add(GetToolPackageOutputPath(ctx.ExecutionContext.ServiceProvider)); + }) + .WithIconName("BoxToolbox") + .WithParentRelationship(builder); + } + + static string GetToolPackageOutputPath(IServiceProvider services) + { + var aspireStore = services.GetRequiredService(); + + var toolPackageOutputPath = Path.Combine(aspireStore.BasePath, "tools"); + Directory.CreateDirectory(toolPackageOutputPath); + + return toolPackageOutputPath; + } + } + + private static IResourceBuilder WithDotnetContainerDefaults(this IResourceBuilder builder) + where T : IResourceWithEnvironment, IResourceWithArgs + { + return builder + .WithDeveloperCertificateTrust(true) + .WithHttpsDeveloperCertificate() + .WithHttpsCertificateConfiguration(ctx => + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath; + ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath; + if (ctx.Password is not null) + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + } + + return Task.CompletedTask; + }); + } + + private static IResourceBuilder WaitForContainerRuntime(this IResourceBuilder builder) + where T : IResource + { + return builder.OnBeforeResourceStarted(async (resource, evt, ct) => + { + var runtimeResolver = evt.Services.GetRequiredService(); + + var runtime = await runtimeResolver.ResolveAsync(ct); + + var isRunning = await runtime.CheckIfRunningAsync(ct); + + if (isRunning) + { + return; + } + + ResourceStateSnapshot? beforeWaitState = null; + var rns = evt.Services.GetRequiredService(); + await rns.PublishUpdateAsync(resource, x => + { + beforeWaitState = x.State; + return x with { State = KnownResourceStates.RuntimeUnhealthy }; + }); + + var logger = evt.Services.GetRequiredService().GetLogger(resource); + logger.LogInformation("Waiting for container runtime {RuntimeName} to be available...", runtime.Name); + + while (!isRunning) + { + await Task.Delay(TimeSpan.FromSeconds(1), ct); + isRunning = await runtime.CheckIfRunningAsync(ct); + } + + await rns.PublishUpdateAsync(resource, x => x with { State = beforeWaitState }); + }); + } +} \ No newline at end of file diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..7ddadf3da6c --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://substitution.dev.localhost:15406;http://substitution.dev.localhost:15407", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://substitution.dev.localhost:15407", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://substitution.dev.localhost:15407", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj new file mode 100644 index 00000000000..818590fbb73 --- /dev/null +++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/playground/ResourceSubstitution/aspire.config.json b/playground/ResourceSubstitution/aspire.config.json new file mode 100644 index 00000000000..255dc93a756 --- /dev/null +++ b/playground/ResourceSubstitution/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs index 26b76f7eae2..25bc2996f18 100644 --- a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs @@ -88,7 +88,7 @@ internal static void AddLifeCycleCommands(this IResource resource) // Use a more detailed description for .NET projects to help AI understand // that source code changes won't take effect until rebuilding the project. - var restartDescription = resource is ProjectResource + var restartDescription = resource.TryGetProjectAnnotation(out _) ? CommandStrings.RestartProjectDescription : CommandStrings.RestartDescription; @@ -122,13 +122,9 @@ internal static void AddLifeCycleCommands(this IResource resource) iconVariant: IconVariant.Regular, isHighlighted: false)); - if (resource is ProjectResource projectResource) + if (resource.TryGetProjectAnnotation(out var projectMetadata) && !projectMetadata.IsFileBasedApp) { - var projectMetadata = projectResource.Annotations.OfType().SingleOrDefault(); - if (projectMetadata is null || !projectMetadata.IsFileBasedApp) - { - AddRebuildCommand(projectResource); - } + AddRebuildCommand(resource); } // Treat "Unknown" as stopped so the command to start the resource is available when "Unknown". @@ -142,7 +138,7 @@ internal static void AddLifeCycleCommands(this IResource resource) static bool HasNoState(string? state) => string.IsNullOrEmpty(state); } - private static void AddRebuildCommand(ProjectResource projectResource) + private static void AddRebuildCommand(IResource projectResource) { // When a resource has replicas, the command framework invokes the handler // once per replica in parallel. We use a shared task so only a single build @@ -191,7 +187,7 @@ async Task ExecuteRebuildAndResetAsync(ExecuteCommandConte } } - private static async Task ExecuteRebuildAsync(ExecuteCommandContext context, ProjectResource projectResource) + private static async Task ExecuteRebuildAsync(ExecuteCommandContext context, IResource projectResource) { var orchestrator = context.ServiceProvider.GetRequiredService(); var resourceNotificationService = context.ServiceProvider.GetRequiredService(); diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 90b141f06b9..fd758408b12 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -293,7 +293,7 @@ private static async Task GetContainerWorkingDirectoryAsync(string proje private string DebuggerToString() { var path = ""; - if (this.TryGetLastAnnotation(out var metadata)) + if (this.TryGetProjectAnnotation(out var metadata)) { path = metadata.ProjectPath; } diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs index 27b79fabf6e..022dc6e97f7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting.ApplicationModel; /// @@ -34,4 +36,34 @@ public static IProjectMetadata GetProjectMetadata(this ProjectResource projectRe return projectResource.Annotations.OfType().Single(); } + + /// + /// Attempts to retrieve the last annotation from the specified resource. + /// + /// The resource to inspect. + /// When this method returns, contains the last annotation, if found; otherwise, . + /// if a project metadata annotation was found; otherwise, . + [AspireExportIgnore(Reason = "Project annotation inspection helper — not part of the ATS surface.")] + internal static bool TryGetProjectAnnotation(this IResource resource, [NotNullWhen(true)] out IProjectMetadata? projectMetadata) + { + return resource.TryGetLastAnnotation(out projectMetadata); + } + + /// + /// Returns resources that are project-like via . + /// + /// The distributed application model. + /// The project-like resources discovered in the model. + internal static IEnumerable GetProjectAnnotatedResources(this DistributedApplicationModel model) + { + ArgumentNullException.ThrowIfNull(model); + + foreach (var resource in model.Resources) + { + if (resource.TryGetProjectAnnotation(out _)) + { + yield return resource; + } + } + } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 8533253d008..96ad7f99009 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -820,7 +820,7 @@ public static IReadOnlyList ResolveEndpoints(this IResource re }; // Track HTTP schemes encountered for ProjectResources - if (resource is ProjectResource && IsHttpScheme(endpoint.UriScheme)) + if (resource.TryGetProjectAnnotation(out _) && IsHttpScheme(endpoint.UriScheme)) { httpSchemesEncountered.Add(endpoint.UriScheme); } @@ -957,7 +957,7 @@ public static bool RequiresImageBuild(this IResource resource) return false; } - return resource is ProjectResource || resource.TryGetLastAnnotation(out _); + return resource.TryGetProjectAnnotation(out _) || resource.TryGetLastAnnotation(out _); } /// @@ -1755,17 +1755,37 @@ private static void CollectHostUrlDependencies( /// /// Gets the resource type string for the specified resource. /// - internal static string GetResourceType(this IResource resource) => resource switch - { - ProjectResource => KnownResourceTypes.Project, - ContainerResource => KnownResourceTypes.Container, - ContainerExecutableResource => KnownResourceTypes.ContainerExec, - DotnetToolResource => KnownResourceTypes.Tool, - ExecutableResource => KnownResourceTypes.Executable, - ParameterResource => KnownResourceTypes.Parameter, - ConnectionStringResource => KnownResourceTypes.ConnectionString, - ExternalServiceResource => KnownResourceTypes.ExternalService, - _ => resource.GetType().Name - }; + internal static string GetResourceType(this IResource resource) + { + if (resource.TryGetProjectAnnotation(out _)) + { + return KnownResourceTypes.Project; + } + + if (resource.TryGetLastAnnotation(out _)) + { + return KnownResourceTypes.Container; + } + + if (resource.TryGetAnnotationsOfType(out _)) + { + return KnownResourceTypes.Tool; + } + + if (resource.TryGetExecutableAnnotation(out _)) + { + return resource is ContainerExecutableResource + ? KnownResourceTypes.ContainerExec + : KnownResourceTypes.Executable; + } + + return resource switch + { + ParameterResource => KnownResourceTypes.Parameter, + ConnectionStringResource => KnownResourceTypes.ConnectionString, + ExternalServiceResource => KnownResourceTypes.ExternalService, + _ => resource.GetType().Name + }; + } #pragma warning restore ASPIREDOTNETTOOL } diff --git a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs index 7a2ece308aa..2147c4346f0 100644 --- a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs +++ b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs @@ -24,7 +24,7 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C nameGenerator.EnsureDcpInstancesPopulated(container); } - foreach (var executable in beforeStartEvent.Model.GetExecutableResources()) + foreach (var executable in beforeStartEvent.Model.GetExecutableAnnotatedResources()) { nameGenerator.EnsureDcpInstancesPopulated(executable); } @@ -34,7 +34,7 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C nameGenerator.EnsureDcpInstancesPopulated(containerExec); } - foreach (var project in beforeStartEvent.Model.GetProjectResources()) + foreach (var project in beforeStartEvent.Model.GetProjectAnnotatedResources()) { nameGenerator.EnsureDcpInstancesPopulated(project); } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 58a6a649ebe..5c7dd3f6bd4 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -377,7 +377,7 @@ internal static string GetResourceType(T resource, IResource appModelResource return resource switch { Container => KnownResourceTypes.Container, - Executable => appModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable, + Executable => appModelResource.TryGetProjectAnnotation(out _) ? KnownResourceTypes.Project : KnownResourceTypes.Executable, ContainerExec => KnownResourceTypes.ContainerExec, _ => throw new InvalidOperationException($"Unknown resource type {resource.GetType().Name}") }; diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs index 4ef706d4a62..c4de06f47e6 100644 --- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs +++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs @@ -46,12 +46,7 @@ public void EnsureDcpInstancesPopulated(IResource resource) var (name, suffix) = GetContainerName(resource); AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]); } - else if (resource is ExecutableResource or ContainerExecutableResource) - { - var (name, suffix) = GetExecutableName(resource); - AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]); - } - else if (resource is ProjectResource) + else if (resource.TryGetProjectAnnotation(out _)) { var replicas = resource.GetReplicaCount(); var builder = ImmutableArray.CreateBuilder(replicas); @@ -62,6 +57,11 @@ public void EnsureDcpInstancesPopulated(IResource resource) } AddInstancesAnnotation(resource, builder.ToImmutable()); } + else if (resource is ContainerExecutableResource || resource.TryGetExecutableAnnotation(out _)) + { + var (name, suffix) = GetExecutableName(resource); + AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]); + } } private static void AddInstancesAnnotation(IResource resource, ImmutableArray instances) @@ -71,7 +71,7 @@ private static void AddInstancesAnnotation(IResource resource, ImmutableArray er, EmptyC private void PrepareProjectExecutables() { - var modelProjectResources = _model.GetProjectResources(); - - foreach (var project in modelProjectResources) + foreach (var project in _model.GetProjectAnnotatedResources()) { - if (!project.TryGetLastAnnotation(out var projectMetadata)) + if (!project.TryGetProjectAnnotation(out var projectMetadata)) { - throw new InvalidOperationException($"Project resource '{project.Name}' is missing required metadata."); // Should never happen. + throw new InvalidOperationException($"Project resource '{project.Name}' is missing required project metadata annotation, despite being returned from GetProjectAnnotatedResources()."); } EnsureRequiredAnnotations(project); @@ -292,18 +290,21 @@ private void PrepareProjectExecutables() private void PreparePlainExecutables() { - var modelExecutableResources = _model.GetExecutableResources(); - - foreach (var executable in modelExecutableResources) + foreach (var executable in _model.GetExecutableAnnotatedResources()) { + if (!executable.TryGetExecutableAnnotation(out var executableAnnotation)) + { + throw new InvalidOperationException($"Executable resource '{executable.Name}' is missing required executable annotation, despite being returned from GetExecutableAnnotatedResources()."); + } + EnsureRequiredAnnotations(executable); var exeInstance = DcpExecutor.GetDcpInstance(executable, instanceIndex: 0); - var exePath = executable.Command; + var exePath = executableAnnotation.Command; var exe = Executable.Create(exeInstance.Name, exePath); // The working directory is always relative to the app host project directory (if it exists). - exe.Spec.WorkingDirectory = executable.WorkingDirectory; + exe.Spec.WorkingDirectory = executableAnnotation.WorkingDirectory; exe.Annotate(CustomResource.OtelServiceNameAnnotation, executable.Name); exe.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, executable.GetOtelServiceInstanceId(exeInstance)); exe.Annotate(CustomResource.ResourceNameAnnotation, executable.Name); @@ -488,7 +489,7 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa } // If the executable is a project then include any command line args from the launch profile. - if (er.ModelResource is ProjectResource project) + if (er.ModelResource.TryGetProjectAnnotation(out _)) { // Args in the launch profile is used when: // 1. The project is run as an executable. Launch profile args are combined with app host supplied args. @@ -499,7 +500,8 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa // We still want to display the args in the dashboard so only add them to the custom arg annotations. var executableArg = spec.ExecutionType != ExecutionType.IDE; - var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile); + LaunchProfile? launchProfile = er.ModelResource.GetEffectiveLaunchProfile()?.LaunchProfile; + var launchProfileArgs = GetLaunchProfileArgs(launchProfile); if (launchProfileArgs.Count > 0 && appHostArgList.Count > 0) { // If there are app host args, add a double-dash to separate them from the launch args. @@ -509,7 +511,7 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa launchArgs.AddRange(launchProfileArgs.Select(a => CreateLaunchArgument(a, isSensitive: false, executableArg, display: true))); } } - else if (er.ModelResource is DotnetToolResource) + else if (er.ModelResource.TryGetAnnotationsOfType(out _)) { var argSeparator = appHostArgList.Select((a, i) => (index: i, value: a.Value)) .FirstOrDefault(x => x.value == DotnetToolResourceExtensions.ArgumentSeparator); @@ -547,7 +549,7 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi return true; } - private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(ProjectResource project, IProjectMetadata projectMetadata) + private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(IResource project, IProjectMetadata projectMetadata) { var projectLaunchConfiguration = new ProjectLaunchConfiguration(); projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; @@ -555,7 +557,7 @@ private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(ProjectResou ?? (Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug); projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); - // Use the effective launch profile which has fallback logic + if (!projectLaunchConfiguration.DisableLaunchProfile && project.GetEffectiveLaunchProfile() is NamedLaunchProfile namedLaunchProfile) { projectLaunchConfiguration.LaunchProfile = namedLaunchProfile.Name; diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 39856c8d2ed..f6b67bf1b81 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -136,10 +136,10 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn if (executable.AppModelResourceName is not null && _resourceState.ApplicationModel.TryGetValue(executable.AppModelResourceName, out appModelResource)) { - if (appModelResource is ProjectResource projectResource) + if (appModelResource.TryGetProjectAnnotation(out var projectMetadata)) { - projectPath = projectResource.GetProjectMetadata().ProjectPath; - launchProfileName = projectResource.GetEffectiveLaunchProfile()?.Name; + projectPath = projectMetadata.ProjectPath; + launchProfileName = appModelResource.GetEffectiveLaunchProfile()?.Name; } } diff --git a/src/Aspire.Hosting/ExecutableResourceExtensions.cs b/src/Aspire.Hosting/ExecutableResourceExtensions.cs index 3f9766081a8..3abbe8d6483 100644 --- a/src/Aspire.Hosting/ExecutableResourceExtensions.cs +++ b/src/Aspire.Hosting/ExecutableResourceExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting; @@ -22,4 +23,34 @@ public static IEnumerable GetExecutableResources(this Distri return model.Resources.OfType(); } + + /// + /// Returns resources that are executable via . + /// + /// The distributed application model to inspect. + /// The executable resources discovered in the model. + internal static IEnumerable GetExecutableAnnotatedResources(this DistributedApplicationModel model) + { + ArgumentNullException.ThrowIfNull(model); + + foreach (var resource in model.Resources) + { + if (!resource.TryGetProjectAnnotation(out _) && resource.TryGetExecutableAnnotation(out _)) + { + yield return resource; + } + } + } + + /// + /// Attempts to retrieve the last from the specified resource. + /// + /// The resource to inspect. + /// When this method returns, contains the last , if found; otherwise, . + /// if an executable annotation was found; otherwise, . + [AspireExportIgnore(Reason = "Executable annotation inspection helper — not part of the ATS surface.")] + internal static bool TryGetExecutableAnnotation(this IResource resource, [NotNullWhen(true)] out ExecutableAnnotation? executableAnnotation) + { + return resource.TryGetLastAnnotation(out executableAnnotation); + } } diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 67458641902..dafd16b5549 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -723,8 +723,8 @@ private async Task PublishEventToHierarchy(Func creat // resources that may want to have their own lifetime, that this code will be unaware of. private static bool ResourceHasOwnLifetime(IResource resource) => resource.IsContainer() || - resource is ProjectResource || - resource is ExecutableResource || + resource.TryGetProjectAnnotation(out _) || + resource.TryGetExecutableAnnotation(out _) || resource is ParameterResource || resource is ConnectionStringResource || resource is ExternalServiceResource; diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index d012fc0b7a4..f2c9d0ccb97 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -174,7 +174,7 @@ private Task WriteConnectionStringAsync(IResourceWithConnectionString resource) private async Task WriteProjectAsync(ProjectResource project) { - if (!project.TryGetLastAnnotation(out var metadata)) + if (!project.TryGetProjectAnnotation(out var metadata)) { throw new DistributedApplicationException($"Project metadata was not found for resource '{project.Name}'."); } diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index f4782b00e96..8b3d0b52a94 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -259,7 +259,7 @@ public async Task BuildImageAsync(IResource resource, CancellationToken cancella logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name); } - if (resource is ProjectResource) + if (resource.TryGetProjectAnnotation(out _)) { // If it is a project resource we need to build the container image // using the .NET SDK. @@ -327,7 +327,7 @@ private async Task BuildProjectContainerImageAsync(IResource resource, ResolvedC private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedContainerBuildOptions options, CancellationToken cancellationToken) { // This is a resource project so we'll use the .NET SDK to build the container image. - if (!resource.TryGetLastAnnotation(out var projectMetadata)) + if (!resource.TryGetProjectAnnotation(out var projectMetadata)) { throw new DistributedApplicationException($"The resource '{resource.Name}' does not have a project metadata annotation."); } diff --git a/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs b/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs index c42d914c5ba..5d208f9ca8b 100644 --- a/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs +++ b/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs @@ -30,16 +30,16 @@ internal static class LaunchProfileExtensions return projectMetadata.GetLaunchSettings(projectResource.Name); } - internal static NamedLaunchProfile? GetEffectiveLaunchProfile(this ProjectResource projectResource, bool throwIfNotFound = false) + internal static NamedLaunchProfile? GetEffectiveLaunchProfile(this IResource resource, bool throwIfNotFound = false) { - string? launchProfileName = projectResource.SelectLaunchProfileName(); + string? launchProfileName = resource.SelectLaunchProfileName(); if (string.IsNullOrEmpty(launchProfileName)) { return null; } - var launchProfile = projectResource.GetLaunchProfile(launchProfileName, throwIfNotFound); - if (launchProfile == null) + var launchProfile = resource.GetLaunchProfile(launchProfileName, throwIfNotFound); + if (launchProfile is null) { return null; } @@ -47,9 +47,14 @@ internal static class LaunchProfileExtensions return new NamedLaunchProfile(launchProfileName, launchProfile); } - internal static LaunchProfile? GetLaunchProfile(this ProjectResource projectResource, string launchProfileName, bool throwIfNotFound = false) + internal static LaunchProfile? GetLaunchProfile(this IResource resource, string launchProfileName, bool throwIfNotFound = false) { - var profiles = projectResource.GetLaunchSettings()?.Profiles; + if (!resource.TryGetLastAnnotation(out var projectMetadata)) + { + return null; + } + + var profiles = projectMetadata.GetLaunchSettings(resource.Name)?.Profiles; if (profiles is null) { return null; @@ -118,9 +123,9 @@ internal static class LaunchProfileExtensions TrySelectLaunchProfileByOrder ]; - private static bool TrySelectLaunchProfileByOrder(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName) + private static bool TrySelectLaunchProfileByOrder(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName) { - var launchSettings = GetLaunchSettings(projectResource); + var launchSettings = projectMetadata.GetLaunchSettings(resource.Name); if (launchSettings == null || launchSettings.Profiles.Count == 0) { @@ -142,16 +147,16 @@ private static bool TrySelectLaunchProfileByOrder(ProjectResource projectResourc return false; } - private static bool TrySelectLaunchProfileFromDefaultAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName) + private static bool TrySelectLaunchProfileFromDefaultAnnotation(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName) { - if (!projectResource.TryGetLastAnnotation(out var launchProfileAnnotation)) + if (!resource.TryGetLastAnnotation(out var launchProfileAnnotation)) { launchProfileName = null; return false; } var appHostDefaultLaunchProfileName = launchProfileAnnotation.LaunchProfileName; - var launchSettings = GetLaunchSettings(projectResource); + var launchSettings = projectMetadata.GetLaunchSettings(resource.Name); if (launchSettings == null) { launchProfileName = null; @@ -168,9 +173,9 @@ private static bool TrySelectLaunchProfileFromDefaultAnnotation(ProjectResource return true; } - private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName) + private static bool TrySelectLaunchProfileFromAnnotation(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName) { - if (projectResource.TryGetLastAnnotation(out var launchProfileAnnotation)) + if (resource.TryGetLastAnnotation(out var launchProfileAnnotation)) { launchProfileName = launchProfileAnnotation.LaunchProfileName; return true; @@ -182,17 +187,26 @@ private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource project } } - internal static string? SelectLaunchProfileName(this ProjectResource projectResource) + internal static string? SelectLaunchProfileName(this IResource resource) + { + if (!resource.TryGetLastAnnotation(out var projectMetadata)) + { + return null; + } + return SelectLaunchProfileName(resource, projectMetadata); + } + + private static string? SelectLaunchProfileName(IResource resource, IProjectMetadata projectMetadata) { // ExcludeLaunchProfileAnnotation takes precedence over all other launch profile selectors. - if (projectResource.TryGetLastAnnotation(out _)) + if (resource.TryGetLastAnnotation(out _)) { return null; } foreach (var launchProfileSelector in s_launchProfileSelectors) { - if (launchProfileSelector(projectResource, out var launchProfile)) + if (launchProfileSelector(resource, projectMetadata, out var launchProfile)) { return launchProfile; } @@ -204,4 +218,4 @@ private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource project internal sealed record class NamedLaunchProfile(string Name, LaunchProfile LaunchProfile); -internal delegate bool LaunchProfileSelector(ProjectResource project, out string? launchProfile); +internal delegate bool LaunchProfileSelector(IResource resource, IProjectMetadata projectMetadata, out string? launchProfile); diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index cfb5625ded8..ee22230f751 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -154,10 +154,7 @@ public async Task AddPythonApp_SetsResourcePropertiesCorrectly() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) - var pythonProjectResource = executableResources.OfType().Single(); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -193,10 +190,7 @@ public async Task AddPythonApp_ObsoleteMethod_StillWorks() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) - var pythonProjectResource = executableResources.OfType().Single(); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -236,10 +230,7 @@ public async Task AddPythonAppWithScriptArgs_IncludesTheArguments() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) - var pythonProjectResource = executableResources.OfType().Single(); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -412,9 +403,7 @@ public async Task WithVirtualEnvironment_UpdatesCommandToUseNewVirtualEnvironmen var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); @@ -445,9 +434,7 @@ public async Task WithVirtualEnvironment_SupportsAbsolutePath() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); if (OperatingSystem.IsWindows()) { @@ -506,9 +493,7 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance); Assert.Equal(3, commandArguments.Count); @@ -536,9 +521,7 @@ public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); // Should use the app directory .venv since it exists there var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path)); @@ -572,9 +555,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); // Should use the AppHost directory .venv since it only exists there AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command); @@ -621,9 +602,7 @@ public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); // Should prefer the app directory .venv when it exists in both locations AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command); @@ -655,9 +634,7 @@ public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); // Should default to app directory when it doesn't exist in either location var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path)); @@ -698,9 +675,7 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); // Should use the explicitly specified path, NOT the AppHost .venv AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command); @@ -981,9 +956,7 @@ public async Task AddPythonApp_SetsCorrectCommandAndArguments() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pythonProject", pythonProjectResource.Name); @@ -1017,9 +990,7 @@ public async Task AddPythonModule_SetsCorrectCommandAndArguments() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); @@ -1051,9 +1022,7 @@ public async Task AddPythonExecutable_SetsCorrectCommandAndArguments() var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var executableResources = appModel.GetExecutableResources(); - - var pythonProjectResource = Assert.Single(executableResources.OfType()); + var pythonProjectResource = Assert.Single(appModel.Resources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 640055e77c6..bbeee13a588 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3195,6 +3195,111 @@ public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); } + [Fact] + public async Task AnnotatedExecutableResource_IsCreatedFromExecutableAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddResource(new PlainResource("annotatedExecutable")) + .WithAnnotation(new ExecutableAnnotation + { + Command = "annotated-command", + WorkingDirectory = "annotated-working-directory" + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var exe = GetCreatedExecutableForResource(kubernetesService, "annotatedExecutable"); + Assert.Equal("annotated-command", exe.Spec.ExecutablePath); + Assert.Equal("annotated-working-directory", exe.Spec.WorkingDirectory); + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + } + + [Fact] + public async Task AnnotatedProjectResource_IsCreatedFromProjectMetadataAndNotAsPlainExecutable() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddResource(new PlainResource("annotatedProject")) + .WithAnnotation(new TestProject()) + .WithAnnotation(new ExecutableAnnotation + { + Command = "should-not-be-used", + WorkingDirectory = "ignored-working-directory" + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "annotatedProject")); + Assert.Equal("dotnet", exe.Spec.ExecutablePath); + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + Assert.NotNull(exe.Spec.Args); + Assert.Contains("run", exe.Spec.Args!); + Assert.Contains("--project", exe.Spec.Args!); + Assert.Contains("TestProject", exe.Spec.Args!); + Assert.Contains("--no-launch-profile", exe.Spec.Args!); + } + + [Fact] + public async Task ContainerResource_WithContainerAnnotationRemovedAndExecutableAnnotationAdded_CreatesExecutableNotContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var resource = builder.AddContainer("mutated-container", "image"); + resource.Resource.Annotations.Remove(resource.Resource.Annotations.OfType().Single()); + resource.WithAnnotation(new ExecutableAnnotation + { + Command = "mutated-command", + WorkingDirectory = "mutated-working-directory" + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + var executable = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "mutated-container"); + Assert.Equal("mutated-command", executable.Spec.ExecutablePath); + Assert.Equal("mutated-working-directory", executable.Spec.WorkingDirectory); + Assert.DoesNotContain(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "mutated-container"); + } + + [Fact] + public async Task ExecutableResource_WithExecutableAnnotationRemovedAndContainerAnnotationAdded_CreatesContainerNotExecutable() + { + var builder = DistributedApplication.CreateBuilder(); + + var resource = builder.AddExecutable("mutated-executable", "old-command", "old-working-directory"); + resource.Resource.Annotations.Remove(resource.Resource.Annotations.OfType().Single()); + resource.WithAnnotation(new ContainerImageAnnotation + { + Image = "mutated-image", + Tag = "latest" + }); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService); + + await appExecutor.RunApplicationAsync(); + + Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "mutated-executable"); + Assert.DoesNotContain(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "mutated-executable"); + } + [Fact] public async Task ProjectExecutable_NoDebugSessionInfo_DefaultsToProjectSupport() { @@ -4983,6 +5088,8 @@ private static X509Certificate2 CreateTestCertificate() serialNumber); } + private sealed class PlainResource(string name) : Resource(name), IComputeResource; + private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory); private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory); diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs index f84cd892373..f2eb59b2bbd 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs @@ -92,6 +92,7 @@ public void RestartCommand_ProjectResource_HasDetailedDescription() // Arrange var builder = DistributedApplication.CreateBuilder(); var projectResource = new ProjectResource("testproject"); + projectResource.Annotations.Add(new ProjectMetadata("test.csproj")); projectResource.AddLifeCycleCommands(); // Act @@ -107,6 +108,7 @@ public void RestartCommand_CSharpAppResource_HasDetailedDescription() // Arrange var builder = DistributedApplication.CreateBuilder(); var csharpAppResource = new CSharpAppResource("testapp"); + csharpAppResource.Annotations.Add(new ProjectMetadata("testapp.cs")); csharpAppResource.AddLifeCycleCommands(); // Act @@ -132,6 +134,7 @@ public void RestartCommand_CSharpAppResource_HasDetailedDescription() public void RebuildCommand_CommandState(string commandName, string? resourceState, ResourceCommandState commandState) { var projectResource = new ProjectResource("testproject"); + projectResource.Annotations.Add(new ProjectMetadata("test.csproj")); projectResource.AddLifeCycleCommands(); var rebuildCommand = projectResource.Annotations.OfType().Single(a => a.Name == commandName); @@ -159,6 +162,7 @@ public void RebuildCommand_OnlyAddedToProjectResources() containerResource.Resource.AddLifeCycleCommands(); var projectResource = new ProjectResource("testproject"); + projectResource.Annotations.Add(new ProjectMetadata("test.csproj")); projectResource.AddLifeCycleCommands(); Assert.DoesNotContain(containerResource.Resource.Annotations.OfType(), a => a.Name == KnownResourceCommands.RebuildCommand); @@ -169,6 +173,7 @@ public void RebuildCommand_OnlyAddedToProjectResources() public void RebuildCommand_ProjectResource_HasDescription() { var projectResource = new ProjectResource("testproject"); + projectResource.Annotations.Add(new ProjectMetadata("test.csproj")); projectResource.AddLifeCycleCommands(); var rebuildCommand = projectResource.Annotations.OfType().Single(a => a.Name == KnownResourceCommands.RebuildCommand); diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index 8f4693e05ea..6fbf0189bdc 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -56,8 +56,8 @@ public async Task InitialSnapshotResourceTypeMatchesKnownResourceTypes(Type reso { IResource resource = resourceType.Name switch { - nameof(ProjectResource) => new ProjectResource("test"), - nameof(ContainerResource) => new ContainerResource("test"), + nameof(ProjectResource) => CreateProjectResource(), + nameof(ContainerResource) => CreateContainerResource(), nameof(ExecutableResource) => new ExecutableResource("test", "cmd", "."), nameof(ParameterResource) => new ParameterResource("test", _ => "value", secret: false), nameof(ConnectionStringResource) => new ConnectionStringResource("test", ReferenceExpression.Create($"connectionString")), @@ -85,6 +85,20 @@ public async Task InitialSnapshotResourceTypeMatchesKnownResourceTypes(Type reso Assert.NotNull(resourceEvent); Assert.Equal(expectedResourceType, resourceEvent.Snapshot.ResourceType); + + static ProjectResource CreateProjectResource() + { + var project = new ProjectResource("test"); + project.Annotations.Add(new ProjectMetadata("test.csproj")); + return project; + } + + static ContainerResource CreateContainerResource() + { + var container = new ContainerResource("test"); + container.Annotations.Add(new ContainerImageAnnotation { Image = "test" }); + return container; + } } [Fact]