diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs index 62f5a73d1..aa0bc1b0d 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs @@ -2,27 +2,15 @@ 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", - workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven", - new JavaAppExecutableResourceOptions() - { - ApplicationName = "target/spring-maven-0.0.1-SNAPSHOT.jar", - Port = 8085, - OtelAgentPath = "../../../agents", - }) - .WithMavenBuild() - .PublishAsDockerFile(c => - { - c.WithBuildArg("JAR_NAME", "spring-maven-0.0.1-SNAPSHOT.jar") - .WithBuildArg("AGENT_PATH", "/agents") - .WithBuildArg("SERVER_PORT", "8085"); - }) +var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample") + .WithOtelAgent("/agents/opentelemetry-javaagent.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); + +var executableapp = builder.AddJavaApp("executableapp", + workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven") + .WithMavenGoal("spring-boot:run") + .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/GradleBuildResource.cs b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs new file mode 100644 index 000000000..4a235f339 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/GradleBuildResource.cs @@ -0,0 +1,10 @@ +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 wrapperScript, string workingDirectory) + : ExecutableResource(name, wrapperScript, 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..a5aa87c7c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppExecutableResource.cs @@ -3,11 +3,35 @@ /// /// 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 + : ExecutableResource, IResourceWithServiceDiscovery, 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. + 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. + [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) + { + } + + /// + /// Gets or sets the path to the JAR file to execute. + /// + public string? JarPath { get; set; } } 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..d0861823b 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; @@ -14,8 +13,31 @@ public static partial class JavaAppHostingExtension /// /// The to add the resource to. /// The name of the resource. - /// The to configure the Java application." + /// The container image name. + /// The container image tag. /// A reference to the . + public static IResourceBuilder AddJavaContainerApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, + string image, string? imageTag = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + ArgumentException.ThrowIfNullOrWhiteSpace(image, nameof(image)); + + var resource = new JavaAppContainerResource(name); + + return builder.AddResource(resource) + .WithImage(image, imageTag) + .WithOtlpExporter(); + } + + /// + /// 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 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) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -43,14 +65,17 @@ public static IResourceBuilder AddJavaApp(this IDistri /// /// 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) => 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 2d53270ff..48ce5901d 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.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; namespace Aspire.Hosting; @@ -14,14 +10,89 @@ 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. /// /// 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 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 . + public static IResourceBuilder AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, + 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)); + + var rb = builder.AddJavaApp(name, workingDirectory); + rb.Resource.JarPath = jarPath; + + if (args is { Length: > 0 }) + { + rb.WithArgs(args); + } + + 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. + /// 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) + .WithOtlpExporter() + .WithArgs(context => + { + if (resource.TryGetLastAnnotation(out var buildTool)) + { + 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); + } + }); + + return resourceBuilder; + } + + /// + /// 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. + /// 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) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); @@ -53,8 +124,9 @@ 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 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); @@ -64,160 +136,275 @@ public static IResourceBuilder AddSpringApp(this IDis /// The to add the Maven build step to. /// The to configure the Maven build step. /// 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. - /// + [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 = null) + MavenOptions mavenOptions) { - mavenOptions ??= new MavenOptions(); + ArgumentNullException.ThrowIfNull(mavenOptions, nameof(mavenOptions)); - if (mavenOptions.WorkingDirectory is null) + // 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)) { - mavenOptions.WorkingDirectory = builder.Resource.WorkingDirectory; + builder.WithWrapperPath(mavenOptions.Command); } - var annotation = new MavenBuildAnnotation(mavenOptions); + 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. + /// 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, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + + 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: 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. + /// 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, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); - if (builder.Resource.TryGetLastAnnotation(out _)) + return builder.WithJavaBuildStep( + buildResourceName: $"{builder.Resource.Name}-gradle-build", + createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory), + wrapperPath: resolvedWrapper, + 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) { - // Replace the existing annotation, but don't continue on and subscribe to the event again. - builder.WithAnnotation(annotation, ResourceAnnotationMutationBehavior.Replace); - return builder; + var buildResource = createResource(buildResourceName, wrapperPath, builder.Resource.WorkingDirectory); + + var buildBuilder = builder.ApplicationBuilder.AddResource(buildResource) + .WithArgs(buildArgs) + .WithParentRelationship(builder.Resource) + .ExcludeFromManifest(); + + builder.WaitForCompletion(buildBuilder); } - builder.WithAnnotation(annotation); + return builder; + } - builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (e, ct) => + /// + /// 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). + /// 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)); + + if (builder.Resource.JarPath is not null) { - if (e.Resource is not JavaAppExecutableResource javaAppResource) - { - return; - } + 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."); + } - await BuildWithMaven(javaAppResource, e.Services, ct).ConfigureAwait(false); - }); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper)); - builder.WithCommand( - "build-with-maven", - "Build with Maven", - async (context) => - await BuildWithMaven(builder.Resource, context.ServiceProvider, context.CancellationToken, false).ConfigureAwait(false) ? - new ExecuteCommandResult { Success = true } : - new ExecuteCommandResult { Success = false, ErrorMessage = "Failed to build with Maven" }, - new CommandOptions() - { - IconName = "build", - UpdateState = (context) => context.ResourceSnapshot.State switch - { - { Text: "Stopped" } or - { Text: "Exited" } or - { Text: "Finished" } or - { Text: "FailedToStart" } => ResourceCommandState.Enabled, - _ => ResourceCommandState.Disabled - }, - }); + builder.Resource.Annotations.Add( + new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal])); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.WithCommand(resolvedWrapper); + } return builder; + } - static async Task BuildWithMaven(JavaAppExecutableResource javaAppResource, IServiceProvider services, CancellationToken ct, bool useNotificationService = true) + /// + /// 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). + /// 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)); + + if (builder.Resource.JarPath is not null) { - if (!javaAppResource.TryGetLastAnnotation(out var mavenOptionsAnnotation)) - { - return false; - } + 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."); + } - var mavenOptions = mavenOptionsAnnotation.MavenOptions; - var logger = services.GetRequiredService().GetLogger(javaAppResource); - var notificationService = services.GetRequiredService(); + string resolvedWrapper = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); - if (useNotificationService) - { - await notificationService.PublishUpdateAsync(javaAppResource, state => state with - { - State = new("Building Maven project", KnownResourceStates.Starting) - }).ConfigureAwait(false); - } + builder.Resource.Annotations.Add( + new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task])); - logger.LogInformation("Building Maven project"); + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.WithCommand(resolvedWrapper); + } - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + return builder; + } - var mvnw = new Process - { - 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, - } - }; - - mvnw.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); - } - - logger.LogInformation("{Data}", args.Data); - } - }; - - mvnw.ErrorDataReceived += async (sender, args) => - { - 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); - } - }; - - mvnw.Start(); - mvnw.BeginOutputReadLine(); - mvnw.BeginErrorReadLine(); - - await mvnw.WaitForExitAsync(ct).ConfigureAwait(false); - - if (mvnw.ExitCode != 0) + /// + /// 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, + 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 => + { + AppendJavaToolOptions(context, args); + }); + } + + /// + /// 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)); + + builder.WithOtlpExporter(); + + if (!string.IsNullOrEmpty(agentPath)) + { + builder.WithEnvironment(context => { - // 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); - - return false; - } - return true; + 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(JavaToolOptions, out var existing) && + existing is string existingValue && + !string.IsNullOrEmpty(existingValue)) + { + context.EnvironmentVariables[JavaToolOptions] = $"{existingValue} {value}"; + } + else + { + context.EnvironmentVariables[JavaToolOptions] = value; + } + } + + /// + /// 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, 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)); } 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/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..ce8daafb0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/MavenBuildResource.cs @@ -0,0 +1,10 @@ +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 wrapperScript, string workingDirectory) + : ExecutableResource(name, wrapperScript, workingDirectory); diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Java/MavenOptions.cs index c45cb7f5d..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 { /// @@ -17,4 +18,4 @@ public sealed class MavenOptions /// 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 +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index d89606ff1..65d7155c0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -1,44 +1,100 @@ -# CommunityToolkit.Aspire.Hosting.Java library +# CommunityToolkit.Aspire.Hosting.Java -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 .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 + +Use `WithMavenGoal` to run the application using a Maven goal: + +```csharp +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +Pass additional arguments: + +```csharp +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run", "-Dspring-boot.run.profiles=dev") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +### Run with Gradle -Then, in the _Program.cs_ file of `AppHost`, define a Java resource, then call `AddJavaApp` or `AddSpringApp`: +Use `WithGradleTask` to run the application using a Gradle task: ```csharp -// Define the Java container app resource -var containerapp = builder.AddSpringApp("containerapp", - new JavaAppContainerResourceOptions() - { - ContainerImageName = "/", - OtelAgentPath = "" - }); +var app = builder.AddJavaApp("app", "../java-project") + .WithGradleTask("bootRun") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +### Run with a JAR file + +To run an existing JAR file, pass the `jarPath` parameter: -// Define the Java executable app resource -var executableapp = builder.AddSpringApp("executableapp", - new JavaAppExecutableResourceOptions() - { - ApplicationName = "target/app.jar", - OtelAgentPath = "../../../agents" - }); +```csharp +var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` -## Additional Information +## Build before run + +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 app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithMavenBuild() + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +## Add a containerized Java application + +To run a Java application from a container image, use `AddJavaContainerApp`: + +```csharp +var app = builder.AddJavaContainerApp("app", "my-java-image", "latest") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +## JVM configuration + +### JVM arguments + +Use `WithJvmArgs` to configure JVM arguments: + +```csharp +var app = builder.AddJavaApp("app", "../java-project") + .WithMavenGoal("spring-boot:run") + .WithJvmArgs(["-Xmx512m", "-Xms256m"]) + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` + +### OpenTelemetry agent + +Use `WithOtelAgent` to configure the OpenTelemetry Java Agent: + +```csharp +var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") + .WithOtelAgent("../agents/opentelemetry-javaagent.jar") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); +``` -https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-java +## Additional information -## Feedback & contributing +- [Aspire Community Toolkit: Java hosting](https://aspire.dev/integrations/frameworks/java/) -https://github.com/CommunityToolkit/Aspire +## Feedback and contributing +- [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 3686b31ad..d782386e6 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", 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!; @@ -53,7 +76,7 @@ public void AddJavaAppContainerResourceOptionsShouldNotBeNull() { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp("java", null!)); + Assert.Throws(() => builder.AddJavaApp("java", (JavaAppContainerResourceOptions)null!)); } [Fact] @@ -105,4 +128,5 @@ public async Task AddJavaAppContainerDetailsSetOnResource() 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..80fb894d3 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/ExecutableResourceCreationTests.cs @@ -1,5 +1,7 @@ +using System.Runtime.InteropServices; using Aspire.Hosting; -using Aspire.Hosting.Eventing; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Java.Tests; @@ -10,277 +12,374 @@ public void AddJavaAppBuilderShouldNotBeNull() { IDistributedApplicationBuilder builder = null!; - Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, "app.jar")); } [Fact] - public void AddSpringAppBuilderShouldNotBeNull() + public void AddJavaAppNameShouldNotBeNullOrWhiteSpace() { - IDistributedApplicationBuilder builder = null!; + var builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddSpringApp("spring", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory, "app.jar")); + + const string name = ""; + Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory, "app.jar")); } [Fact] - public void AddJavaAppNameShouldNotBeNullOrWhiteSpace() + public void AddJavaAppWorkingDirectoryShouldNotBeNullOrWhiteSpace() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var builder = DistributedApplication.CreateBuilder(); - Assert.Throws(() => builder.AddJavaApp(null!, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: null!, jarPath: "app.jar")); + Assert.Throws(() => builder.AddJavaApp("java", workingDirectory: "", jarPath: "app.jar")); + } - const string name = ""; - Assert.Throws(() => builder.AddJavaApp(name, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + [Fact] + public void DefaultJavaApp() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("javaapp", "../java-project", "target/app.jar")); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("java", resource.Command); } [Fact] - public void AddSpringAppNameShouldNotBeNullOrWhiteSpace() + public async Task AddJavaAppDetailsSetOnResource() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "test.jar", args: ["--test"]) + .WithJvmArgs(["-Dtest"])); - Assert.Throws(() => builder.AddSpringApp(null!, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + var resource = Assert.Single(appModel.Resources.OfType()); - const string name = ""; - Assert.Throws(() => builder.AddSpringApp(name, Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + Assert.Equal("java", resource.Name); + Assert.Equal(Environment.CurrentDirectory, resource.WorkingDirectory); + Assert.Equal("java", resource.Command); + + var args = await resource.GetArgumentListAsync(); + 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 void AddJavaAppWorkingDirectoryShouldNotBeNullOrWhiteSpace() + public async Task JavaAppWithJvmArgsSetsJavaToolOptionsEnvironmentVariable() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("javaapp", "../java-project", "target/app.jar") + .WithJvmArgs(["-Xmx512m"])); - Assert.Throws(() => builder.AddJavaApp("java", null!, new JavaAppExecutableResourceOptions())); - Assert.Throws(() => builder.AddJavaApp("java", "", new JavaAppExecutableResourceOptions())); + 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.Contains("-jar", args); + Assert.Contains("target/app.jar", args); } [Fact] - public void AddJavaAppExecutableResourceOptionsShouldNotBeNull() + public async Task MavenBuildCreatesResourceWithCorrectArgs() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild()); + + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("java-maven-build", buildResource.Name); + + 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); - Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, null!)); + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["clean", "package"], args); } [Fact] - public void AddSpringAppContainerResourceOptionsShouldNotBeNull() + public async Task MavenBuildWithCustomArgsReplacesDefaults() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(args: ["-DskipTests", "-Pprod"])); - Assert.Throws(() => builder.AddSpringApp("spring", Environment.CurrentDirectory, null!)); + var buildResource = Assert.Single(appModel.Resources.OfType()); + + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["-DskipTests", "-Pprod"], args); } [Fact] - public async Task AddJavaAppContainerDetailsSetOnResource() + public void MavenBuildWithCustomWrapperUsesProvidedPath() { - IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithWrapperPath("custom/mvnw") + .WithMavenBuild()); + + 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); + } - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - JvmArgs = ["-Dtest"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + [Fact] + public async Task GradleBuildCreatesResourceWithCorrectArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithGradleBuild()); - builder.AddJavaApp("java", Environment.CurrentDirectory, options); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); - using var app = builder.Build(); + Assert.Equal("java-gradle-build", buildResource.Name); - var appModel = app.Services.GetRequiredService(); + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "gradlew.bat" : "gradlew"; + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, expectedWrapper)), buildResource.Command); - var resource = appModel.Resources.OfType().SingleOrDefault(); + Assert.True(javaResource.TryGetAnnotationsOfType(out var waitAnnotations)); + Assert.Contains(waitAnnotations, w => w.Resource == buildResource); - Assert.NotNull(resource); - Assert.Equal("java", resource.Name); + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["clean", "build"], args); + } - Assert.Equal(Environment.CurrentDirectory, resource.WorkingDirectory); - Assert.Equal("java", resource.Command); + [Fact] + public async Task GradleBuildWithCustomArgsReplacesDefaults() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithGradleBuild(args: ["-x", "test", "--parallel"])); - Assert.True(resource.TryGetLastAnnotation(out EndpointAnnotation? httpEndpointAnnotations)); - Assert.Equal(options.Port, httpEndpointAnnotations.Port); + var buildResource = Assert.Single(appModel.Resources.OfType()); - Assert.True(resource.TryGetLastAnnotation(out CommandLineArgsCallbackAnnotation? argsAnnotations)); - CommandLineArgsCallbackContext context = new([]); - await argsAnnotations.Callback(context); + var args = await buildResource.GetArgumentListAsync(); + Assert.Equal(["-x", "test", "--parallel"], args); + } - Assert.Equal([..options.JvmArgs, "-jar", options.ApplicationName, ..options.Args], context.Args); + [Fact] + public void GradleBuildWithCustomWrapperUsesProvidedPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "build/libs/app.jar") + .WithWrapperPath("custom/gradlew") + .WithGradleBuild()); + + 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 void AddingMavenOptions() + public async Task AddJavaAppWithOtelAgentSetsEnvironment() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithOtelAgent("/agents/opentelemetry-javaagent.jar")); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var config = await resource.GetEnvironmentVariablesAsync(); + Assert.True(config.TryGetValue("JAVA_TOOL_OPTIONS", out var value)); + Assert.Equal("-javaagent:/agents/opentelemetry-javaagent.jar", value); + } - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + [Fact] +#pragma warning disable CS0618 + public void DeprecatedAddJavaAppBuilderShouldNotBeNull() + { + IDistributedApplicationBuilder builder = null!; - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); + Assert.Throws(() => builder.AddJavaApp("java", Environment.CurrentDirectory, new JavaAppExecutableResourceOptions())); + } +#pragma warning restore CS0618 - using var app = builder.Build(); + [Fact] + public async Task MavenGoalCreatesResourceWithCorrectAnnotation() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run")); - 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); + 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 void AddingMavenOptionsWithOverrides() + public async Task MavenGoalWithCustomArgsAddsArgs() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run", args: ["-Dspring-boot.run.profiles=dev"])); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + var resource = Assert.Single(appModel.Resources.OfType()); - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(new() - { - Args = ["clean", "package"], - }); + var args = await resource.GetArgumentListAsync(); + Assert.Equal(["spring-boot:run", "-Dspring-boot.run.profiles=dev"], args); + } - using var app = builder.Build(); + [Fact] + public async Task MavenGoalDoesNotAddJarArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run")); - 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 args = await resource.GetArgumentListAsync(); + Assert.DoesNotContain("-jar", args); } [Fact] - public void ChainingAddMavenBuildOverridesPreviousOptions() + public async Task GradleTaskCreatesResourceWithCorrectAnnotation() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun")); + + var resource = Assert.Single(appModel.Resources.OfType()); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + 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); - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(new() - { - Args = ["clean", "package"], - }) - .WithMavenBuild(); + var args = await resource.GetArgumentListAsync(); + Assert.Equal("bootRun", args[0]); + } - using var app = builder.Build(); + [Fact] + public async Task GradleTaskWithCustomArgsAddsArgs() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun", args: ["--args=--spring.profiles.active=dev"])); - 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 args = await resource.GetArgumentListAsync(); + Assert.Equal(["bootRun", "--args=--spring.profiles.active=dev"], args); } [Fact] - public void AddingMavenBuildRegistersRebuildCommand() + public async Task GradleTaskDoesNotAddJarArgs() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun")); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + var resource = Assert.Single(appModel.Resources.OfType()); - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); + var args = await resource.GetArgumentListAsync(); + Assert.DoesNotContain("-jar", args); + } - using var app = builder.Build(); + [Fact] + public async Task MavenGoalWithCustomWrapperScriptUsesCustomPath() + { + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithWrapperPath("../../mvnw") + .WithMavenGoal("spring-boot:run")); - var appModel = app.Services.GetRequiredService(); var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-maven"); + + 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 void MultipleAddingMavenBuildRegistersSingleRebuildCommand() + public async Task GradleTaskWithCustomWrapperScriptUsesCustomPath() { - var builder = DistributedApplication.CreateBuilder(); + var appModel = BuildAppModel(builder => + builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithWrapperPath("../gradlew") + .WithGradleTask("bootRun")); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + var resource = Assert.Single(appModel.Resources.OfType()); - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); + Assert.True(resource.TryGetLastAnnotation(out var annotation)); + Assert.Equal(Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "../gradlew")), annotation.WrapperPath); - using var app = builder.Build(); + var args = await resource.GetArgumentListAsync(); + Assert.Equal("bootRun", args[0]); + } - var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); - Assert.Single(resource.Annotations.OfType(), a => a.Name == "build-with-maven"); + [Fact] + public void WithMavenGoalThrowsWhenJarPathIsSet() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => + builder.AddJavaApp("java", Environment.CurrentDirectory, "my-app.jar") + .WithMavenGoal("spring-boot:run")); } - [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) + [Fact] + public void WithGradleTaskThrowsWhenJarPathIsSet() { var builder = DistributedApplication.CreateBuilder(); - var options = new JavaAppExecutableResourceOptions - { - ApplicationName = "test.jar", - Args = ["--test"], - OtelAgentPath = "otel-agent", - Port = 8080 - }; + Assert.Throws(() => + builder.AddJavaApp("java", Environment.CurrentDirectory, "my-app.jar") + .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())); - builder.AddJavaApp("java", Environment.CurrentDirectory, options) - .WithMavenBuild(); + var javaResource = Assert.Single(appModel.Resources.OfType()); + var buildResource = Assert.Single(appModel.Resources.OfType()); - using var app = builder.Build(); + string expectedWrapper = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mvnw.cmd" : "mvnw"; + Assert.Equal(Path.GetFullPath(Path.Combine(javaResource.WorkingDirectory, expectedWrapper)), buildResource.Command); + } - 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); + [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(); + configure(builder); + var app = builder.Build(); + return app.Services.GetRequiredService(); } }