From f30c3b0aec7f6d13eb87d2b98772fa3aa28b33aa Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:11:21 -0600 Subject: [PATCH 01/16] Start adding gradle support --- .../GradleBuildAnnotation.cs | 8 + .../GradleOptions.cs | 16 ++ .../JavaAppHostingExtension.Executable.cs | 189 +++++++++++------- .../JavaBuildOptions.cs | 20 ++ .../MavenOptions.cs | 20 +- .../README.md | 6 + .../GradleResourceCreationTests.cs | 184 +++++++++++++++++ 7 files changed, 355 insertions(+), 88 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs new file mode 100644 index 000000000..54d6c8452 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs @@ -0,0 +1,8 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +internal class GradleBuildAnnotation(GradleOptions gradleOptions) : IResourceAnnotation +{ + public GradleOptions GradleOptions { get; } = gradleOptions; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs new file mode 100644 index 000000000..b4f4a2900 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs @@ -0,0 +1,16 @@ +namespace Aspire.Hosting; + +/// +/// Represents the options for configuring a Gradle build step. +/// +public sealed class GradleOptions : JavaBuildOptions +{ + /// + /// Initializes a new instance of the class. + /// + public GradleOptions() + { + Command = "gradlew"; + Args = ["--quiet", "clean", "build"]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 2d53270ff..159469977 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -77,14 +77,51 @@ public static IResourceBuilder WithMavenBuild( { mavenOptions ??= new MavenOptions(); - if (mavenOptions.WorkingDirectory is null) - { - mavenOptions.WorkingDirectory = builder.Resource.WorkingDirectory; - } + mavenOptions.WorkingDirectory ??= builder.Resource.WorkingDirectory; + + return builder.WithJavaBuild( + new MavenBuildAnnotation(mavenOptions), + "Maven", + (resource, services, ct, useNotificationService) => + ExecuteBuild(resource, services, ct, useNotificationService, "Maven", mavenOptions)); + } + + /// + /// Adds a Gradle build step to the application model. + /// + /// The to add the Gradle build step to. + /// The to configure the Gradle build step. + /// A reference to the . + /// + /// This method adds a Gradle build step to the application model. The Gradle build step is executed before the Java application is started. + /// + /// The Gradle build step is added as an executable resource named "gradle" with the command "gradlew --quiet clean build". + /// + /// The Gradle build step is excluded from the manifest file. + /// + public static IResourceBuilder WithGradleBuild( + this IResourceBuilder builder, + GradleOptions? gradleOptions = null) + { + gradleOptions ??= new GradleOptions(); - var annotation = new MavenBuildAnnotation(mavenOptions); + gradleOptions.WorkingDirectory ??= builder.Resource.WorkingDirectory; - if (builder.Resource.TryGetLastAnnotation(out _)) + return builder.WithJavaBuild( + new GradleBuildAnnotation(gradleOptions), + "Gradle", + (resource, services, ct, useNotificationService) => + ExecuteBuild(resource, services, ct, useNotificationService, "Gradle", gradleOptions)); + } + + private static IResourceBuilder WithJavaBuild( + this IResourceBuilder builder, + TAnnotation annotation, + string buildSystemName, + Func> buildFunc) + where TAnnotation : IResourceAnnotation + { + if (builder.Resource.TryGetLastAnnotation(out _)) { // Replace the existing annotation, but don't continue on and subscribe to the event again. builder.WithAnnotation(annotation, ResourceAnnotationMutationBehavior.Replace); @@ -100,16 +137,16 @@ public static IResourceBuilder WithMavenBuild( return; } - await BuildWithMaven(javaAppResource, e.Services, ct).ConfigureAwait(false); + await buildFunc(javaAppResource, e.Services, ct, true).ConfigureAwait(false); }); builder.WithCommand( - "build-with-maven", - "Build with Maven", + $"build-with-{buildSystemName.ToLowerInvariant()}", + $"Build with {buildSystemName}", async (context) => - await BuildWithMaven(builder.Resource, context.ServiceProvider, context.CancellationToken, false).ConfigureAwait(false) ? + await buildFunc(builder.Resource, context.ServiceProvider, context.CancellationToken, false).ConfigureAwait(false) ? new ExecuteCommandResult { Success = true } : - new ExecuteCommandResult { Success = false, ErrorMessage = "Failed to build with Maven" }, + new ExecuteCommandResult { Success = false, ErrorMessage = $"Failed to build with {buildSystemName}" }, new CommandOptions() { IconName = "build", @@ -124,94 +161,94 @@ await BuildWithMaven(builder.Resource, context.ServiceProvider, context.Cancella }); return builder; + } - static async Task BuildWithMaven(JavaAppExecutableResource javaAppResource, IServiceProvider services, CancellationToken ct, bool useNotificationService = true) - { - if (!javaAppResource.TryGetLastAnnotation(out var mavenOptionsAnnotation)) - { - return false; - } - - var mavenOptions = mavenOptionsAnnotation.MavenOptions; - var logger = services.GetRequiredService().GetLogger(javaAppResource); - var notificationService = services.GetRequiredService(); + private static async Task ExecuteBuild( + JavaAppExecutableResource javaAppResource, + IServiceProvider services, + CancellationToken ct, + bool useNotificationService, + string buildSystemName, + JavaBuildOptions options) + { + var logger = services.GetRequiredService().GetLogger(javaAppResource); + var notificationService = services.GetRequiredService(); - if (useNotificationService) + if (useNotificationService) + { + await notificationService.PublishUpdateAsync(javaAppResource, state => state with { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new("Building Maven project", KnownResourceStates.Starting) - }).ConfigureAwait(false); - } + State = new($"Building {buildSystemName} project", KnownResourceStates.Starting) + }).ConfigureAwait(false); + } - logger.LogInformation("Building Maven project"); + logger.LogInformation("Building {BuildSystemName} project", buildSystemName); - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var mvnw = new Process + var process = new Process + { + StartInfo = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - FileName = isWindows ? "cmd" : "sh", - Arguments = isWindows ? $"/c {mavenOptions.Command} {string.Join(" ", mavenOptions.Args)}" : $"./{mavenOptions.Command} {string.Join(" ", mavenOptions.Args)}", - WorkingDirectory = mavenOptions.WorkingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - UseShellExecute = false, - } - }; + FileName = isWindows ? "cmd" : "sh", + Arguments = isWindows ? $"/c {options.Command} {string.Join(" ", options.Args)}" : $"./{options.Command} {string.Join(" ", options.Args)}", + WorkingDirectory = options.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + UseShellExecute = false, + } + }; - mvnw.OutputDataReceived += async (sender, args) => + process.OutputDataReceived += async (sender, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) { - if (!string.IsNullOrWhiteSpace(args.Data)) + if (useNotificationService) { - if (useNotificationService) + await notificationService.PublishUpdateAsync(javaAppResource, state => state with { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new(args.Data, KnownResourceStates.Starting) - }).ConfigureAwait(false); - } - - logger.LogInformation("{Data}", args.Data); + State = new(args.Data, KnownResourceStates.Starting) + }).ConfigureAwait(false); } - }; - mvnw.ErrorDataReceived += async (sender, args) => + logger.LogInformation("{Data}", args.Data); + } + }; + + process.ErrorDataReceived += async (sender, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) { - if (!string.IsNullOrWhiteSpace(args.Data)) + if (useNotificationService) { - if (useNotificationService) + await notificationService.PublishUpdateAsync(javaAppResource, state => state with { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new(args.Data, KnownResourceStates.FailedToStart) - }).ConfigureAwait(false); - } - - logger.LogError("{Data}", args.Data); + State = new(args.Data, KnownResourceStates.FailedToStart) + }).ConfigureAwait(false); } - }; - mvnw.Start(); - mvnw.BeginOutputReadLine(); - mvnw.BeginErrorReadLine(); + logger.LogError("{Data}", args.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); - await mvnw.WaitForExitAsync(ct).ConfigureAwait(false); + await process.WaitForExitAsync(ct).ConfigureAwait(false); - if (mvnw.ExitCode != 0) + if (process.ExitCode != 0) + { + // always use notification service to push out errors in the build + await notificationService.PublishUpdateAsync(javaAppResource, state => state with { - // always use notification service to push out errors in the maven build - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new($"mvnw exited with {mvnw.ExitCode}", KnownResourceStates.FailedToStart) - }).ConfigureAwait(false); + State = new($"{options.Command} exited with {process.ExitCode}", KnownResourceStates.FailedToStart) + }).ConfigureAwait(false); - return false; - } - return true; + return false; } + return true; } private static IResourceBuilder WithJavaDefaults( diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs new file mode 100644 index 000000000..5925a2d54 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs @@ -0,0 +1,20 @@ +namespace Aspire.Hosting; + +/// +/// Represents the base options for configuring a Java build step. +/// +public abstract class JavaBuildOptions +{ + /// + /// Gets or sets the working directory to use for the command. If null, the working directory of the current process is used. + /// + public string? WorkingDirectory { get; set; } + /// + /// Gets or sets the command to execute. + /// + public string Command { get; set; } = default!; + /// + /// Gets or sets the arguments to pass to the command. + /// + public string[] Args { get; set; } = []; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs index c45cb7f5d..60eaab184 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs @@ -3,18 +3,14 @@ namespace Aspire.Hosting; /// /// Represents the options for configuring a Maven build step. /// -public sealed class MavenOptions +public sealed class MavenOptions : JavaBuildOptions { /// - /// Gets or sets the working directory to use for the command. If null, the working directory of the current process is used. + /// Initializes a new instance of the class. /// - public string? WorkingDirectory { get; set; } - /// - /// Gets or sets the command to execute. Default is "mvnw". - /// - public string Command { get; set; } = "mvnw"; - /// - /// Gets or sets the arguments to pass to the command. Default is "--quiet clean package". - /// - public string[] Args { get; set; } = ["--quiet", "clean", "package"]; -} \ No newline at end of file + public MavenOptions() + { + Command = "mvnw"; + Args = ["--quiet", "clean", "package"]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index d89606ff1..57c2aa024 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -32,6 +32,12 @@ var executableapp = builder.AddSpringApp("executableapp", ApplicationName = "target/app.jar", OtelAgentPath = "../../../agents" }); + +// (Optional) Add a Maven build step +executableapp.WithMavenBuild(); + +// (Optional) Add a Gradle build step +executableapp.WithGradleBuild(); ``` ## Additional Information diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs new file mode 100644 index 000000000..919435c99 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs @@ -0,0 +1,184 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Java.Tests; + +public class GradleResourceCreationTests +{ + [Fact] + public void AddingGradleOptions() + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + + Assert.NotNull(annotation.GradleOptions); + Assert.Equal("gradlew", annotation.GradleOptions.Command); + Assert.Equal("--quiet clean build", string.Join(' ', annotation.GradleOptions.Args)); + Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); + } + + [Fact] + public void AddingGradleOptionsWithOverrides() + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(new() + { + Args = ["clean", "build"], + }); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + + Assert.NotNull(annotation.GradleOptions); + Assert.Equal("gradlew", annotation.GradleOptions.Command); + Assert.Equal("clean build", string.Join(' ', annotation.GradleOptions.Args)); + Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); + } + + [Fact] + public void ChainingAddGradleBuildOverridesPreviousOptions() + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(new() + { + Args = ["clean", "build"], + }) + .WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + + Assert.NotNull(annotation.GradleOptions); + Assert.Equal("gradlew", annotation.GradleOptions.Command); + Assert.Equal("--quiet clean build", string.Join(' ', annotation.GradleOptions.Args)); + Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); + } + + [Fact] + public void AddingGradleBuildRegistersRebuildCommand() + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); + } + + [Fact] + public void MultipleAddingGradleBuildRegistersSingleRebuildCommand() + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); + } + + [Theory] + [InlineData("Stopped", ResourceCommandState.Enabled)] + [InlineData("Finished", ResourceCommandState.Enabled)] + [InlineData("Exited", ResourceCommandState.Enabled)] + [InlineData("FailedToStart", ResourceCommandState.Enabled)] + [InlineData("Starting", ResourceCommandState.Disabled)] + [InlineData("Running", ResourceCommandState.Disabled)] + public void GradleBuildCommandAvailability(string text, ResourceCommandState expectedCommandState) + { + var builder = DistributedApplication.CreateBuilder(); + + var options = new JavaAppExecutableResourceOptions + { + ApplicationName = "test.jar", + Args = ["--test"], + OtelAgentPath = "otel-agent", + Port = 8080 + }; + + builder.AddJavaApp("java", Environment.CurrentDirectory, options) + .WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); + + var updateState = annotation.UpdateState(new UpdateCommandStateContext() + { + ResourceSnapshot = new CustomResourceSnapshot() + { + State = new ResourceStateSnapshot(text, null), + ResourceType = "JavaAppExecutableResource", + Properties = [] + }, + ServiceProvider = app.Services + }); + Assert.Equal(expectedCommandState, updateState); + } +} From 8da882abbffae3b2c6fa7e07189294b231f6a98c Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:23:13 -0600 Subject: [PATCH 02/16] Refactor the Java hosting integration to allow Aspire to manage the executable resources --- .../Program.cs | 27 +- .../GradleBuildAnnotation.cs | 8 - .../GradleBuildResource.cs | 9 + .../GradleOptions.cs | 16 - .../JavaAppContainerResource.cs | 8 +- .../JavaAppContainerResourceOptions.cs | 1 + .../JavaAppExecutableResource.cs | 13 +- .../JavaAppExecutableResourceOptions.cs | 1 + .../JavaAppHostingExtension.Container.cs | 42 +- .../JavaAppHostingExtension.Executable.cs | 373 ++++++++++-------- .../JavaBuildAnnotation.cs | 19 + .../JavaBuildOptions.cs | 1 + .../MavenBuildAnnotation.cs | 8 - .../MavenBuildResource.cs | 9 + .../MavenOptions.cs | 1 + .../README.md | 64 +-- .../ContainerResourceCreationTests.cs | 28 +- .../ExecutableResourceCreationTests.cs | 247 +++--------- .../GradleResourceCreationTests.cs | 173 +------- .../ResourceCreationTests.cs | 128 ++++++ 20 files changed, 569 insertions(+), 607 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildAnnotation.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildAnnotation.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs index 62f5a73d1..5b7376ba8 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs @@ -2,27 +2,16 @@ var apiapp = builder.AddProject("apiapp"); -var containerapp = builder.AddSpringApp("containerapp", - new JavaAppContainerResourceOptions() - { - ContainerImageName = "aliencube/aspire-spring-maven-sample", - OtelAgentPath = "/agents" - }); -var executableapp = builder.AddSpringApp("executableapp", +var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample", workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven") + .WithOtelAgent("/agents/opentelemetry-javaagent.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); + +var executableapp = builder.AddJavaApp("executableapp", workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven", - new JavaAppExecutableResourceOptions() - { - ApplicationName = "target/spring-maven-0.0.1-SNAPSHOT.jar", - Port = 8085, - OtelAgentPath = "../../../agents", - }) + jarPath: "target/spring-maven-0.0.1-SNAPSHOT.jar") .WithMavenBuild() - .PublishAsDockerFile(c => - { - c.WithBuildArg("JAR_NAME", "spring-maven-0.0.1-SNAPSHOT.jar") - .WithBuildArg("AGENT_PATH", "/agents") - .WithBuildArg("SERVER_PORT", "8085"); - }) + .WithOtelAgent("../../../agents/opentelemetry-javaagent.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT") .WithHttpHealthCheck("/health"); var webapp = builder.AddProject("webapp") diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs deleted file mode 100644 index 54d6c8452..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildAnnotation.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting; - -internal class GradleBuildAnnotation(GradleOptions gradleOptions) : IResourceAnnotation -{ - public GradleOptions GradleOptions { get; } = gradleOptions; -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs new file mode 100644 index 000000000..4e4ae739b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs @@ -0,0 +1,9 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Gradle build step. +/// +/// The name of the resource. +/// The working directory to use for the command. +public class GradleBuildResource(string name, string workingDirectory) + : ExecutableResource(name, "gradlew", workingDirectory); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs deleted file mode 100644 index b4f4a2900..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Java/GradleOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Aspire.Hosting; - -/// -/// Represents the options for configuring a Gradle build step. -/// -public sealed class GradleOptions : JavaBuildOptions -{ - /// - /// Initializes a new instance of the class. - /// - public GradleOptions() - { - Command = "gradlew"; - Args = ["--quiet", "clean", "build"]; - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs index dccc9cf7a..ea880665a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs @@ -4,10 +4,14 @@ /// A resource that represents a Java application. /// /// The name of the resource. +/// The working directory to use for the command. If null, the working directory of the current process is used. /// An optional container entrypoint. -public class JavaAppContainerResource(string name, string? entrypoint = null) - : ContainerResource(name, entrypoint), IResourceWithServiceDiscovery +public class JavaAppContainerResource(string name, string workingDirectory, string? entrypoint = null) + : ContainerResource(name, entrypoint), IResourceWithServiceDiscovery, IResourceWithWaitSupport { internal const string HttpEndpointName = "http"; + + /// + public string WorkingDirectory { get; } = workingDirectory; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResourceOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResourceOptions.cs index 52fe3a36f..7b88e35d0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResourceOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResourceOptions.cs @@ -3,6 +3,7 @@ /// /// This represents the options entity for configuring a Java application running in a container. /// +[Obsolete("This class will be removed in a future version.")] public class JavaAppContainerResourceOptions { /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs index c9726e2a4..13952f7d6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -4,10 +4,17 @@ /// A resource that represents a Java application. /// /// The name of the resource. -/// The command to execute. /// The working directory to use for the command. If null, the working directory of the current process is used. -public class JavaAppExecutableResource(string name, string command, string workingDirectory) - : ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery +public class JavaAppExecutableResource(string name, string workingDirectory) + : ExecutableResource(name, "java", workingDirectory), IResourceWithServiceDiscovery, IContainerFilesDestinationResource, IResourceWithWaitSupport { internal const string HttpEndpointName = "http"; + + /// + public string? ContainerFilesDestination => "/app/static"; + + /// + /// Gets or sets the path to the JAR file to execute. + /// + public string JarPath { get; set; } = "target/app.jar"; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResourceOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResourceOptions.cs index 3bf7f305b..70be04e93 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResourceOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResourceOptions.cs @@ -3,6 +3,7 @@ /// /// This represents the options entity for configuring an executable Java application. /// +[Obsolete("This class will be removed in a future version.")] public class JavaAppExecutableResourceOptions { /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index 51a69e21a..5ab63e446 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Utils; @@ -9,6 +8,32 @@ namespace Aspire.Hosting; /// public static partial class JavaAppHostingExtension { + /// + /// Adds a Java application to the application model. Executes the containerized Java app. + /// + /// The to add the resource to. + /// The name of the resource. + /// The container image name. + /// The working directory containing the Java application. + /// The container image tag. + /// A reference to the . + public static IResourceBuilder AddJavaContainerApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, + string image, string? workingDirectory = null, string? imageTag = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + ArgumentException.ThrowIfNullOrWhiteSpace(image, nameof(image)); + + workingDirectory ??= builder.AppHostDirectory; + + workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); + var resource = new JavaAppContainerResource(name, workingDirectory); + + return builder.AddResource(resource) + .WithImage(image, imageTag) + .WithOtelAgent(); + } + /// /// Adds a Java application to the application model. Executes the containerized Java app. /// @@ -16,6 +41,7 @@ public static partial class JavaAppHostingExtension /// The name of the resource. /// The to configure the Java application." /// A reference to the . + [Obsolete("Use AddJavaApp(string, string, string?, string[]?) instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -23,12 +49,12 @@ public static IResourceBuilder AddJavaApp(this IDistri ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); ArgumentException.ThrowIfNullOrWhiteSpace(options.ContainerImageName, nameof(options.ContainerImageName)); - var resource = new JavaAppContainerResource(name); + var resource = new JavaAppContainerResource(name, builder.AppHostDirectory); var rb = builder.AddResource(resource) .WithAnnotation(new ContainerImageAnnotation { Image = options.ContainerImageName, Tag = options.ContainerImageTag, Registry = options.ContainerRegistry }) .WithHttpEndpoint(port: options.Port, targetPort: options.TargetPort, name: JavaAppContainerResource.HttpEndpointName) - .WithJavaDefaults(options); + .WithJavaDefaults(otelAgentPath: options.OtelAgentPath); if (options.Args is { Length: > 0 }) { @@ -45,12 +71,6 @@ public static IResourceBuilder AddJavaApp(this IDistri /// The name of the resource. /// The to configure the Java application." /// A reference to the . - public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => - builder.AddJavaApp(name, options); - - private static IResourceBuilder WithJavaDefaults( - this IResourceBuilder builder, - JavaAppContainerResourceOptions options) => - builder.WithOtlpExporter() - .WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar"); + [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] + public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => builder.AddJavaApp(name, options); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 159469977..31175fa58 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -1,11 +1,10 @@ -using System.Diagnostics; using System.Globalization; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting; @@ -14,6 +13,85 @@ namespace Aspire.Hosting; /// public static partial class JavaAppHostingExtension { + /// + /// Adds a Java application to the application model. Executes the executable Java app. + /// + /// The to add the resource to. + /// The name of the resource. + /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The path to the jar file to execute. Default is target/app.jar. + /// The optional arguments to be passed to the executable when it is started. + /// A reference to the . + public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, + string? jarPath = null, string[]? args = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory, nameof(workingDirectory)); + + workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); + + var resource = new JavaAppExecutableResource(name, workingDirectory) + { + JarPath = jarPath ?? "target/app.jar" + }; + + return builder.AddResource(resource) + .WithOtelAgent() + .WithArgs(context => + { + context.Args.Add("-jar"); + context.Args.Add(resource.JarPath); + + if (args is { Length: > 0 }) + { + foreach (var arg in args) + { + context.Args.Add(arg); + } + } + }); + } + + /// + /// Configures the Java application to be published as a Dockerfile with automatic multi-stage build generation. + /// + /// The resource builder. + /// The working directory containing the Java application. + /// A reference to the . +#pragma warning disable ASPIREDOCKERFILEBUILDER001 + private static IResourceBuilder PublishAsJavaDockerfile( + this IResourceBuilder builder, + string workingDirectory) + { + return builder.PublishAsDockerFile(publish => + { + publish.WithDockerfileBuilder(workingDirectory, context => + { + if (!builder.Resource.TryGetLastAnnotation(out var buildAnnotation)) + { + buildAnnotation = new JavaBuildAnnotation("eclipse-temurin:25-jdk-alpine", "./mvnw clean package -DskipTests"); + } + + var buildStage = context.Builder + .From(buildAnnotation.BuildImage, "builder") + .WorkDir("/build") + .Copy(".", "./") + .Run(buildAnnotation.BuildCommand); + + var logger = context.Services.GetService>() ?? NullLogger.Instance; + + context.Builder + .From("eclipse-temurin:25-jre-alpine") + .Run("apk --no-cache add ca-certificates") + .WorkDir("/app") + .CopyFrom(buildStage.StageName!, $"/build/{builder.Resource.JarPath}", "/app/app.jar") + .Entrypoint(["java", "-jar", "/app/app.jar"]); + }); + }); + } +#pragma warning restore ASPIREDOCKERFILEBUILDER001 + /// /// Adds a Java application to the application model. Executes the executable Java app. /// @@ -22,6 +100,7 @@ public static partial class JavaAppHostingExtension /// The working directory to use for the command. If null, the working directory of the current process is used. /// The to configure the Java application." /// A reference to the . + [Obsolete("Use AddJavaApp(string, string, string?, string[]?, string[]?) instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -39,10 +118,10 @@ public static IResourceBuilder AddJavaApp(this IDistr allArgs = [.. options.JvmArgs, .. allArgs]; workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); - var resource = new JavaAppExecutableResource(name, "java", workingDirectory); + var resource = new JavaAppExecutableResource(name, workingDirectory); return builder.AddResource(resource) - .WithJavaDefaults(options) + .WithJavaDefaults(otelAgentPath: options.OtelAgentPath, port: options.Port) .WithHttpEndpoint(port: options.Port, name: JavaAppContainerResource.HttpEndpointName, isProxied: false) .WithArgs(allArgs); } @@ -55,206 +134,184 @@ public static IResourceBuilder AddJavaApp(this IDistr /// The working directory to use for the command. If null, the working directory of the current process is used. /// The to configure the Java application." /// A reference to the . - public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) => - builder.AddJavaApp(name, workingDirectory, options); + [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] + public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) => builder.AddJavaApp(name, workingDirectory, options); + /// /// Adds a Maven build step to the application model. /// /// The to add the Maven build step to. - /// The to configure the Maven build step. + /// The path to the jar file to execute. If not provided, it will be automatically detected in the 'target' directory. /// A reference to the . - /// - /// This method adds a Maven build step to the application model. The Maven build step is executed before the Java application is started. - /// - /// The Maven build step is added as an executable resource named "maven" with the command "mvnw --quiet clean package". - /// - /// The Maven build step is excluded from the manifest file. - /// - public static IResourceBuilder WithMavenBuild( - this IResourceBuilder builder, - MavenOptions? mavenOptions = null) + public static IResourceBuilder WithMavenBuild( + this IResourceBuilder builder, + string? jarPath = null) where T : ExecutableResource { - mavenOptions ??= new MavenOptions(); + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + + if (jarPath != null && builder.Resource is JavaAppExecutableResource executableResource) + { + executableResource.JarPath = jarPath; + } + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + var buildResourceName = $"{builder.Resource.Name}-maven-build"; + var buildResource = new MavenBuildResource(buildResourceName, builder.Resource.WorkingDirectory); + + var mavenBuilder = builder.ApplicationBuilder.AddResource(buildResource) + .WithParentRelationship(builder.Resource) + .ExcludeFromManifest(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + mavenBuilder.WithCommand("cmd"); + } + else + { + mavenBuilder.WithCommand(Path.Combine(builder.Resource.WorkingDirectory, "mvnw")); + } - mavenOptions.WorkingDirectory ??= builder.Resource.WorkingDirectory; + mavenBuilder.WithArgs(context => + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + context.Args.Add("/c"); + context.Args.Add("mvnw.cmd"); + } - return builder.WithJavaBuild( - new MavenBuildAnnotation(mavenOptions), - "Maven", - (resource, services, ct, useNotificationService) => - ExecuteBuild(resource, services, ct, useNotificationService, "Maven", mavenOptions)); + context.Args.Add("clean"); + context.Args.Add("package"); + }); + + builder.WaitForCompletion(mavenBuilder); + } + + if (builder.Resource is JavaAppExecutableResource javaAppExecutableResource) + { + builder.ApplicationBuilder.CreateResourceBuilder(javaAppExecutableResource) + .PublishAsJavaDockerfile(builder.Resource.WorkingDirectory); + } + + return builder; } /// /// Adds a Gradle build step to the application model. /// /// The to add the Gradle build step to. - /// The to configure the Gradle build step. + /// The path to the executable jar. /// A reference to the . - /// - /// This method adds a Gradle build step to the application model. The Gradle build step is executed before the Java application is started. - /// - /// The Gradle build step is added as an executable resource named "gradle" with the command "gradlew --quiet clean build". - /// - /// The Gradle build step is excluded from the manifest file. - /// - public static IResourceBuilder WithGradleBuild( - this IResourceBuilder builder, - GradleOptions? gradleOptions = null) + public static IResourceBuilder WithGradleBuild( + this IResourceBuilder builder, + string? jarPath = null) where T : ExecutableResource { - gradleOptions ??= new GradleOptions(); - - gradleOptions.WorkingDirectory ??= builder.Resource.WorkingDirectory; - - return builder.WithJavaBuild( - new GradleBuildAnnotation(gradleOptions), - "Gradle", - (resource, services, ct, useNotificationService) => - ExecuteBuild(resource, services, ct, useNotificationService, "Gradle", gradleOptions)); - } + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - private static IResourceBuilder WithJavaBuild( - this IResourceBuilder builder, - TAnnotation annotation, - string buildSystemName, - Func> buildFunc) - where TAnnotation : IResourceAnnotation - { - if (builder.Resource.TryGetLastAnnotation(out _)) + if (jarPath != null && builder.Resource is JavaAppExecutableResource executableResource) { - // Replace the existing annotation, but don't continue on and subscribe to the event again. - builder.WithAnnotation(annotation, ResourceAnnotationMutationBehavior.Replace); - return builder; + executableResource.JarPath = jarPath; } - builder.WithAnnotation(annotation); - - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (e, ct) => + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - if (e.Resource is not JavaAppExecutableResource javaAppResource) + var buildResourceName = $"{builder.Resource.Name}-gradle-build"; + var buildResource = new GradleBuildResource(buildResourceName, builder.Resource.WorkingDirectory); + + var gradleBuilder = builder.ApplicationBuilder.AddResource(buildResource) + .WithParentRelationship(builder.Resource) + .ExcludeFromManifest(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return; + gradleBuilder.WithCommand("cmd"); + } + else + { + gradleBuilder.WithCommand(Path.Combine(builder.Resource.WorkingDirectory, "gradlew")); } - await buildFunc(javaAppResource, e.Services, ct, true).ConfigureAwait(false); - }); - - builder.WithCommand( - $"build-with-{buildSystemName.ToLowerInvariant()}", - $"Build with {buildSystemName}", - async (context) => - await buildFunc(builder.Resource, context.ServiceProvider, context.CancellationToken, false).ConfigureAwait(false) ? - new ExecuteCommandResult { Success = true } : - new ExecuteCommandResult { Success = false, ErrorMessage = $"Failed to build with {buildSystemName}" }, - new CommandOptions() + gradleBuilder.WithArgs(context => { - IconName = "build", - UpdateState = (context) => context.ResourceSnapshot.State switch + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - { Text: "Stopped" } or - { Text: "Exited" } or - { Text: "Finished" } or - { Text: "FailedToStart" } => ResourceCommandState.Enabled, - _ => ResourceCommandState.Disabled - }, - }); + context.Args.Add("/c"); + context.Args.Add("gradlew.bat"); + } - return builder; - } + context.Args.Add("clean"); + context.Args.Add("build"); + }); - private static async Task ExecuteBuild( - JavaAppExecutableResource javaAppResource, - IServiceProvider services, - CancellationToken ct, - bool useNotificationService, - string buildSystemName, - JavaBuildOptions options) - { - var logger = services.GetRequiredService().GetLogger(javaAppResource); - var notificationService = services.GetRequiredService(); + builder.WaitForCompletion(gradleBuilder); + } - if (useNotificationService) + if (builder.Resource is JavaAppExecutableResource javaAppExecutableResource) { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new($"Building {buildSystemName} project", KnownResourceStates.Starting) - }).ConfigureAwait(false); + builder.ApplicationBuilder.CreateResourceBuilder(javaAppExecutableResource) + .PublishAsJavaDockerfile(builder.Resource.WorkingDirectory); } - logger.LogInformation("Building {BuildSystemName} project", buildSystemName); + return builder; + } - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + /// + /// Configures the Java Virtual Machine arguments for the Java application. + /// + /// The resource builder. + /// The JVM arguments. + /// A reference to the . + public static IResourceBuilder WithJvmArgs( + this IResourceBuilder builder, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - var process = new Process + return builder.WithArgs(context => { - StartInfo = new ProcessStartInfo + foreach (var arg in args) { - FileName = isWindows ? "cmd" : "sh", - Arguments = isWindows ? $"/c {options.Command} {string.Join(" ", options.Args)}" : $"./{options.Command} {string.Join(" ", options.Args)}", - WorkingDirectory = options.WorkingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - UseShellExecute = false, + context.Args.Insert(0, arg); } - }; + }); + } - process.OutputDataReceived += async (sender, args) => - { - if (!string.IsNullOrWhiteSpace(args.Data)) - { - if (useNotificationService) - { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new(args.Data, KnownResourceStates.Starting) - }).ConfigureAwait(false); - } + /// + /// Configures the OpenTelemetry Java Agent for the Java application. + /// + /// The resource builder. + /// The path to the OpenTelemetry Java Agent jar file. + /// A reference to the . + public static IResourceBuilder WithOtelAgent( + this IResourceBuilder builder, + string? agentPath = null) where T : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - logger.LogInformation("{Data}", args.Data); - } - }; + builder.WithOtlpExporter(); - process.ErrorDataReceived += async (sender, args) => + if (!string.IsNullOrEmpty(agentPath)) { - if (!string.IsNullOrWhiteSpace(args.Data)) - { - if (useNotificationService) - { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new(args.Data, KnownResourceStates.FailedToStart) - }).ConfigureAwait(false); - } - - logger.LogError("{Data}", args.Data); - } - }; + builder.WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{agentPath}"); + } - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + return builder; + } - await process.WaitForExitAsync(ct).ConfigureAwait(false); + [Obsolete("Use WithOtelAgent instead.")] + private static IResourceBuilder WithJavaDefaults( + this IResourceBuilder builder, + string? otelAgentPath = null, + int? port = null) where T : IResourceWithEnvironment + { + builder.WithOtelAgent($"{otelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar"); - if (process.ExitCode != 0) + if (port.HasValue) { - // always use notification service to push out errors in the build - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new($"{options.Command} exited with {process.ExitCode}", KnownResourceStates.FailedToStart) - }).ConfigureAwait(false); - - return false; + builder.WithEnvironment("SERVER_PORT", port.Value.ToString(CultureInfo.InvariantCulture)); } - return true; - } - private static IResourceBuilder WithJavaDefaults( - this IResourceBuilder builder, - JavaAppExecutableResourceOptions options) => - builder.WithOtlpExporter() - .WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") - .WithEnvironment("SERVER_PORT", options.Port.ToString(CultureInfo.InvariantCulture)); + return builder; + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildAnnotation.cs new file mode 100644 index 000000000..aa62bea83 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildAnnotation.cs @@ -0,0 +1,19 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a metadata annotation that specifies Java build options for publishing. +/// +/// The build image to use. +/// The build command to run. +internal sealed class JavaBuildAnnotation(string buildImage, string buildCommand) : IResourceAnnotation +{ + /// + /// The image to use for building the Java application. + /// + public string BuildImage { get; } = buildImage; + + /// + /// The command to run to build the Java application. + /// + public string BuildCommand { get; } = buildCommand; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs index 5925a2d54..f1faa5c5a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting; /// /// Represents the base options for configuring a Java build step. /// +[Obsolete("This class will be removed in a future version.")] public abstract class JavaBuildOptions { /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildAnnotation.cs deleted file mode 100644 index 8b678b615..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildAnnotation.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting; - -internal class MavenBuildAnnotation(MavenOptions mavenOptions) : IResourceAnnotation -{ - public MavenOptions MavenOptions { get; } = mavenOptions; -} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs new file mode 100644 index 000000000..9f9760d33 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs @@ -0,0 +1,9 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Maven build step. +/// +/// The name of the resource. +/// The working directory to use for the command. +public class MavenBuildResource(string name, string workingDirectory) + : ExecutableResource(name, "mvnw", workingDirectory); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs index 60eaab184..76e478164 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting; /// /// Represents the options for configuring a Maven build step. /// +[Obsolete("This class will be removed in a future version.")] public sealed class MavenOptions : JavaBuildOptions { /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index 57c2aa024..52964e1c4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -1,6 +1,6 @@ # CommunityToolkit.Aspire.Hosting.Java library -Provides extensions methods and resource definitions for the Aspire AppHost to support running Java/Spring applications either using either the JDK or a container and configuring the OpenTelemetry agent for Java. +Provides extension methods and resource definitions for the Aspire AppHost to support running Java applications. ## Getting Started @@ -14,30 +14,48 @@ dotnet add package CommunityToolkit.Aspire.Hosting.Java ### Example usage -Then, in the _Program.cs_ file of `AppHost`, define a Java resource, then call `AddJavaApp` or `AddSpringApp`: +In the _Program.cs_ file of `AppHost`, you can add a Java application: ```csharp -// Define the Java container app resource -var containerapp = builder.AddSpringApp("containerapp", - new JavaAppContainerResourceOptions() - { - ContainerImageName = "/", - OtelAgentPath = "" - }); - -// Define the Java executable app resource -var executableapp = builder.AddSpringApp("executableapp", - new JavaAppExecutableResourceOptions() - { - ApplicationName = "target/app.jar", - OtelAgentPath = "../../../agents" - }); - -// (Optional) Add a Maven build step -executableapp.WithMavenBuild(); - -// (Optional) Add a Gradle build step -executableapp.WithGradleBuild(); +var javaApp = builder.AddJavaApp("javaApp", "../java-project") + .WithMavenBuild() + .WithHttpEndpoint(env: "SERVER_PORT"); +``` + +### Maven and Gradle support + +The integration provides support for Maven and Gradle build systems. + +To run a Maven build before the application starts: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project") + .WithMavenBuild(); +``` + +To run a Gradle build before the application starts: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project") + .WithGradleBuild(); +``` + +### Customizing the JVM arguments + +You can customize the JVM arguments for the Java application: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project") + .WithJvmArgs("-Xmx512m", "-Xms256m"); +``` + +### Configuring OpenTelemetry Agent + +You can configure the OpenTelemetry Java Agent: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project") + .WithOtelAgent("../agents/opentelemetry-javaagent.jar"); ``` ## Additional Information diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs index 3686b31ad..08432539d 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs @@ -1,9 +1,32 @@ using Aspire.Hosting; namespace CommunityToolkit.Aspire.Hosting.Java.Tests; + public class ContainerResourceCreationTests { [Fact] + public void AddJavaAppWithImageSetsDetails() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaContainerApp("java", "java-image", workingDirectory: ".", imageTag: "v1"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + Assert.Equal("java", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotations)); + Assert.Equal("java-image", imageAnnotations.Image); + Assert.Equal("v1", imageAnnotations.Tag); + } + + [Fact] +#pragma warning disable CS0618 public void AddJavaAppBuilderShouldNotBeNull() { IDistributedApplicationBuilder builder = null!; @@ -96,13 +119,10 @@ public async Task AddJavaAppContainerDetailsSetOnResource() Assert.Equal(options.ContainerRegistry, imageAnnotations.Registry); Assert.Equal(options.ContainerImageTag, imageAnnotations.Tag); - Assert.True(resource.TryGetLastAnnotation(out EndpointAnnotation? httpEndpointAnnotations)); - Assert.Equal(options.Port, httpEndpointAnnotations.Port); - Assert.Equal(options.TargetPort, httpEndpointAnnotations.TargetPort); - Assert.True(resource.TryGetLastAnnotation(out CommandLineArgsCallbackAnnotation? argsAnnotations)); CommandLineArgsCallbackContext context = new([]); await argsAnnotations.Callback(context); Assert.All(options.Args, arg => Assert.Contains(arg, context.Args)); } +#pragma warning restore CS0618 } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index 332d7a757..8f8de5ea6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -1,5 +1,6 @@ +using System.Runtime.InteropServices; using Aspire.Hosting; -using Aspire.Hosting.Eventing; +using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Java.Tests; @@ -10,15 +11,7 @@ public void AddJavaAppBuilderShouldNotBeNull() { IDistributedApplicationBuilder builder = null!; - Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); - } - - [Fact] - public void AddSpringAppBuilderShouldNotBeNull() - { - IDistributedApplicationBuilder builder = null!; - - Assert.Throws(() => builder.AddSpringApp("spring", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory)); } [Fact] @@ -26,21 +19,10 @@ public void AddJavaAppNameShouldNotBeNullOrWhiteSpace() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); - - const string name = ""; - Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); - } - - [Fact] - public void AddSpringAppNameShouldNotBeNullOrWhiteSpace() - { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - - Assert.Throws(() => builder.AddSpringApp(null!, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory)); const string name = ""; - Assert.Throws(() => builder.AddSpringApp(name, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory)); } [Fact] @@ -48,41 +30,17 @@ public void AddJavaAppWorkingDirectoryShouldNotBeNullOrWhiteSpace() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp("java", null!, new JavaAppExecutableResourceOptions())); - Assert.Throws(() => builder.AddJavaApp("java", "", new JavaAppExecutableResourceOptions())); - } - - [Fact] - public void AddJavaAppExecutableResourceOptionsShouldNotBeNull() - { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - - Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, null!)); - } - - [Fact] - public void AddSpringAppContainerResourceOptionsShouldNotBeNull() - { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - - Assert.Throws(() => builder.AddSpringApp("spring", Environment.CurrentDirectory, null!)); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: null!)); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: "")); } [Fact] - public async Task AddJavaAppContainerDetailsSetOnResource() + public async Task AddJavaAppDetailsSetOnResource() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - JvmArgs = ["-Dtest"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options); + builder.AddJavaApp("java", Environment.CurrentDirectory, "test.jar", args: ["--test"]) + .WithJvmArgs("-Dtest"); using var app = builder.Build(); @@ -96,191 +54,80 @@ public async Task AddJavaAppContainerDetailsSetOnResource() Assert.Equal(Environment.CurrentDirectory, resource.WorkingDirectory); Assert.Equal("java", resource.Command); - Assert.True(resource.TryGetLastAnnotation(out EndpointAnnotation? httpEndpointAnnotations)); - Assert.Equal(options.Port, httpEndpointAnnotations.Port); - - Assert.True(resource.TryGetLastAnnotation(out CommandLineArgsCallbackAnnotation? argsAnnotations)); - CommandLineArgsCallbackContext context = new([]); - await argsAnnotations.Callback(context); - - Assert.Equal([..options.JvmArgs, "-jar", options.ApplicationName, ..options.Args], context.Args); + var args = await resource.GetArgumentListAsync(); + Assert.Equal("-Dtest", args[0]); + Assert.Contains("-jar", args); + Assert.Contains("test.jar", args); + Assert.Contains("--test", args); } [Fact] - public void AddingMavenOptions() + public void AddJavaAppWithMavenBuildCreatesResource() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) + builder.AddJavaApp("java", Environment.CurrentDirectory) .WithMavenBuild(); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.MavenOptions); - Assert.Equal("mvnw", annotation.MavenOptions.Command); - Assert.Equal("--quiet clean package", string.Join(' ', annotation.MavenOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.MavenOptions.WorkingDirectory); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("java-maven-build", buildResource.Name); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "mvnw"), buildResource.Command); + + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); } [Fact] - public void AddingMavenOptionsWithOverrides() + public void AddJavaAppWithGradleBuildCreatesResource() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(new() - { - Args = ["clean", "package"], - }); + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleBuild(); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.MavenOptions); - Assert.Equal("mvnw", annotation.MavenOptions.Command); - Assert.Equal("clean package", string.Join(' ', annotation.MavenOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.MavenOptions.WorkingDirectory); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("java-gradle-build", buildResource.Name); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "gradlew"), buildResource.Command); + + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); } [Fact] - public void ChainingAddMavenBuildOverridesPreviousOptions() + public async Task AddJavaAppWithOtelAgentSetsEnvironment() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(new() - { - Args = ["clean", "package"], - }) - .WithMavenBuild(); + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithOtelAgent("/agents/opentelemetry-javaagent.jar"); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.MavenOptions); - Assert.Equal("mvnw", annotation.MavenOptions.Command); - Assert.Equal("--quiet clean package", string.Join(' ', annotation.MavenOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.MavenOptions.WorkingDirectory); - } - - [Fact] - public void AddingMavenBuildRegistersRebuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); - - using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-maven"); + var config = await resource.GetEnvironmentVariablesAsync(); + Assert.True(config.TryGetValue("JAVA_TOOL_OPTIONS", out var value)); + Assert.Equal("-javaagent:/agents/opentelemetry-javaagent.jar", value); } [Fact] - public void MultipleAddingMavenBuildRegistersSingleRebuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-maven"); - } - - [Theory] - [InlineData("Stopped", ResourceCommandState.Enabled)] - [InlineData("Finished", ResourceCommandState.Enabled)] - [InlineData("Exited", ResourceCommandState.Enabled)] - [InlineData("FailedToStart", ResourceCommandState.Enabled)] - [InlineData("Starting", ResourceCommandState.Disabled)] - [InlineData("Running", ResourceCommandState.Disabled)] - public void MavenBuildCommandAvailability(string text, ResourceCommandState expectedCommandState) +#pragma warning disable CS0618 + public void DeprecatedAddJavaAppBuilderShouldNotBeNull() { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); - - using var app = builder.Build(); + IDistributedApplicationBuilder builder = null!; - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annoitation = Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-maven"); - - var updateState = annoitation.UpdateState(new UpdateCommandStateContext() - { - ResourceSnapshot = new CustomResourceSnapshot() - { - State = new ResourceStateSnapshot(text, null), - ResourceType = "JavaAppExecutableResource", - Properties = [] - }, - ServiceProvider = app.Services - }); - Assert.Equal(expectedCommandState, updateState); + Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); } +#pragma warning restore CS0618 } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs index 919435c99..27ab5eb3a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs @@ -1,184 +1,47 @@ +using System.Runtime.InteropServices; using Aspire.Hosting; +using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Java.Tests; public class GradleResourceCreationTests { [Fact] - public void AddingGradleOptions() + public void AddingGradleBuildCreatesResource() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) + builder.AddJavaApp("java", Environment.CurrentDirectory) .WithGradleBuild(); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.GradleOptions); - Assert.Equal("gradlew", annotation.GradleOptions.Command); - Assert.Equal("--quiet clean build", string.Join(' ', annotation.GradleOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); - } - - [Fact] - public void AddingGradleOptionsWithOverrides() - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithGradleBuild(new() - { - Args = ["clean", "build"], - }); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.GradleOptions); - Assert.Equal("gradlew", annotation.GradleOptions.Command); - Assert.Equal("clean build", string.Join(' ', annotation.GradleOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("java-gradle-build", buildResource.Name); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(Environment.CurrentDirectory, "gradlew"), buildResource.Command); + + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); } [Fact] - public void ChainingAddGradleBuildOverridesPreviousOptions() + public async Task AddingGradleBuildWithOverrides() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithGradleBuild(new() - { - Args = ["clean", "build"], - }) - .WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType()); - - Assert.NotNull(annotation.GradleOptions); - Assert.Equal("gradlew", annotation.GradleOptions.Command); - Assert.Equal("--quiet clean build", string.Join(' ', annotation.GradleOptions.Args)); - Assert.Equal(Environment.CurrentDirectory, annotation.GradleOptions.WorkingDirectory); - } - - [Fact] - public void AddingGradleBuildRegistersRebuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); - } - - [Fact] - public void MultipleAddingGradleBuildRegistersSingleRebuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); - } - - [Theory] - [InlineData("Stopped", ResourceCommandState.Enabled)] - [InlineData("Finished", ResourceCommandState.Enabled)] - [InlineData("Exited", ResourceCommandState.Enabled)] - [InlineData("FailedToStart", ResourceCommandState.Enabled)] - [InlineData("Starting", ResourceCommandState.Disabled)] - [InlineData("Running", ResourceCommandState.Disabled)] - public void GradleBuildCommandAvailability(string text, ResourceCommandState expectedCommandState) - { - var builder = DistributedApplication.CreateBuilder(); - - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; - - builder.AddJavaApp("java", Environment.CurrentDirectory, options) + builder.AddJavaApp("java", Environment.CurrentDirectory) .WithGradleBuild(); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - var annotation = Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-gradle"); + var buildResource = Assert.Single(appModel.Resources.OfType()); - var updateState = annotation.UpdateState(new UpdateCommandStateContext() - { - ResourceSnapshot = new CustomResourceSnapshot() - { - State = new ResourceStateSnapshot(text, null), - ResourceType = "JavaAppExecutableResource", - Properties = [] - }, - ServiceProvider = app.Services - }); - Assert.Equal(expectedCommandState, updateState); + var args = await buildResource.GetArgumentListAsync(); + string[] expectedArgs = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ["/c", "gradlew.bat", "clean", "build"] : ["clean", "build"]; + Assert.Equal(expectedArgs, args); } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs new file mode 100644 index 000000000..7cbd3d5fb --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs @@ -0,0 +1,128 @@ +using System.Runtime.InteropServices; +using Aspire.Hosting; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Java.Tests; + +public class ResourceCreationTests +{ + [Fact] + public void DefaultJavaApp() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("javaapp", "../java-project"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.Equal("java", resource.Command); + } + + [Fact] + public async Task JavaAppWithJarPathHasCorrectArgsAsync() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("javaapp", "../java-project", "app.jar"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + var args = await resource.GetArgumentListAsync(); + Assert.Contains("-jar", args); + Assert.Contains("app.jar", args); + } + + [Fact] + public void JavaAppWithMavenBuildCreatesBuildResource() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("javaapp", "../java-project").WithMavenBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("javaapp-maven-build", buildResource.Name); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "mvnw"), buildResource.Command); + + // Verify that the Java app waits for the build to complete + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); + } + + [Fact] + public async Task AddingMavenBuildWithOverrides() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + var args = await buildResource.GetArgumentListAsync(); + string[] expectedArgs = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ["/c", "mvnw.cmd", "clean", "package"] : ["clean", "package"]; + Assert.Equal(expectedArgs, args); + } + + [Fact] + public void JavaAppWithGradleBuildCreatesBuildResource() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("javaapp", "../java-project").WithGradleBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("javaapp-gradle-build", buildResource.Name); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "gradlew"), buildResource.Command); + + // Verify that the Java app waits for the build to complete + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); + } + + [Fact] + public async Task JavaAppWithJvmArgsHasCorrectArgsAsync() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddJavaApp("javaapp", "../java-project") + .WithJvmArgs("-Xmx512m"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal("-Xmx512m", args[0]); + Assert.Contains("-jar", args); + Assert.Contains("target/app.jar", args); + } +} From a45060a968edc3fdf31369b7191f381548c3a4f5 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:28:31 -0600 Subject: [PATCH 03/16] Refactor to reduce the number of potential breaking changes --- .../Program.cs | 2 +- .../JavaAppContainerResource.cs | 8 ++---- .../JavaAppExecutableResource.cs | 28 ++++++++++++++++--- .../JavaAppHostingExtension.Container.cs | 10 ++----- .../JavaBuildOptions.cs | 21 -------------- .../MavenOptions.cs | 19 +++++++------ .../ContainerResourceCreationTests.cs | 2 +- 7 files changed, 42 insertions(+), 48 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs index 5b7376ba8..50fabda74 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs @@ -2,7 +2,7 @@ var apiapp = builder.AddProject("apiapp"); -var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample", workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven") +var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample") .WithOtelAgent("/agents/opentelemetry-javaagent.jar") .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs index ea880665a..dccc9cf7a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppContainerResource.cs @@ -4,14 +4,10 @@ /// A resource that represents a Java application. /// /// The name of the resource. -/// The working directory to use for the command. If null, the working directory of the current process is used. /// An optional container entrypoint. -public class JavaAppContainerResource(string name, string workingDirectory, string? entrypoint = null) - : ContainerResource(name, entrypoint), IResourceWithServiceDiscovery, IResourceWithWaitSupport +public class JavaAppContainerResource(string name, string? entrypoint = null) + : ContainerResource(name, entrypoint), IResourceWithServiceDiscovery { internal const string HttpEndpointName = "http"; - - /// - public string WorkingDirectory { get; } = workingDirectory; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs index 13952f7d6..02ffc4c23 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -3,13 +3,33 @@ /// /// A resource that represents a Java application. /// -/// The name of the resource. -/// The working directory to use for the command. If null, the working directory of the current process is used. -public class JavaAppExecutableResource(string name, string workingDirectory) - : ExecutableResource(name, "java", workingDirectory), IResourceWithServiceDiscovery, IContainerFilesDestinationResource, IResourceWithWaitSupport +public class JavaAppExecutableResource + : ExecutableResource, IResourceWithServiceDiscovery, IContainerFilesDestinationResource, IResourceWithWaitSupport { internal const string HttpEndpointName = "http"; + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The working directory to use for the command. If null, the working directory of the current process is used. + public JavaAppExecutableResource(string name, string workingDirectory) + : base(name, "java", workingDirectory) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The command to execute. + /// The working directory to use for the command. If null, the working directory of the current process is used. + [Obsolete("Use JavaAppExecutableResource(string, string) instead. This constructor will be removed in a future version.")] + public JavaAppExecutableResource(string name, string command, string workingDirectory) + : base(name, command, workingDirectory) + { + } + /// public string? ContainerFilesDestination => "/app/static"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index 5ab63e446..be0ec435f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -14,20 +14,16 @@ public static partial class JavaAppHostingExtension /// The to add the resource to. /// The name of the resource. /// The container image name. - /// The working directory containing the Java application. /// The container image tag. /// A reference to the . public static IResourceBuilder AddJavaContainerApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, - string image, string? workingDirectory = null, string? imageTag = null) + string image, string? imageTag = null) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); ArgumentException.ThrowIfNullOrWhiteSpace(image, nameof(image)); - workingDirectory ??= builder.AppHostDirectory; - - workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); - var resource = new JavaAppContainerResource(name, workingDirectory); + var resource = new JavaAppContainerResource(name); return builder.AddResource(resource) .WithImage(image, imageTag) @@ -49,7 +45,7 @@ public static IResourceBuilder AddJavaApp(this IDistri ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); ArgumentException.ThrowIfNullOrWhiteSpace(options.ContainerImageName, nameof(options.ContainerImageName)); - var resource = new JavaAppContainerResource(name, builder.AppHostDirectory); + var resource = new JavaAppContainerResource(name); var rb = builder.AddResource(resource) .WithAnnotation(new ContainerImageAnnotation { Image = options.ContainerImageName, Tag = options.ContainerImageTag, Registry = options.ContainerRegistry }) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs deleted file mode 100644 index f1faa5c5a..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Aspire.Hosting; - -/// -/// Represents the base options for configuring a Java build step. -/// -[Obsolete("This class will be removed in a future version.")] -public abstract class JavaBuildOptions -{ - /// - /// Gets or sets the working directory to use for the command. If null, the working directory of the current process is used. - /// - public string? WorkingDirectory { get; set; } - /// - /// Gets or sets the command to execute. - /// - public string Command { get; set; } = default!; - /// - /// Gets or sets the arguments to pass to the command. - /// - public string[] Args { get; set; } = []; -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs index 76e478164..65fa20941 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs @@ -3,15 +3,18 @@ namespace Aspire.Hosting; /// /// Represents the options for configuring a Maven build step. /// -[Obsolete("This class will be removed in a future version.")] -public sealed class MavenOptions : JavaBuildOptions +public sealed class MavenOptions { /// - /// Initializes a new instance of the class. + /// Gets or sets the working directory to use for the command. If null, the working directory of the current process is used. /// - public MavenOptions() - { - Command = "mvnw"; - Args = ["--quiet", "clean", "package"]; - } + public string? WorkingDirectory { get; set; } + /// + /// Gets or sets the command to execute. Default is "mvnw". + /// + public string Command { get; set; } = "mvnw"; + /// + /// Gets or sets the arguments to pass to the command. Default is "--quiet clean package". + /// + public string[] Args { get; set; } = ["--quiet", "clean", "package"]; } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs index 08432539d..74240fa63 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs @@ -9,7 +9,7 @@ public void AddJavaAppWithImageSetsDetails() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - builder.AddJavaContainerApp("java", "java-image", workingDirectory: ".", imageTag: "v1"); + builder.AddJavaContainerApp("java", "java-image", imageTag: "v1"); using var app = builder.Build(); From f094e8888da83e3df21342ac9ea8e677a14e5000 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:44:35 -0600 Subject: [PATCH 04/16] Refine Java hosting integration APIs and documentation - Make jarPath a required parameter in AddJavaApp - Remove jarPath from WithMavenBuild/WithGradleBuild - Simplify wrapper path resolution using ??= pattern - Inline ResolveWrapperCommand into callers - Restructure README to align with other integrations - Fix stale Obsolete messages to match current signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Container.cs | 16 +- .../JavaAppHostingExtension.Executable.cs | 197 ++++++++---------- .../MavenOptions.cs | 1 + .../README.md | 71 +++++-- .../ExecutableResourceCreationTests.cs | 152 ++++++++++---- .../GradleResourceCreationTests.cs | 47 ----- .../ResourceCreationTests.cs | 128 ------------ 7 files changed, 274 insertions(+), 338 deletions(-) delete mode 100644 tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs delete mode 100644 tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index be0ec435f..96e958a0b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -37,7 +37,7 @@ public static IResourceBuilder AddJavaContainerApp(thi /// The name of the resource. /// The to configure the Java application." /// A reference to the . - [Obsolete("Use AddJavaApp(string, string, string?, string[]?) instead. This method will be removed in a future version.")] + [Obsolete("Use AddJavaContainerApp instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -50,7 +50,7 @@ public static IResourceBuilder AddJavaApp(this IDistri var rb = builder.AddResource(resource) .WithAnnotation(new ContainerImageAnnotation { Image = options.ContainerImageName, Tag = options.ContainerImageTag, Registry = options.ContainerRegistry }) .WithHttpEndpoint(port: options.Port, targetPort: options.TargetPort, name: JavaAppContainerResource.HttpEndpointName) - .WithJavaDefaults(otelAgentPath: options.OtelAgentPath); + .WithJavaDefaults(options); if (options.Args is { Length: > 0 }) { @@ -60,6 +60,7 @@ public static IResourceBuilder AddJavaApp(this IDistri return rb; } + /// /// Adds a Spring application to the application model. Executes the containerized Spring app. /// @@ -68,5 +69,14 @@ public static IResourceBuilder AddJavaApp(this IDistri /// The to configure the Java application." /// A reference to the . [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] - public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => builder.AddJavaApp(name, options); + public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => + builder.AddJavaApp(name, options); + +#pragma warning disable CS0618 + private static IResourceBuilder WithJavaDefaults( + this IResourceBuilder builder, + JavaAppContainerResourceOptions options) => + builder.WithOtlpExporter() + .WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar"); +#pragma warning restore CS0618 } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 31175fa58..96d1c25da 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Runtime.InteropServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; using CommunityToolkit.Aspire.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -19,21 +20,22 @@ public static partial class JavaAppHostingExtension /// The to add the resource to. /// The name of the resource. /// The working directory to use for the command. If null, the working directory of the current process is used. - /// The path to the jar file to execute. Default is target/app.jar. + /// The path to the jar file, relative to the resource working directory. /// The optional arguments to be passed to the executable when it is started. /// A reference to the . public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, - string? jarPath = null, string[]? args = null) + string jarPath, string[]? args = null) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory, nameof(workingDirectory)); + ArgumentException.ThrowIfNullOrWhiteSpace(jarPath, nameof(jarPath)); workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); var resource = new JavaAppExecutableResource(name, workingDirectory) { - JarPath = jarPath ?? "target/app.jar" + JarPath = jarPath }; return builder.AddResource(resource) @@ -70,7 +72,7 @@ private static IResourceBuilder PublishAsJavaDockerfi { if (!builder.Resource.TryGetLastAnnotation(out var buildAnnotation)) { - buildAnnotation = new JavaBuildAnnotation("eclipse-temurin:25-jdk-alpine", "./mvnw clean package -DskipTests"); + buildAnnotation = new JavaBuildAnnotation("eclipse-temurin:21-jdk-alpine", "./mvnw clean package -DskipTests"); } var buildStage = context.Builder @@ -82,10 +84,11 @@ private static IResourceBuilder PublishAsJavaDockerfi var logger = context.Services.GetService>() ?? NullLogger.Instance; context.Builder - .From("eclipse-temurin:25-jre-alpine") + .From("eclipse-temurin:21-jre-alpine") .Run("apk --no-cache add ca-certificates") .WorkDir("/app") .CopyFrom(buildStage.StageName!, $"/build/{builder.Resource.JarPath}", "/app/app.jar") + .AddContainerFiles(context.Resource, "/app", logger) .Entrypoint(["java", "-jar", "/app/app.jar"]); }); }); @@ -100,7 +103,7 @@ private static IResourceBuilder PublishAsJavaDockerfi /// The working directory to use for the command. If null, the working directory of the current process is used. /// The to configure the Java application." /// A reference to the . - [Obsolete("Use AddJavaApp(string, string, string?, string[]?, string[]?) instead. This method will be removed in a future version.")] + [Obsolete("Use AddJavaApp(string, string, string, string[]?) instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -118,14 +121,15 @@ public static IResourceBuilder AddJavaApp(this IDistr allArgs = [.. options.JvmArgs, .. allArgs]; workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); - var resource = new JavaAppExecutableResource(name, workingDirectory); + var resource = new JavaAppExecutableResource(name, "java", workingDirectory); return builder.AddResource(resource) - .WithJavaDefaults(otelAgentPath: options.OtelAgentPath, port: options.Port) + .WithJavaDefaults(options) .WithHttpEndpoint(port: options.Port, name: JavaAppContainerResource.HttpEndpointName, isProxied: false) .WithArgs(allArgs); } + /// /// Adds a Spring application to the application model. Executes the executable Spring app. /// @@ -135,123 +139,108 @@ public static IResourceBuilder AddJavaApp(this IDistr /// The to configure the Java application." /// A reference to the . [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] - public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) => builder.AddJavaApp(name, workingDirectory, options); - + public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) => + builder.AddJavaApp(name, workingDirectory, options); /// /// Adds a Maven build step to the application model. /// /// The to add the Maven build step to. - /// The path to the jar file to execute. If not provided, it will be automatically detected in the 'target' directory. + /// The to configure the Maven build step. /// A reference to the . - public static IResourceBuilder WithMavenBuild( - this IResourceBuilder builder, - string? jarPath = null) where T : ExecutableResource + [Obsolete("Use WithMavenBuild(string?, params string[]) instead. This method will be removed in a future version.")] + public static IResourceBuilder WithMavenBuild( + this IResourceBuilder builder, + MavenOptions mavenOptions) { - ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(mavenOptions, nameof(mavenOptions)); - if (jarPath != null && builder.Resource is JavaAppExecutableResource executableResource) - { - executableResource.JarPath = jarPath; - } - - if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) - { - var buildResourceName = $"{builder.Resource.Name}-maven-build"; - var buildResource = new MavenBuildResource(buildResourceName, builder.Resource.WorkingDirectory); - - var mavenBuilder = builder.ApplicationBuilder.AddResource(buildResource) - .WithParentRelationship(builder.Resource) - .ExcludeFromManifest(); + return builder.WithMavenBuild(args: mavenOptions.Args); + } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - mavenBuilder.WithCommand("cmd"); - } - else - { - mavenBuilder.WithCommand(Path.Combine(builder.Resource.WorkingDirectory, "mvnw")); - } - mavenBuilder.WithArgs(context => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - context.Args.Add("/c"); - context.Args.Add("mvnw.cmd"); - } + /// + /// Adds a Maven build step to the application model. + /// + /// The to add the Maven build step to. + /// The path to the Maven wrapper script, relative to the resource working directory. If not provided, defaults to mvnw (or mvnw.cmd on Windows) in the working directory. + /// Arguments to pass to the Maven wrapper. If not provided, defaults to clean package. + /// A reference to the . + public static IResourceBuilder WithMavenBuild( + this IResourceBuilder builder, + string? wrapperScript = null, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - context.Args.Add("clean"); - context.Args.Add("package"); - }); + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".cmd" : string.Empty; - builder.WaitForCompletion(mavenBuilder); - } + wrapperScript ??= $"mvnw{extension}"; - if (builder.Resource is JavaAppExecutableResource javaAppExecutableResource) - { - builder.ApplicationBuilder.CreateResourceBuilder(javaAppExecutableResource) - .PublishAsJavaDockerfile(builder.Resource.WorkingDirectory); - } + string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); - return builder; + return builder.WithJavaBuildStep( + buildResourceName: $"{builder.Resource.Name}-maven-build", + createResource: (name, workingDirectory) => new MavenBuildResource(name, workingDirectory), + wrapperPath: wrapperPath, + buildArgs: args.Length > 0 ? args : ["clean", "package"]); } /// /// Adds a Gradle build step to the application model. /// /// The to add the Gradle build step to. - /// The path to the executable jar. + /// The path to the Gradle wrapper script, relative to the resource working directory. If not provided, defaults to gradlew (or gradlew.bat on Windows) in the working directory. + /// Arguments to pass to the Gradle wrapper. If not provided, defaults to clean build. /// A reference to the . - public static IResourceBuilder WithGradleBuild( - this IResourceBuilder builder, - string? jarPath = null) where T : ExecutableResource + public static IResourceBuilder WithGradleBuild( + this IResourceBuilder builder, + string? wrapperScript = null, + params string[] args) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - if (jarPath != null && builder.Resource is JavaAppExecutableResource executableResource) - { - executableResource.JarPath = jarPath; - } + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; + + wrapperScript ??= $"gradlew{extension}"; + + string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); + + return builder.WithJavaBuildStep( + buildResourceName: $"{builder.Resource.Name}-gradle-build", + createResource: (name, workingDirectory) => new GradleBuildResource(name, workingDirectory), + wrapperPath: wrapperPath, + buildArgs: args.Length > 0 ? args : ["clean", "build"]); + } + private static IResourceBuilder WithJavaBuildStep( + this IResourceBuilder builder, + string buildResourceName, + Func createResource, + string wrapperPath, + string[] buildArgs) where TBuildResource : ExecutableResource + { if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - var buildResourceName = $"{builder.Resource.Name}-gradle-build"; - var buildResource = new GradleBuildResource(buildResourceName, builder.Resource.WorkingDirectory); + var buildResource = createResource(buildResourceName, builder.Resource.WorkingDirectory); - var gradleBuilder = builder.ApplicationBuilder.AddResource(buildResource) + var buildBuilder = builder.ApplicationBuilder.AddResource(buildResource) + .WithCommand(wrapperPath) + .WithArgs(buildArgs) .WithParentRelationship(builder.Resource) .ExcludeFromManifest(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - gradleBuilder.WithCommand("cmd"); - } - else - { - gradleBuilder.WithCommand(Path.Combine(builder.Resource.WorkingDirectory, "gradlew")); - } - - gradleBuilder.WithArgs(context => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - context.Args.Add("/c"); - context.Args.Add("gradlew.bat"); - } - - context.Args.Add("clean"); - context.Args.Add("build"); - }); - - builder.WaitForCompletion(gradleBuilder); + builder.WaitForCompletion(buildBuilder); } - if (builder.Resource is JavaAppExecutableResource javaAppExecutableResource) - { - builder.ApplicationBuilder.CreateResourceBuilder(javaAppExecutableResource) - .PublishAsJavaDockerfile(builder.Resource.WorkingDirectory); - } + // Use the file name only for the Dockerfile RUN command, since the wrapper + // is executed relative to the container's WORKDIR, not the host machine. + string wrapperFileName = Path.GetFileName(wrapperPath); + string buildCommand = $"./{wrapperFileName} {string.Join(' ', buildArgs)}"; + builder.Resource.Annotations.Add( + new JavaBuildAnnotation("eclipse-temurin:21-jdk-alpine", buildCommand)); + builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource) + .PublishAsJavaDockerfile(builder.Resource.WorkingDirectory); return builder; } @@ -270,9 +259,9 @@ public static IResourceBuilder WithJvmArgs( return builder.WithArgs(context => { - foreach (var arg in args) + for (int i = args.Length - 1; i >= 0; i--) { - context.Args.Insert(0, arg); + context.Args.Insert(0, args[i]); } }); } @@ -300,18 +289,10 @@ public static IResourceBuilder WithOtelAgent( } [Obsolete("Use WithOtelAgent instead.")] - private static IResourceBuilder WithJavaDefaults( - this IResourceBuilder builder, - string? otelAgentPath = null, - int? port = null) where T : IResourceWithEnvironment - { - builder.WithOtelAgent($"{otelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar"); - - if (port.HasValue) - { - builder.WithEnvironment("SERVER_PORT", port.Value.ToString(CultureInfo.InvariantCulture)); - } - - return builder; - } + private static IResourceBuilder WithJavaDefaults( + this IResourceBuilder builder, + JavaAppExecutableResourceOptions options) => + builder.WithOtlpExporter() + .WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") + .WithEnvironment("SERVER_PORT", options.Port.ToString(CultureInfo.InvariantCulture)); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs index 65fa20941..116fd1716 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs @@ -3,6 +3,7 @@ namespace Aspire.Hosting; /// /// Represents the options for configuring a Maven build step. /// +[Obsolete("This class will be removed in a future version.")] public sealed class MavenOptions { /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index 52964e1c4..0f0d3ede9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -14,50 +14,96 @@ dotnet add package CommunityToolkit.Aspire.Hosting.Java ### Example usage -In the _Program.cs_ file of `AppHost`, you can add a Java application: +Then, in the _Program.cs_ file of `AppHost`, define a Java resource, then call `AddJavaApp`: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project") +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") .WithMavenBuild() .WithHttpEndpoint(env: "SERVER_PORT"); ``` -### Maven and Gradle support +The `SERVER_PORT` environment variable is used to determine the port the Java application should listen on. It is randomly assigned by Aspire. The name of the environment variable can be changed by passing a different value to the `WithHttpEndpoint` method. -The integration provides support for Maven and Gradle build systems. +The `jarPath` parameter specifies the path to the jar file, relative to the resource working directory. -To run a Maven build before the application starts: +### Containerized Java applications + +To run a containerized Java application, use `AddJavaContainerApp`: + +```csharp +var javaApp = builder.AddJavaContainerApp("javaApp", "my-java-image", "latest"); +``` + +## Build Systems + +The integration provides support for Maven and Gradle build systems. Build resources are created in run mode only — they do not run when publishing, as the generated Dockerfile handles the build automatically. + +### Maven + +To run a Maven build (`mvnw clean package`) before the application starts: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project") +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") .WithMavenBuild(); ``` -To run a Gradle build before the application starts: +### Gradle + +To run a Gradle build (`gradlew clean build`) before the application starts: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project") +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "build/libs/app.jar") .WithGradleBuild(); ``` -### Customizing the JVM arguments +### Custom build arguments + +You can provide custom build arguments, replacing the defaults (`clean package` for Maven, `clean build` for Gradle): + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") + .WithMavenBuild(args: ["clean", "install", "-DskipTests"]); +``` + +### Custom wrapper script + +You can override the wrapper script path, relative to the resource working directory: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "build/libs/app.jar") + .WithGradleBuild(wrapperScript: "scripts/gradlew"); +``` + +## JVM Configuration + +### JVM arguments You can customize the JVM arguments for the Java application: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project") +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") .WithJvmArgs("-Xmx512m", "-Xms256m"); ``` -### Configuring OpenTelemetry Agent +### OpenTelemetry Agent You can configure the OpenTelemetry Java Agent: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project") +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") .WithOtelAgent("../agents/opentelemetry-javaagent.jar"); ``` +## Publishing + +When publishing your Aspire application with a Maven or Gradle build configured, the Java resource automatically generates a multi-stage Dockerfile for containerization. To enable this, call `PublishAsJavaDockerfile`: + +```csharp +var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") + .WithMavenBuild() + .PublishAsJavaDockerfile(); +``` + ## Additional Information https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java @@ -65,4 +111,3 @@ https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java ## Feedback & contributing https://github.com/CommunityToolkit/Aspire - diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index 8f8de5ea6..f98e942c4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -11,46 +11,49 @@ public void AddJavaAppBuilderShouldNotBeNull() { IDistributedApplicationBuilder builder = null!; - Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory)); + Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, "app.jar")); } [Fact] public void AddJavaAppNameShouldNotBeNullOrWhiteSpace() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory)); + Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory, "app.jar")); const string name = ""; - Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory)); + Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory, "app.jar")); } [Fact] public void AddJavaAppWorkingDirectoryShouldNotBeNullOrWhiteSpace() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: null!)); - Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: "")); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: null!, jarPath: "app.jar")); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: "", jarPath: "app.jar")); } [Fact] - public async Task AddJavaAppDetailsSetOnResource() + public void DefaultJavaApp() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("java", Environment.CurrentDirectory, "test.jar", args: ["--test"]) - .WithJvmArgs("-Dtest"); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("javaapp", "../java-project", "target/app.jar")); - using var app = builder.Build(); + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("java", resource.Command); + } - var appModel = app.Services.GetRequiredService(); + [Fact] + public async Task AddJavaAppDetailsSetOnResource() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "test.jar", args: ["--test"]) + .WithJvmArgs("-Dtest")); - var resource = appModel.Resources.OfType().SingleOrDefault(); + var resource = Assert.Single(appModel.Resources.OfType()); - Assert.NotNull(resource); Assert.Equal("java", resource.Name); - Assert.Equal(Environment.CurrentDirectory, resource.WorkingDirectory); Assert.Equal("java", resource.Command); @@ -62,58 +65,121 @@ public async Task AddJavaAppDetailsSetOnResource() } [Fact] - public void AddJavaAppWithMavenBuildCreatesResource() + public async Task JavaAppWithJvmArgsHasCorrectArgsAsync() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("javaapp", "../java-project", "target/app.jar") + .WithJvmArgs("-Xmx512m")); - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithMavenBuild(); + var resource = Assert.Single(appModel.Resources.OfType()); - using var app = builder.Build(); + var args = await resource.GetArgumentListAsync(); + Assert.Equal("-Xmx512m", args[0]); + Assert.Contains("-jar", args); + Assert.Contains("target/app.jar", args); + } + + [Fact] + public async Task MavenBuildCreatesResourceWithCorrectArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild()); - var appModel = app.Services.GetRequiredService(); var javaResource = Assert.Single(appModel.Resources.OfType()); var buildResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("java-maven-build", buildResource.Name); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "mvnw"), buildResource.Command); - + + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mvnw.cmd" : "mvnw"; + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, expectedWrapper)), buildResource.Command); + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); Assert.Contains(waitAnnotations, w => w.Resource == buildResource); + + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["clean", "package"], args); } [Fact] - public void AddJavaAppWithGradleBuildCreatesResource() + public async Task MavenBuildWithCustomArgsReplacesDefaults() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(args: ["-DskipTests", "-Pprod"])); + + var buildResource = Assert.Single(appModel.Resources.OfType()); + + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["-DskipTests", "-Pprod"], args); + } + + [Fact] + public void MavenBuildWithCustomWrapperUsesProvidedPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(wrapperScript: "custom/mvnw")); - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithGradleBuild(); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, "custom/mvnw")), buildResource.Command); + } - using var app = builder.Build(); + [Fact] + public async Task GradleBuildCreatesResourceWithCorrectArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithGradleBuild()); - var appModel = app.Services.GetRequiredService(); var javaResource = Assert.Single(appModel.Resources.OfType()); var buildResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("java-gradle-build", buildResource.Name); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "gradlew"), buildResource.Command); - + + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "gradlew.bat" : "gradlew"; + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, expectedWrapper)), buildResource.Command); + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); Assert.Contains(waitAnnotations, w => w.Resource == buildResource); + + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["clean", "build"], args); } [Fact] - public async Task AddJavaAppWithOtelAgentSetsEnvironment() + public async Task GradleBuildWithCustomArgsReplacesDefaults() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithGradleBuild(args: ["-x", "test", "--parallel"])); - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithOtelAgent("/agents/opentelemetry-javaagent.jar"); + var buildResource = Assert.Single(appModel.Resources.OfType()); - using var app = builder.Build(); + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["-x", "test", "--parallel"], args); + } + + [Fact] + public void GradleBuildWithCustomWrapperUsesProvidedPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithGradleBuild(wrapperScript: "custom/gradlew")); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, "custom/gradlew")), buildResource.Command); + } + + [Fact] + public async Task AddJavaAppWithOtelAgentSetsEnvironment() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithOtelAgent("/agents/opentelemetry-javaagent.jar")); - var appModel = app.Services.GetRequiredService(); var resource = Assert.Single(appModel.Resources.OfType()); var config = await resource.GetEnvironmentVariablesAsync(); @@ -130,4 +196,12 @@ public void DeprecatedAddJavaAppBuilderShouldNotBeNull() Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); } #pragma warning restore CS0618 + + private static DistributedApplicationModel BuildAppModel(Action configure) + { + var builder = DistributedApplication.CreateBuilder(); + configure(builder); + var app = builder.Build(); + return app.Services.GetRequiredService(); + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs deleted file mode 100644 index 27ab5eb3a..000000000 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/GradleResourceCreationTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Runtime.InteropServices; -using Aspire.Hosting; -using CommunityToolkit.Aspire.Testing; - -namespace CommunityToolkit.Aspire.Hosting.Java.Tests; - -public class GradleResourceCreationTests -{ - [Fact] - public void AddingGradleBuildCreatesResource() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var javaResource = Assert.Single(appModel.Resources.OfType()); - var buildResource = Assert.Single(appModel.Resources.OfType()); - - Assert.Equal("java-gradle-build", buildResource.Name); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(Environment.CurrentDirectory, "gradlew"), buildResource.Command); - - Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); - Assert.Contains(waitAnnotations, w => w.Resource == buildResource); - } - - [Fact] - public async Task AddingGradleBuildWithOverrides() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var buildResource = Assert.Single(appModel.Resources.OfType()); - - var args = await buildResource.GetArgumentListAsync(); - string[] expectedArgs = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ["/c", "gradlew.bat", "clean", "build"] : ["clean", "build"]; - Assert.Equal(expectedArgs, args); - } -} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs deleted file mode 100644 index 7cbd3d5fb..000000000 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ResourceCreationTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Runtime.InteropServices; -using Aspire.Hosting; -using CommunityToolkit.Aspire.Testing; - -namespace CommunityToolkit.Aspire.Hosting.Java.Tests; - -public class ResourceCreationTests -{ - [Fact] - public void DefaultJavaApp() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("javaapp", "../java-project"); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - - var resource = appModel.Resources.OfType().SingleOrDefault(); - - Assert.NotNull(resource); - - Assert.Equal("java", resource.Command); - } - - [Fact] - public async Task JavaAppWithJarPathHasCorrectArgsAsync() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("javaapp", "../java-project", "app.jar"); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - - var resource = appModel.Resources.OfType().SingleOrDefault(); - - Assert.NotNull(resource); - - var args = await resource.GetArgumentListAsync(); - Assert.Contains("-jar", args); - Assert.Contains("app.jar", args); - } - - [Fact] - public void JavaAppWithMavenBuildCreatesBuildResource() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("javaapp", "../java-project").WithMavenBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - - var javaResource = Assert.Single(appModel.Resources.OfType()); - var buildResource = Assert.Single(appModel.Resources.OfType()); - - Assert.Equal("javaapp-maven-build", buildResource.Name); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "mvnw"), buildResource.Command); - - // Verify that the Java app waits for the build to complete - Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); - Assert.Contains(waitAnnotations, w => w.Resource == buildResource); - } - - [Fact] - public async Task AddingMavenBuildWithOverrides() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("java", Environment.CurrentDirectory) - .WithMavenBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - var buildResource = Assert.Single(appModel.Resources.OfType()); - - var args = await buildResource.GetArgumentListAsync(); - string[] expectedArgs = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ["/c", "mvnw.cmd", "clean", "package"] : ["clean", "package"]; - Assert.Equal(expectedArgs, args); - } - - [Fact] - public void JavaAppWithGradleBuildCreatesBuildResource() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("javaapp", "../java-project").WithGradleBuild(); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - - var javaResource = Assert.Single(appModel.Resources.OfType()); - var buildResource = Assert.Single(appModel.Resources.OfType()); - - Assert.Equal("javaapp-gradle-build", buildResource.Name); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : Path.Combine(javaResource.WorkingDirectory, "gradlew"), buildResource.Command); - - // Verify that the Java app waits for the build to complete - Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); - Assert.Contains(waitAnnotations, w => w.Resource == buildResource); - } - - [Fact] - public async Task JavaAppWithJvmArgsHasCorrectArgsAsync() - { - var builder = DistributedApplication.CreateBuilder(); - - builder.AddJavaApp("javaapp", "../java-project") - .WithJvmArgs("-Xmx512m"); - - using var app = builder.Build(); - - var appModel = app.Services.GetRequiredService(); - - var resource = Assert.Single(appModel.Resources.OfType()); - - var args = await resource.GetArgumentListAsync(); - Assert.Equal("-Xmx512m", args[0]); - Assert.Contains("-jar", args); - Assert.Contains("target/app.jar", args); - } -} From d7b6c50cb5b5e8797dc6c4a38f1a65f9ed34d56e Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:55:34 -0600 Subject: [PATCH 05/16] Add WithJarPath, WithMavenGoal, WithGradleTask and new AddJavaApp overload - Add AddJavaApp(name, workingDirectory) overload without jarPath - Add WithJarPath() fluent method for explicit JAR path configuration - Add WithMavenGoal() and WithGradleTask() to run apps via build tools - Add JavaBuildToolAnnotation for build tool integration - Refactor existing AddJavaApp to delegate to new overload - Simplify Dockerfile generation for publish scenarios - Update README with new API patterns and examples - Update example app to use new API - Add new tests for the expanded API surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 5 +- .../JavaAppHostingExtension.Executable.cs | 166 +++++++++++++++--- .../JavaBuildToolAnnotation.cs | 19 ++ .../README.md | 107 ++++++----- .../ContainerResourceCreationTests.cs | 2 +- .../ExecutableResourceCreationTests.cs | 99 ++++++++++- 6 files changed, 310 insertions(+), 88 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs index 50fabda74..aa0bc1b0d 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs @@ -7,9 +7,8 @@ .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); var executableapp = builder.AddJavaApp("executableapp", - workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven", - jarPath: "target/spring-maven-0.0.1-SNAPSHOT.jar") - .WithMavenBuild() + workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven") + .WithMavenGoal("spring-boot:run") .WithOtelAgent("../../../agents/opentelemetry-javaagent.jar") .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT") .WithHttpHealthCheck("/health"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 96d1c25da..1d34f47df 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -14,6 +14,8 @@ namespace Aspire.Hosting; /// public static partial class JavaAppHostingExtension { + private const string JAVA_TOOL_OPTIONS = "JAVA_TOOL_OPTIONS"; + /// /// Adds a Java application to the application model. Executes the executable Java app. /// @@ -31,28 +33,72 @@ public static IResourceBuilder AddJavaApp(this IDistr ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory, nameof(workingDirectory)); ArgumentException.ThrowIfNullOrWhiteSpace(jarPath, nameof(jarPath)); - workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); + var rb = builder.AddJavaApp(name, workingDirectory); + rb.Resource.JarPath = jarPath; - var resource = new JavaAppExecutableResource(name, workingDirectory) + if (args is { Length: > 0 }) { - JarPath = jarPath - }; + rb.WithArgs(args); + } - return builder.AddResource(resource) + return rb; + } + + /// + /// Adds a Java application to the application model. Executes the executable Java app. + /// + /// The to add the resource to. + /// The name of the resource. + /// The working directory to use for the command. If null, the working directory of the current process is used. + /// A reference to the . + /// + /// Use or to run the application via a build tool, + /// or use the overload that accepts a jarPath parameter to run with java -jar. + /// + public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory, nameof(workingDirectory)); + + workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); + + var resource = new JavaAppExecutableResource(name, workingDirectory); + + var resourceBuilder = builder.AddResource(resource) .WithOtelAgent() .WithArgs(context => { - context.Args.Add("-jar"); - context.Args.Add(resource.JarPath); - - if (args is { Length: > 0 }) + if (resource.TryGetLastAnnotation(out var buildTool)) { - foreach (var arg in args) + foreach (var arg in buildTool.Args) { context.Args.Add(arg); } } + else if (resource.JarPath is not null) + { + context.Args.Add("-jar"); + context.Args.Add(resource.JarPath); + } }); + + resourceBuilder.PublishAsJavaDockerfile(workingDirectory); + + if (builder.ExecutionContext.IsRunMode) + { + builder.Eventing.Subscribe((_, _) => + { + if (resource.TryGetLastAnnotation(out var buildTool)) + { + resourceBuilder.WithCommand(buildTool.WrapperPath); + } + + return Task.CompletedTask; + }); + } + + return resourceBuilder; } /// @@ -201,7 +247,7 @@ public static IResourceBuilder WithGradleBuild( ArgumentNullException.ThrowIfNull(builder, nameof(builder)); string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; - + wrapperScript ??= $"gradlew{extension}"; string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); @@ -239,30 +285,78 @@ private static IResourceBuilder WithJavaBuildStep + /// Configures the Java application to run using a Maven goal (e.g., spring-boot:run). + /// In run mode, the resource command is changed from java to the Maven wrapper. + /// + /// The to configure. + /// The Maven goal to execute (e.g., spring-boot:run). + /// Additional arguments to pass to the Maven wrapper. + /// A reference to the . + public static IResourceBuilder WithMavenGoal( + this IResourceBuilder builder, + string goal, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(goal, nameof(goal)); + + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".cmd" : string.Empty; + string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"mvnw{extension}")); + + builder.Resource.Annotations.Add( + new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [goal, .. args] : [goal])); + + return builder; + } + + /// + /// Configures the Java application to run using a Gradle task (e.g., bootRun). + /// In run mode, the resource command is changed from java to the Gradle wrapper. + /// + /// The to configure. + /// The Gradle task to execute (e.g., bootRun). + /// Additional arguments to pass to the Gradle wrapper. + /// A reference to the . + public static IResourceBuilder WithGradleTask( + this IResourceBuilder builder, + string task, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(task, nameof(task)); + + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; + string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"gradlew{extension}")); + + builder.Resource.Annotations.Add( + new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [task, .. args] : [task])); return builder; } /// /// Configures the Java Virtual Machine arguments for the Java application. + /// The arguments are set via the JAVA_TOOL_OPTIONS environment variable, + /// which is recognized by the JVM regardless of how the application is launched + /// (e.g., java -jar, Maven wrapper, or Gradle wrapper). /// /// The resource builder. /// The JVM arguments. /// A reference to the . - public static IResourceBuilder WithJvmArgs( - this IResourceBuilder builder, - params string[] args) + public static IResourceBuilder WithJvmArgs( + this IResourceBuilder builder, + params string[] args) where T : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - return builder.WithArgs(context => + return builder.WithEnvironment(context => { - for (int i = args.Length - 1; i >= 0; i--) - { - context.Args.Insert(0, args[i]); - } + AppendJavaToolOptions(context, args); }); } @@ -282,17 +376,43 @@ public static IResourceBuilder WithOtelAgent( if (!string.IsNullOrEmpty(agentPath)) { - builder.WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{agentPath}"); + builder.WithEnvironment(context => + { + AppendJavaToolOptions(context, [$"-javaagent:{agentPath}"]); + }); } return builder; } + /// + /// Merges the specified values into the JAVA_TOOL_OPTIONS environment variable. + /// This ensures that all JVM arguments are passed to the Java application regardless of how it is launched. + /// + /// The environment callback context. + /// The values to append. + /// A reference to the . + private static void AppendJavaToolOptions(EnvironmentCallbackContext context, string[] values) + { + var value = string.Join(' ', values); + + if (context.EnvironmentVariables.TryGetValue(JAVA_TOOL_OPTIONS, out var existing) && + existing is string existingValue && + !string.IsNullOrEmpty(existingValue)) + { + context.EnvironmentVariables[JAVA_TOOL_OPTIONS] = $"{existingValue} {value}"; + } + else + { + context.EnvironmentVariables[JAVA_TOOL_OPTIONS] = value; + } + } + [Obsolete("Use WithOtelAgent instead.")] private static IResourceBuilder WithJavaDefaults( this IResourceBuilder builder, JavaAppExecutableResourceOptions options) => builder.WithOtlpExporter() - .WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") + .WithEnvironment(JAVA_TOOL_OPTIONS, $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") .WithEnvironment("SERVER_PORT", options.Port.ToString(CultureInfo.InvariantCulture)); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs new file mode 100644 index 000000000..62763bc01 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs @@ -0,0 +1,19 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a metadata annotation that specifies the build tool used to run the Java application. +/// +/// The full path to the build tool wrapper script. +/// The arguments to pass to the build tool (e.g., the goal or task name). +internal sealed class JavaBuildToolAnnotation(string wrapperPath, string[] args) : IResourceAnnotation +{ + /// + /// The full path to the build tool wrapper script (e.g., mvnw or gradlew). + /// + public string WrapperPath { get; } = wrapperPath; + + /// + /// The arguments to pass to the build tool. + /// + public string[] Args { get; } = args; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index 0f0d3ede9..cf3755a0f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -1,113 +1,104 @@ -# CommunityToolkit.Aspire.Hosting.Java library +# CommunityToolkit.Aspire.Hosting.Java -Provides extension methods and resource definitions for the Aspire AppHost to support running Java applications. +Provides extension methods and resource definitions for the .NET Aspire AppHost to support running Java applications. The integration supports Maven, Gradle, and standalone JAR-based applications. -## Getting Started +## Install the package -### Install the package - -In your AppHost project, install the package using the following command: +In your AppHost project, install the package: ```dotnetcli dotnet add package CommunityToolkit.Aspire.Hosting.Java ``` -### Example usage +## Add a Java application resource + +### Run with Maven -Then, in the _Program.cs_ file of `AppHost`, define a Java resource, then call `AddJavaApp`: +Use `WithMavenGoal` to run the application using a Maven goal: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithMavenBuild() - .WithHttpEndpoint(env: "SERVER_PORT"); +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -The `SERVER_PORT` environment variable is used to determine the port the Java application should listen on. It is randomly assigned by Aspire. The name of the environment variable can be changed by passing a different value to the `WithHttpEndpoint` method. - -The `jarPath` parameter specifies the path to the jar file, relative to the resource working directory. - -### Containerized Java applications - -To run a containerized Java application, use `AddJavaContainerApp`: +Pass additional arguments: ```csharp -var javaApp = builder.AddJavaContainerApp("javaApp", "my-java-image", "latest"); +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run", "-Dspring-boot.run.profiles=dev") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -## Build Systems - -The integration provides support for Maven and Gradle build systems. Build resources are created in run mode only — they do not run when publishing, as the generated Dockerfile handles the build automatically. +### Run with Gradle -### Maven - -To run a Maven build (`mvnw clean package`) before the application starts: +Use `WithGradleTask` to run the application using a Gradle task: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithMavenBuild(); +var app = builder.AddJavaApp("app", "../java-project") + .WithGradleTask("bootRun") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -### Gradle +### Run with a JAR file -To run a Gradle build (`gradlew clean build`) before the application starts: +To run an existing JAR file, pass the `jarPath` parameter: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "build/libs/app.jar") - .WithGradleBuild(); +var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -### Custom build arguments +## Build before run -You can provide custom build arguments, replacing the defaults (`clean package` for Maven, `clean build` for Gradle): +Use `WithMavenBuild` or `WithGradleBuild` to compile the application before it starts. This is typically not needed when using `WithMavenGoal` or `WithGradleTask`, as those goals usually handle building automatically. ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithMavenBuild(args: ["clean", "install", "-DskipTests"]); +var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithMavenBuild() + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -### Custom wrapper script +## Add a containerized Java application -You can override the wrapper script path, relative to the resource working directory: +To run a Java application from a container image, use `AddJavaContainerApp`: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "build/libs/app.jar") - .WithGradleBuild(wrapperScript: "scripts/gradlew"); +var app = builder.AddJavaContainerApp("app", "my-java-image", "latest") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -## JVM Configuration +## JVM configuration ### JVM arguments -You can customize the JVM arguments for the Java application: +Use `WithJvmArgs` to configure JVM arguments: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithJvmArgs("-Xmx512m", "-Xms256m"); +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run") + .WithJvmArgs("-Xmx512m", "-Xms256m") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -### OpenTelemetry Agent +### OpenTelemetry agent -You can configure the OpenTelemetry Java Agent: +Use `WithOtelAgent` to configure the OpenTelemetry Java Agent: ```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithOtelAgent("../agents/opentelemetry-javaagent.jar"); +var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithOtelAgent("../agents/opentelemetry-javaagent.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` ## Publishing -When publishing your Aspire application with a Maven or Gradle build configured, the Java resource automatically generates a multi-stage Dockerfile for containerization. To enable this, call `PublishAsJavaDockerfile`: - -```csharp -var javaApp = builder.AddJavaApp("javaApp", "../java-project", "target/app.jar") - .WithMavenBuild() - .PublishAsJavaDockerfile(); -``` +When publishing, the Java resource automatically generates a multi-stage Dockerfile for containerization. If a Maven or Gradle build is configured (via `WithMavenBuild` or `WithGradleBuild`), the Dockerfile uses the build tool to compile the application inside the container. -## Additional Information +## Additional information -https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java +- [Aspire Community Toolkit: Java hosting](https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java) -## Feedback & contributing +## Feedback and contributing -https://github.com/CommunityToolkit/Aspire +- [GitHub repository](https://github.com/CommunityToolkit/Aspire) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs index 74240fa63..b3e9d40ea 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs @@ -76,7 +76,7 @@ public void AddJavaAppContainerResourceOptionsShouldNotBeNull() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp("java", null!)); + Assert.Throws(() => builder.AddJavaApp("java", (JavaAppContainerResourceOptions)null!)); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index f98e942c4..3d97521ba 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Java.Tests; @@ -58,14 +59,17 @@ public async Task AddJavaAppDetailsSetOnResource() Assert.Equal("java", resource.Command); var args = await resource.GetArgumentListAsync(); - Assert.Equal("-Dtest", args[0]); Assert.Contains("-jar", args); Assert.Contains("test.jar", args); Assert.Contains("--test", args); + + var config = await resource.GetEnvironmentVariablesAsync(); + Assert.True(config.TryGetValue("JAVA_TOOL_OPTIONS", out var jvmArgs)); + Assert.Contains("-Dtest", jvmArgs); } [Fact] - public async Task JavaAppWithJvmArgsHasCorrectArgsAsync() + public async Task JavaAppWithJvmArgsSetsJavaToolOptionsEnvironmentVariable() { var appModel = BuildAppModel(builder => builder.AddJavaApp("javaapp", "../java-project", "target/app.jar") @@ -73,8 +77,11 @@ public async Task JavaAppWithJvmArgsHasCorrectArgsAsync() var resource = Assert.Single(appModel.Resources.OfType()); + var config = await resource.GetEnvironmentVariablesAsync(); + Assert.True(config.TryGetValue("JAVA_TOOL_OPTIONS", out var value)); + Assert.Contains("-Xmx512m", value); + var args = await resource.GetArgumentListAsync(); - Assert.Equal("-Xmx512m", args[0]); Assert.Contains("-jar", args); Assert.Contains("target/app.jar", args); } @@ -197,6 +204,92 @@ public void DeprecatedAddJavaAppBuilderShouldNotBeNull() } #pragma warning restore CS0618 + [Fact] + public async Task MavenGoalCreatesResourceWithCorrectAnnotation() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetLastAnnotation(out var annotation)); + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mvnw.cmd" : "mvnw"; + Assert.Equal(Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, expectedWrapper)), annotation.WrapperPath); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal("spring-boot:run", args[0]); + } + + [Fact] + public async Task MavenGoalWithCustomArgsAddsArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run", args: ["-Dspring-boot.run.profiles=dev"])); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal(["spring-boot:run", "-Dspring-boot.run.profiles=dev"], args); + } + + [Fact] + public async Task MavenGoalDoesNotAddJarArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + Assert.DoesNotContain("-jar", args); + } + + [Fact] + public async Task GradleTaskCreatesResourceWithCorrectAnnotation() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetLastAnnotation(out var annotation)); + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "gradlew.bat" : "gradlew"; + Assert.Equal(Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, expectedWrapper)), annotation.WrapperPath); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal("bootRun", args[0]); + } + + [Fact] + public async Task GradleTaskWithCustomArgsAddsArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun", args: ["--args=--spring.profiles.active=dev"])); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal(["bootRun", "--args=--spring.profiles.active=dev"], args); + } + + [Fact] + public async Task GradleTaskDoesNotAddJarArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + Assert.DoesNotContain("-jar", args); + } + private static DistributedApplicationModel BuildAppModel(Action configure) { var builder = DistributedApplication.CreateBuilder(); From aa1769ca3ce85c933d1d8744b2024dea1c16310f Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:57:14 -0600 Subject: [PATCH 06/16] Remove auto-generated Dockerfile logic and fix review issues Remove PublishAsJavaDockerfile and JavaBuildAnnotation, which were new functionality not present in the existing integration. Auto-Dockerfile generation can be revisited as a separate feature. Fix deprecated AddSpringApp (container) obsolete message to point to AddJavaContainerApp instead of the also-deprecated AddJavaApp. Restore endpoint assertions in container resource tests. Update README publishing section to reflect manual PublishAsDockerFile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppExecutableResource.cs | 5 +- .../JavaAppHostingExtension.Container.cs | 2 +- .../JavaAppHostingExtension.Executable.cs | 53 ------------------- .../JavaBuildAnnotation.cs | 19 ------- .../README.md | 4 -- .../ContainerResourceCreationTests.cs | 4 ++ 6 files changed, 6 insertions(+), 81 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildAnnotation.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs index 02ffc4c23..a3d243407 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -4,7 +4,7 @@ /// A resource that represents a Java application. /// public class JavaAppExecutableResource - : ExecutableResource, IResourceWithServiceDiscovery, IContainerFilesDestinationResource, IResourceWithWaitSupport + : ExecutableResource, IResourceWithServiceDiscovery, IResourceWithWaitSupport { internal const string HttpEndpointName = "http"; @@ -30,9 +30,6 @@ public JavaAppExecutableResource(string name, string command, string workingDire { } - /// - public string? ContainerFilesDestination => "/app/static"; - /// /// Gets or sets the path to the JAR file to execute. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index 96e958a0b..a8b8cc541 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -68,7 +68,7 @@ public static IResourceBuilder AddJavaApp(this IDistri /// The name of the resource. /// The to configure the Java application." /// A reference to the . - [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] + [Obsolete("Use AddJavaContainerApp instead. This method will be removed in a future version.")] public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => builder.AddJavaApp(name, options); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 1d34f47df..ae68a46f1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -1,11 +1,7 @@ using System.Globalization; using System.Runtime.InteropServices; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using CommunityToolkit.Aspire.Utils; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting; @@ -83,8 +79,6 @@ public static IResourceBuilder AddJavaApp(this IDistr } }); - resourceBuilder.PublishAsJavaDockerfile(workingDirectory); - if (builder.ExecutionContext.IsRunMode) { builder.Eventing.Subscribe((_, _) => @@ -101,46 +95,6 @@ public static IResourceBuilder AddJavaApp(this IDistr return resourceBuilder; } - /// - /// Configures the Java application to be published as a Dockerfile with automatic multi-stage build generation. - /// - /// The resource builder. - /// The working directory containing the Java application. - /// A reference to the . -#pragma warning disable ASPIREDOCKERFILEBUILDER001 - private static IResourceBuilder PublishAsJavaDockerfile( - this IResourceBuilder builder, - string workingDirectory) - { - return builder.PublishAsDockerFile(publish => - { - publish.WithDockerfileBuilder(workingDirectory, context => - { - if (!builder.Resource.TryGetLastAnnotation(out var buildAnnotation)) - { - buildAnnotation = new JavaBuildAnnotation("eclipse-temurin:21-jdk-alpine", "./mvnw clean package -DskipTests"); - } - - var buildStage = context.Builder - .From(buildAnnotation.BuildImage, "builder") - .WorkDir("/build") - .Copy(".", "./") - .Run(buildAnnotation.BuildCommand); - - var logger = context.Services.GetService>() ?? NullLogger.Instance; - - context.Builder - .From("eclipse-temurin:21-jre-alpine") - .Run("apk --no-cache add ca-certificates") - .WorkDir("/app") - .CopyFrom(buildStage.StageName!, $"/build/{builder.Resource.JarPath}", "/app/app.jar") - .AddContainerFiles(context.Resource, "/app", logger) - .Entrypoint(["java", "-jar", "/app/app.jar"]); - }); - }); - } -#pragma warning restore ASPIREDOCKERFILEBUILDER001 - /// /// Adds a Java application to the application model. Executes the executable Java app. /// @@ -279,13 +233,6 @@ private static IResourceBuilder WithJavaBuildStep -/// Represents a metadata annotation that specifies Java build options for publishing. -/// -/// The build image to use. -/// The build command to run. -internal sealed class JavaBuildAnnotation(string buildImage, string buildCommand) : IResourceAnnotation -{ - /// - /// The image to use for building the Java application. - /// - public string BuildImage { get; } = buildImage; - - /// - /// The command to run to build the Java application. - /// - public string BuildCommand { get; } = buildCommand; -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index cf3755a0f..78278a06d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -91,10 +91,6 @@ var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -## Publishing - -When publishing, the Java resource automatically generates a multi-stage Dockerfile for containerization. If a Maven or Gradle build is configured (via `WithMavenBuild` or `WithGradleBuild`), the Dockerfile uses the build tool to compile the application inside the container. - ## Additional information - [Aspire Community Toolkit: Java hosting](https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs index b3e9d40ea..d782386e6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ContainerResourceCreationTests.cs @@ -119,6 +119,10 @@ public async Task AddJavaAppContainerDetailsSetOnResource() Assert.Equal(options.ContainerRegistry, imageAnnotations.Registry); Assert.Equal(options.ContainerImageTag, imageAnnotations.Tag); + Assert.True(resource.TryGetLastAnnotation(out EndpointAnnotation? httpEndpointAnnotations)); + Assert.Equal(options.Port, httpEndpointAnnotations.Port); + Assert.Equal(options.TargetPort, httpEndpointAnnotations.TargetPort); + Assert.True(resource.TryGetLastAnnotation(out CommandLineArgsCallbackAnnotation? argsAnnotations)); CommandLineArgsCallbackContext context = new([]); await argsAnnotations.Callback(context); From 17c2986e3fa0dfeea203adb36d8898001b181e52 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:07:50 -0600 Subject: [PATCH 07/16] Fix trailing double quotes and extra blank lines in XML docs Remove stray double quote characters after tags and remove unnecessary blank lines in Container and Executable extensions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Container.cs | 5 ++--- .../JavaAppHostingExtension.Executable.cs | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index a8b8cc541..e5461b1eb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -35,7 +35,7 @@ public static IResourceBuilder AddJavaContainerApp(thi /// /// The to add the resource to. /// The name of the resource. - /// The to configure the Java application." + /// The to configure the Java application. /// A reference to the . [Obsolete("Use AddJavaContainerApp instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) @@ -60,13 +60,12 @@ public static IResourceBuilder AddJavaApp(this IDistri return rb; } - /// /// Adds a Spring application to the application model. Executes the containerized Spring app. /// /// The to add the resource to. /// The name of the resource. - /// The to configure the Java application." + /// The to configure the Java application. /// A reference to the . [Obsolete("Use AddJavaContainerApp instead. This method will be removed in a future version.")] public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) => diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index ae68a46f1..5d9406ce7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -101,7 +101,7 @@ public static IResourceBuilder AddJavaApp(this IDistr /// The to add the resource to. /// The name of the resource. /// The working directory to use for the command. If null, the working directory of the current process is used. - /// The to configure the Java application." + /// The to configure the Java application. /// A reference to the . [Obsolete("Use AddJavaApp(string, string, string, string[]?) instead. This method will be removed in a future version.")] public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) @@ -129,14 +129,13 @@ public static IResourceBuilder AddJavaApp(this IDistr .WithArgs(allArgs); } - /// /// Adds a Spring application to the application model. Executes the executable Spring app. /// /// The to add the resource to. /// The name of the resource. /// The working directory to use for the command. If null, the working directory of the current process is used. - /// The to configure the Java application." + /// The to configure the Java application. /// A reference to the . [Obsolete("Use AddJavaApp instead. This method will be removed in a future version.")] public static IResourceBuilder AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) => @@ -158,7 +157,6 @@ public static IResourceBuilder WithMavenBuild( return builder.WithMavenBuild(args: mavenOptions.Args); } - /// /// Adds a Maven build step to the application model. /// From 8959013aeb43a9eca27090f9a54ed73b1d5c9862 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:10:20 -0600 Subject: [PATCH 08/16] Rename JAVA_TOOL_OPTIONS constant to PascalCase Follow C# naming conventions for constants by renaming JAVA_TOOL_OPTIONS to JavaToolOptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Executable.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 5d9406ce7..8e065f031 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting; /// public static partial class JavaAppHostingExtension { - private const string JAVA_TOOL_OPTIONS = "JAVA_TOOL_OPTIONS"; + private const string JavaToolOptions = "JAVA_TOOL_OPTIONS"; /// /// Adds a Java application to the application model. Executes the executable Java app. @@ -341,15 +341,15 @@ private static void AppendJavaToolOptions(EnvironmentCallbackContext context, st { var value = string.Join(' ', values); - if (context.EnvironmentVariables.TryGetValue(JAVA_TOOL_OPTIONS, out var existing) && + if (context.EnvironmentVariables.TryGetValue(JavaToolOptions, out var existing) && existing is string existingValue && !string.IsNullOrEmpty(existingValue)) { - context.EnvironmentVariables[JAVA_TOOL_OPTIONS] = $"{existingValue} {value}"; + context.EnvironmentVariables[JavaToolOptions] = $"{existingValue} {value}"; } else { - context.EnvironmentVariables[JAVA_TOOL_OPTIONS] = value; + context.EnvironmentVariables[JavaToolOptions] = value; } } @@ -358,6 +358,6 @@ private static IResourceBuilder WithJavaDefaults( this IResourceBuilder builder, JavaAppExecutableResourceOptions options) => builder.WithOtlpExporter() - .WithEnvironment(JAVA_TOOL_OPTIONS, $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") + .WithEnvironment(JavaToolOptions, $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar") .WithEnvironment("SERVER_PORT", options.Port.ToString(CultureInfo.InvariantCulture)); } From 1694ca1d1f7bfae7446711515d55ccdd04715076 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:46:58 -0600 Subject: [PATCH 09/16] Pass wrapperScript to build resource constructors instead of overriding with WithCommand MavenBuildResource and GradleBuildResource now accept the resolved wrapper script path in their constructors, eliminating the need to pass a placeholder ("mvnw"/"gradlew") and immediately override it with .WithCommand(). This aligns with the Aspire convention where ExecutableResource receives the actual command at construction time (e.g. PythonAppResource, NodeAppResource). Addresses PR review comments r2844673239 and r2844673316. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GradleBuildResource.cs | 5 +++-- .../JavaAppHostingExtension.Executable.cs | 9 ++++----- .../MavenBuildResource.cs | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs index 4e4ae739b..4a235f339 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs @@ -4,6 +4,7 @@ namespace Aspire.Hosting.ApplicationModel; /// A resource that represents a Gradle build step. /// /// The name of the resource. +/// The full path to the Gradle wrapper script. /// The working directory to use for the command. -public class GradleBuildResource(string name, string workingDirectory) - : ExecutableResource(name, "gradlew", workingDirectory); +public class GradleBuildResource(string name, string wrapperScript, string workingDirectory) + : ExecutableResource(name, wrapperScript, workingDirectory); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 8e065f031..af4436c93 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -179,7 +179,7 @@ public static IResourceBuilder WithMavenBuild( return builder.WithJavaBuildStep( buildResourceName: $"{builder.Resource.Name}-maven-build", - createResource: (name, workingDirectory) => new MavenBuildResource(name, workingDirectory), + createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory), wrapperPath: wrapperPath, buildArgs: args.Length > 0 ? args : ["clean", "package"]); } @@ -206,7 +206,7 @@ public static IResourceBuilder WithGradleBuild( return builder.WithJavaBuildStep( buildResourceName: $"{builder.Resource.Name}-gradle-build", - createResource: (name, workingDirectory) => new GradleBuildResource(name, workingDirectory), + createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory), wrapperPath: wrapperPath, buildArgs: args.Length > 0 ? args : ["clean", "build"]); } @@ -214,16 +214,15 @@ public static IResourceBuilder WithGradleBuild( private static IResourceBuilder WithJavaBuildStep( this IResourceBuilder builder, string buildResourceName, - Func createResource, + Func createResource, string wrapperPath, string[] buildArgs) where TBuildResource : ExecutableResource { if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - var buildResource = createResource(buildResourceName, builder.Resource.WorkingDirectory); + var buildResource = createResource(buildResourceName, wrapperPath, builder.Resource.WorkingDirectory); var buildBuilder = builder.ApplicationBuilder.AddResource(buildResource) - .WithCommand(wrapperPath) .WithArgs(buildArgs) .WithParentRelationship(builder.Resource) .ExcludeFromManifest(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs index 9f9760d33..ce8daafb0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs @@ -4,6 +4,7 @@ namespace Aspire.Hosting.ApplicationModel; /// A resource that represents a Maven build step. /// /// The name of the resource. +/// The full path to the Maven wrapper script. /// The working directory to use for the command. -public class MavenBuildResource(string name, string workingDirectory) - : ExecutableResource(name, "mvnw", workingDirectory); +public class MavenBuildResource(string name, string wrapperScript, string workingDirectory) + : ExecutableResource(name, wrapperScript, workingDirectory); From baefcaef1a833b49afeebee9e29ad9e60d055413 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:57:02 -0600 Subject: [PATCH 10/16] Replace WithOtelAgent() with WithOtlpExporter() in new methods AddJavaContainerApp and the new AddJavaApp called WithOtelAgent() with no agentPath, which just delegates to WithOtlpExporter() anyway. Call WithOtlpExporter() directly to avoid implying an OTel agent is configured. Users who need the agent can chain WithOtelAgent(path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Container.cs | 2 +- .../JavaAppHostingExtension.Executable.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs index e5461b1eb..d0861823b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Container.cs @@ -27,7 +27,7 @@ public static IResourceBuilder AddJavaContainerApp(thi return builder.AddResource(resource) .WithImage(image, imageTag) - .WithOtelAgent(); + .WithOtlpExporter(); } /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index af4436c93..c6dc5d3c5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -62,7 +62,7 @@ public static IResourceBuilder AddJavaApp(this IDistr var resource = new JavaAppExecutableResource(name, workingDirectory); var resourceBuilder = builder.AddResource(resource) - .WithOtelAgent() + .WithOtlpExporter() .WithArgs(context => { if (resource.TryGetLastAnnotation(out var buildTool)) From eafe3b45f01c420278b687618d6d9241873253f5 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:06:11 -0600 Subject: [PATCH 11/16] Make JarPath nullable with no default The Maven-specific default was misleading for Gradle users and unused by the WithMavenGoal/WithGradleTask code path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppExecutableResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs index a3d243407..7aef11086 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -33,5 +33,5 @@ public JavaAppExecutableResource(string name, string command, string workingDire /// /// Gets or sets the path to the JAR file to execute. /// - public string JarPath { get; set; } = "target/app.jar"; + public string? JarPath { get; set; } } From 5d6d25f1957a2a62f7160930e418d8904c950d33 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:26:45 -0600 Subject: [PATCH 12/16] Disallow use of jar path when using WithMavenGoal/WithGradleTask Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Executable.cs | 10 ++++++++++ .../ExecutableResourceCreationTests.cs | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index c6dc5d3c5..1bff52594 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -249,6 +249,11 @@ public static IResourceBuilder WithMavenGoal( ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentException.ThrowIfNullOrWhiteSpace(goal, nameof(goal)); + if (builder.Resource.JarPath is not null) + { + throw new InvalidOperationException($"{nameof(WithMavenGoal)} cannot be used when a {nameof(JavaAppExecutableResource.JarPath)} has been specified. Use either {nameof(AddJavaApp)} with a {nameof(JavaAppExecutableResource.JarPath)} or {nameof(WithMavenGoal)}, not both."); + } + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".cmd" : string.Empty; string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"mvnw{extension}")); @@ -274,6 +279,11 @@ public static IResourceBuilder WithGradleTask( ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentException.ThrowIfNullOrWhiteSpace(task, nameof(task)); + if (builder.Resource.JarPath is not null) + { + throw new InvalidOperationException($"{nameof(WithGradleTask)} cannot be used when a {nameof(JavaAppExecutableResource.JarPath)} has been specified. Use either {nameof(AddJavaApp)} with a {nameof(JavaAppExecutableResource.JarPath)} or {nameof(WithGradleTask)}, not both."); + } + string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"gradlew{extension}")); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index 3d97521ba..6b2560fea 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -290,6 +290,26 @@ public async Task GradleTaskDoesNotAddJarArgs() Assert.DoesNotContain("-jar", args); } + [Fact] + public void WithMavenGoalThrowsWhenJarPathIsSet() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => + builder.AddJavaApp("java", Environment.CurrentDirectory, "my-app.jar") + .WithMavenGoal("spring-boot:run")); + } + + [Fact] + public void WithGradleTaskThrowsWhenJarPathIsSet() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => + builder.AddJavaApp("java", Environment.CurrentDirectory, "my-app.jar") + .WithGradleTask("bootRun")); + } + private static DistributedApplicationModel BuildAppModel(Action configure) { var builder = DistributedApplication.CreateBuilder(); From 67cf1b2947054bab9899debbcd0cd42e8eb5ecfd Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:37:28 -0600 Subject: [PATCH 13/16] Remove BeforeStartEvent subscription for command override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Executable.cs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 1bff52594..aac187977 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -79,19 +79,6 @@ public static IResourceBuilder AddJavaApp(this IDistr } }); - if (builder.ExecutionContext.IsRunMode) - { - builder.Eventing.Subscribe((_, _) => - { - if (resource.TryGetLastAnnotation(out var buildTool)) - { - resourceBuilder.WithCommand(buildTool.WrapperPath); - } - - return Task.CompletedTask; - }); - } - return resourceBuilder; } @@ -260,6 +247,11 @@ public static IResourceBuilder WithMavenGoal( builder.Resource.Annotations.Add( new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [goal, .. args] : [goal])); + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.WithCommand(wrapperPath); + } + return builder; } @@ -290,6 +282,11 @@ public static IResourceBuilder WithGradleTask( builder.Resource.Annotations.Add( new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [task, .. args] : [task])); + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.WithCommand(wrapperPath); + } + return builder; } From 46d320c34ed6e8ba6109b5ee190dd29ffdaebf01 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:16:32 -0600 Subject: [PATCH 14/16] Improve Java hosting API correctness and compat - WithJvmArgs: remove params, require explicit string[] args with null check and empty-array early return - Fix XML docs: remove incorrect 'If null, falls back to current process directory' claim from workingDirectory params (non-nullable) - Obsolete WithMavenBuild(MavenOptions): pass Command as wrapperScript to preserve existing callers' custom wrapper commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppExecutableResource.cs | 4 ++-- .../JavaAppHostingExtension.Executable.cs | 16 +++++++++++----- .../README.md | 2 +- .../ExecutableResourceCreationTests.cs | 4 ++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs index 7aef11086..a5aa87c7c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -12,7 +12,7 @@ public class JavaAppExecutableResource /// Initializes a new instance of the class. /// /// The name of the resource. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. public JavaAppExecutableResource(string name, string workingDirectory) : base(name, "java", workingDirectory) { @@ -23,7 +23,7 @@ public JavaAppExecutableResource(string name, string workingDirectory) /// /// The name of the resource. /// The command to execute. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. [Obsolete("Use JavaAppExecutableResource(string, string) instead. This constructor will be removed in a future version.")] public JavaAppExecutableResource(string name, string command, string workingDirectory) : base(name, command, workingDirectory) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index aac187977..3b4934224 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -17,7 +17,7 @@ public static partial class JavaAppHostingExtension /// /// The to add the resource to. /// The name of the resource. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. /// The path to the jar file, relative to the resource working directory. /// The optional arguments to be passed to the executable when it is started. /// A reference to the . @@ -45,7 +45,7 @@ public static IResourceBuilder AddJavaApp(this IDistr /// /// The to add the resource to. /// The name of the resource. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. /// A reference to the . /// /// Use or to run the application via a build tool, @@ -87,7 +87,7 @@ public static IResourceBuilder AddJavaApp(this IDistr /// /// The to add the resource to. /// The name of the resource. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. /// The to configure the Java application. /// A reference to the . [Obsolete("Use AddJavaApp(string, string, string, string[]?) instead. This method will be removed in a future version.")] @@ -141,7 +141,7 @@ public static IResourceBuilder WithMavenBuild( { ArgumentNullException.ThrowIfNull(mavenOptions, nameof(mavenOptions)); - return builder.WithMavenBuild(args: mavenOptions.Args); + return builder.WithMavenBuild(wrapperScript: mavenOptions.Command, args: mavenOptions.Args); } /// @@ -301,9 +301,15 @@ public static IResourceBuilder WithGradleTask( /// A reference to the . public static IResourceBuilder WithJvmArgs( this IResourceBuilder builder, - params string[] args) where T : IResourceWithEnvironment + string[] args) where T : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(args, nameof(args)); + + if (args.Length == 0) + { + return builder; + } return builder.WithEnvironment(context => { diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index 78278a06d..aec6ce2b5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -77,7 +77,7 @@ Use `WithJvmArgs` to configure JVM arguments: ```csharp var app = builder.AddJavaApp("app", "../java-project") .WithMavenGoal("spring-boot:run") - .WithJvmArgs("-Xmx512m", "-Xms256m") + .WithJvmArgs(["-Xmx512m", "-Xms256m"]) .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index 6b2560fea..f97da8d76 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -50,7 +50,7 @@ public async Task AddJavaAppDetailsSetOnResource() { var appModel = BuildAppModel(builder => builder.AddJavaApp("java", Environment.CurrentDirectory, "test.jar", args: ["--test"]) - .WithJvmArgs("-Dtest")); + .WithJvmArgs(["-Dtest"])); var resource = Assert.Single(appModel.Resources.OfType()); @@ -73,7 +73,7 @@ public async Task JavaAppWithJvmArgsSetsJavaToolOptionsEnvironmentVariable() { var appModel = BuildAppModel(builder => builder.AddJavaApp("javaapp", "../java-project", "target/app.jar") - .WithJvmArgs("-Xmx512m")); + .WithJvmArgs(["-Xmx512m"])); var resource = Assert.Single(appModel.Resources.OfType()); From 7b87219ce95f5a2fdec38ae9b78583ade14c68e1 Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:52:32 -0600 Subject: [PATCH 15/16] Replace Microsoft Learn link with aspire.dev --- src/CommunityToolkit.Aspire.Hosting.Java/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index aec6ce2b5..65d7155c0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -93,7 +93,7 @@ var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") ## Additional information -- [Aspire Community Toolkit: Java hosting](https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java) +- [Aspire Community Toolkit: Java hosting](https://aspire.dev/integrations/frameworks/java/) ## Feedback and contributing From 8fa843ba06e5e03f75d414c1afde3c10fe83c04f Mon Sep 17 00:00:00 2001 From: Marshal Hayes <17213165+marshalhayes@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:27:59 -0600 Subject: [PATCH 16/16] Add WithWrapperPath for custom build tool wrapper scripts Allow overriding the default mvnw/gradlew wrapper path via a composable WithWrapperPath method. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaAppHostingExtension.Executable.cs | 85 +++++++++++++------ .../ExecutableResourceCreationTests.cs | 69 ++++++++++++++- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 3b4934224..48ce5901d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -11,6 +11,8 @@ namespace Aspire.Hosting; public static partial class JavaAppHostingExtension { private const string JavaToolOptions = "JAVA_TOOL_OPTIONS"; + private static readonly string DefaultMavenWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mvnw.cmd" : "mvnw"; + private static readonly string DefaultGradleWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "gradlew.bat" : "gradlew"; /// /// Adds a Java application to the application model. Executes the executable Java app. @@ -141,60 +143,64 @@ public static IResourceBuilder WithMavenBuild( { ArgumentNullException.ThrowIfNull(mavenOptions, nameof(mavenOptions)); - return builder.WithMavenBuild(wrapperScript: mavenOptions.Command, args: mavenOptions.Args); + // Only set a custom wrapper when the command differs from the default "mvnw". + // The default case is handled by WithMavenBuild, which applies the correct + // platform-specific wrapper name (e.g., mvnw.cmd on Windows). + if (!string.Equals(mavenOptions.Command, "mvnw", StringComparison.Ordinal)) + { + builder.WithWrapperPath(mavenOptions.Command); + } + + return builder.WithMavenBuild(args: mavenOptions.Args); } /// /// Adds a Maven build step to the application model. + /// The wrapper script path defaults to mvnw (or mvnw.cmd on Windows) in the resource's working directory, + /// unless overridden with . /// /// The to add the Maven build step to. - /// The path to the Maven wrapper script, relative to the resource working directory. If not provided, defaults to mvnw (or mvnw.cmd on Windows) in the working directory. /// Arguments to pass to the Maven wrapper. If not provided, defaults to clean package. /// A reference to the . public static IResourceBuilder WithMavenBuild( this IResourceBuilder builder, - string? wrapperScript = null, params string[] args) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".cmd" : string.Empty; - - wrapperScript ??= $"mvnw{extension}"; - - string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper)); return builder.WithJavaBuildStep( buildResourceName: $"{builder.Resource.Name}-maven-build", createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory), - wrapperPath: wrapperPath, + wrapperPath: resolvedWrapper, buildArgs: args.Length > 0 ? args : ["clean", "package"]); } /// /// Adds a Gradle build step to the application model. + /// The wrapper script path defaults to gradlew (or gradlew.bat on Windows) in the resource's working directory, + /// unless overridden with . /// /// The to add the Gradle build step to. - /// The path to the Gradle wrapper script, relative to the resource working directory. If not provided, defaults to gradlew (or gradlew.bat on Windows) in the working directory. /// Arguments to pass to the Gradle wrapper. If not provided, defaults to clean build. /// A reference to the . public static IResourceBuilder WithGradleBuild( this IResourceBuilder builder, - string? wrapperScript = null, params string[] args) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; - - wrapperScript ??= $"gradlew{extension}"; - - string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); return builder.WithJavaBuildStep( buildResourceName: $"{builder.Resource.Name}-gradle-build", createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory), - wrapperPath: wrapperPath, + wrapperPath: resolvedWrapper, buildArgs: args.Length > 0 ? args : ["clean", "build"]); } @@ -223,6 +229,8 @@ private static IResourceBuilder WithJavaBuildStep /// Configures the Java application to run using a Maven goal (e.g., spring-boot:run). /// In run mode, the resource command is changed from java to the Maven wrapper. + /// The wrapper script path defaults to mvnw (or mvnw.cmd on Windows) in the resource's working directory, + /// unless overridden with . /// /// The to configure. /// The Maven goal to execute (e.g., spring-boot:run). @@ -241,15 +249,16 @@ public static IResourceBuilder WithMavenGoal( throw new InvalidOperationException($"{nameof(WithMavenGoal)} cannot be used when a {nameof(JavaAppExecutableResource.JarPath)} has been specified. Use either {nameof(AddJavaApp)} with a {nameof(JavaAppExecutableResource.JarPath)} or {nameof(WithMavenGoal)}, not both."); } - string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".cmd" : string.Empty; - string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"mvnw{extension}")); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper)); builder.Resource.Annotations.Add( - new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [goal, .. args] : [goal])); + new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal])); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.WithCommand(wrapperPath); + builder.WithCommand(resolvedWrapper); } return builder; @@ -258,6 +267,8 @@ public static IResourceBuilder WithMavenGoal( /// /// Configures the Java application to run using a Gradle task (e.g., bootRun). /// In run mode, the resource command is changed from java to the Gradle wrapper. + /// The wrapper script path defaults to gradlew (or gradlew.bat on Windows) in the resource's working directory, + /// unless overridden with . /// /// The to configure. /// The Gradle task to execute (e.g., bootRun). @@ -276,15 +287,16 @@ public static IResourceBuilder WithGradleTask( throw new InvalidOperationException($"{nameof(WithGradleTask)} cannot be used when a {nameof(JavaAppExecutableResource.JarPath)} has been specified. Use either {nameof(AddJavaApp)} with a {nameof(JavaAppExecutableResource.JarPath)} or {nameof(WithGradleTask)}, not both."); } - string extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".bat" : string.Empty; - string wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, $"gradlew{extension}")); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); builder.Resource.Annotations.Add( - new JavaBuildToolAnnotation(wrapperPath, args.Length > 0 ? [task, .. args] : [task])); + new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task])); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.WithCommand(wrapperPath); + builder.WithCommand(resolvedWrapper); } return builder; @@ -365,6 +377,29 @@ existing is string existingValue && } } + /// + /// Configures a custom build tool wrapper script path. + /// This is useful when the wrapper script is not in the default location or has a non-standard name. + /// + /// The to configure. + /// The path to the wrapper script, relative to the resource working directory or an absolute path. + /// A reference to the . + public static IResourceBuilder WithWrapperPath( + this IResourceBuilder builder, + string wrapperScript) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(wrapperScript, nameof(wrapperScript)); + + var wrapperPath = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, wrapperScript)); + + builder.Resource.Annotations.Add(new WrapperAnnotation(wrapperPath)); + + return builder; + } + + internal sealed record WrapperAnnotation(string WrapperPath) : IResourceAnnotation; + [Obsolete("Use WithOtelAgent instead.")] private static IResourceBuilder WithJavaDefaults( this IResourceBuilder builder, diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs index f97da8d76..80fb894d3 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -126,7 +126,8 @@ public void MavenBuildWithCustomWrapperUsesProvidedPath() { var appModel = BuildAppModel(builder => builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") - .WithMavenBuild(wrapperScript: "custom/mvnw")); + .WithWrapperPath("custom/mvnw") + .WithMavenBuild()); var javaResource = Assert.Single(appModel.Resources.OfType()); var buildResource = Assert.Single(appModel.Resources.OfType()); @@ -173,7 +174,8 @@ public void GradleBuildWithCustomWrapperUsesProvidedPath() { var appModel = BuildAppModel(builder => builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") - .WithGradleBuild(wrapperScript: "custom/gradlew")); + .WithWrapperPath("custom/gradlew") + .WithGradleBuild()); var javaResource = Assert.Single(appModel.Resources.OfType()); var buildResource = Assert.Single(appModel.Resources.OfType()); @@ -290,6 +292,40 @@ public async Task GradleTaskDoesNotAddJarArgs() Assert.DoesNotContain("-jar", args); } + [Fact] + public async Task MavenGoalWithCustomWrapperScriptUsesCustomPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithWrapperPath("../../mvnw") + .WithMavenGoal("spring-boot:run")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetLastAnnotation(out var annotation)); + Assert.Equal(Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "../../mvnw")), annotation.WrapperPath); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal("spring-boot:run", args[0]); + } + + [Fact] + public async Task GradleTaskWithCustomWrapperScriptUsesCustomPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithWrapperPath("../gradlew") + .WithGradleTask("bootRun")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetLastAnnotation(out var annotation)); + Assert.Equal(Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "../gradlew")), annotation.WrapperPath); + + var args = await resource.GetArgumentListAsync(); + Assert.Equal("bootRun", args[0]); + } + [Fact] public void WithMavenGoalThrowsWhenJarPathIsSet() { @@ -310,6 +346,35 @@ public void WithGradleTaskThrowsWhenJarPathIsSet() .WithGradleTask("bootRun")); } +#pragma warning disable CS0618 + [Fact] + public void DeprecatedMavenOptionsWithDefaultCommandUsesDefaultWrapper() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(new MavenOptions())); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mvnw.cmd" : "mvnw"; + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, expectedWrapper)), buildResource.Command); + } + + [Fact] + public void DeprecatedMavenOptionsWithCustomCommandUsesCustomWrapper() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(new MavenOptions { Command = "tools/mvnw" })); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, "tools/mvnw")), buildResource.Command); + } +#pragma warning restore CS0618 + private static DistributedApplicationModel BuildAppModel(Action configure) { var builder = DistributedApplication.CreateBuilder();