// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREEXTENSION001 // WithDebugSupport and WithLaunchToolArgs are experimental but used internally for debug support.
using System.Diagnostics.CodeAnalysis;
using System.Formats.Asn1;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Java;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Java applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static partial class JavaHostingExtensions
{
private const string JavaToolOptions = "JAVA_TOOL_OPTIONS";
// The icon Java resources and their build steps show in the dashboard, matching
// CommunityToolkit.Aspire.Hosting.Java so migrating users see the same thing.
private const string JavaIconName = "DrinkCoffee";
internal static readonly string s_defaultMavenWrapper =
JavaBuildToolResolver.GetDefaultWrapperName(JavaBuildTool.Maven, RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
internal static readonly string s_defaultGradleWrapper =
JavaBuildToolResolver.GetDefaultWrapperName(JavaBuildTool.Gradle, RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
/// <summary>The directory Quarkus's default packaging writes under the build tool's output directory.</summary>
internal const string QuarkusFastJarDirectory = "quarkus-app";
/// <summary>The runnable artifact inside <see cref="QuarkusFastJarDirectory"/>.</summary>
internal const string QuarkusRunJarName = "quarkus-run.jar";
/// <summary>
/// Adds a Java application to the application model, launched with <c>java</c>.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The application directory. Relative paths are resolved against the AppHost directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> or <paramref name="appDirectory"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <exception cref="InvalidOperationException">No launch mode was configured. Raised when the resource starts, not when this is called.</exception>
/// <remarks>
/// Combine with <see cref="WithMavenGoal{T}(IResourceBuilder{T}, string, string[])"/> or
/// <see cref="WithGradleTask{T}(IResourceBuilder{T}, string, string[])"/> to run the application through a build tool,
/// or use the overload that accepts a <c>jarPath</c> to run a prebuilt JAR with <c>java -jar</c>.
/// Exactly one of those three launch modes must be configured.
/// </remarks>
/// <example>
/// Run a Spring Boot application through the Maven wrapper:
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddJavaApp("catalog", "../catalog")
/// .WithMavenGoal("spring-boot:run")
/// .WithHttpEndpoint(env: "SERVER_PORT")
/// .WithHttpHealthCheck("/actuator/health");
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<JavaAppResource> AddJavaApp(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string appDirectory)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory);
// Accept both slash styles so an AppHost authored on Windows resolves the same paths on Linux.
appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(
Path.Combine(builder.AppHostDirectory, appDirectory));
var resource = new JavaAppResource(name, appDirectory);
var resourceBuilder = builder.AddResource(resource)
.WithIconName(JavaIconName)
.WithRequiredCommand("java", "https://adoptium.net/")
// Declared as launch tool arguments rather than through WithArgs for two reasons: they are
// pinned ahead of any caller-supplied WithArgs no matter the order the builder methods are
// called in, and they are omitted when an IDE launches the resource through a "java" launch
// configuration, which starts the JVM directly instead of going through the build tool.
.WithLaunchToolArgs(ctx => AddLaunchArgs(resource, ctx), ownedByLaunchConfigurationType: "java")
.WithOtlpExporter()
// Requested explicitly because the JVM's trust store setting replaces the default certificate
// authorities instead of adding to them, so the bundle has to carry the system roots too.
.WithCertificateTrustScope(CertificateTrustScope.System)
.WithCertificateTrustConfiguration(JavaCertificateTrustCallback)
.WithVSCodeDebugging()
.PublishAsDockerFile(containerBuilder =>
{
// An authored Dockerfile in the application directory is the author's deployment contract.
// Generating over it would silently discard base image pins, extra runtime packages, and
// anything else the project depends on.
if (File.Exists(Path.Combine(appDirectory, "Dockerfile")))
{
resource.Annotations.Add(new JavaAuthoredDockerfileAnnotation());
return;
}
containerBuilder.WithDockerfileBuilder(
appDirectory,
ctx =>
{
// The build context was fixed when PublishAsDockerFile ran, which is during
// AddJavaApp, and DockerfileBuildAnnotation.ContextPath cannot be changed
// afterwards. A later WithWorkingDirectory therefore moves where the application
// runs without moving what is uploaded to the daemon, and the image would be built
// from the original directory. Saying so is far better than producing an image
// whose sources come from somewhere the author no longer points at.
if (!ArePathsEquivalent(resource.WorkingDirectory, appDirectory))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its working " +
$"directory was changed to '{resource.WorkingDirectory}' after the container " +
$"build context was set to '{appDirectory}'. Pass the directory to " +
"AddJavaApp instead of calling WithWorkingDirectory afterwards.");
}
JavaDockerfileGenerator.Write(resource, appDirectory, ctx);
});
});
// The generated image copies files out of each container files source, so those sources have to be
// built first. PublishAsDockerFile removes the Java resource from the model, but the container it
// substitutes shares this annotation collection, so the callback still runs; the step lookup matches
// on resource name and therefore finds the substituted container's build steps.
resourceBuilder.WithPipelineConfiguration(context =>
{
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
});
return resourceBuilder;
}
/// <summary>
/// Adds a Java application that runs a prebuilt JAR with <c>java -jar</c>.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The application directory. Relative paths are resolved against the AppHost directory.</param>
/// <param name="jarPath">The path to the JAR file to execute. Relative paths are resolved against <paramref name="appDirectory"/>.</param>
/// <param name="args">Arguments passed to the Java application after the JAR path.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/>, <paramref name="appDirectory"/>, or <paramref name="jarPath"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <example>
/// Build the JAR with Maven, then run it:
/// <code language="csharp">
/// builder.AddJavaApp("worker", "../worker", "target/worker.jar")
/// .WithMavenBuild();
/// </code>
/// </example>
[AspireExport("addJavaAppWithJar")]
public static IResourceBuilder<JavaAppResource> AddJavaApp(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string appDirectory,
string jarPath,
params string[] args)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(jarPath);
ArgumentNullException.ThrowIfNull(args);
var rb = builder.AddJavaApp(name, appDirectory);
rb.WithAnnotation(
// Keep the authored text in the model so diagnostics can quote the path the user recognizes.
// Run and publish normalize only when crossing into their execution environment instead of
// baking the AppHost's path semantics into a value that also targets a Linux container.
new JavaJarPathAnnotation(jarPath),
ResourceAnnotationMutationBehavior.Replace);
if (args.Length > 0)
{
rb.WithArgs(args);
}
return rb;
}
/// <summary>
/// Adds a Java application that runs from an existing container image.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="image">The container image that runs the application, for example <c>mycompany/catalog</c>.</param>
/// <param name="tag">The image tag. Defaults to the image's <c>latest</c> tag.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> or <paramref name="image"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <remarks>
/// Use this when the image is built elsewhere — by a separate CI pipeline, or by a team that ships the
/// application as a container. Aspire runs the image as-is and never rebuilds it, so the JAR, the JDK,
/// and any OpenTelemetry agent all come from the image. Use
/// <see cref="AddJavaApp(IDistributedApplicationBuilder, string, string)"/> instead when Aspire should
/// build and run the application from source.
/// <para>
/// No endpoint is declared, because the port the image listens on is a property of the image. Add one
/// with <c>WithHttpEndpoint(targetPort: 8080)</c>, using whichever port the application binds — 8080
/// for a default Spring Boot or Quarkus image.
/// </para>
/// </remarks>
/// <example>
/// Run a published Spring Boot image and give it a database:
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var db = builder.AddPostgres("pg").AddDatabase("catalogdb");
///
/// builder.AddJavaContainer("catalog", "mycompany/catalog", "1.4.0")
/// .WithHttpEndpoint(targetPort: 8080)
/// .WithReference(db)
/// .WithJvmArgs("-Xmx512m");
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<JavaContainerResource> AddJavaContainer(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string image,
string? tag = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(image);
var resource = new JavaContainerResource(name);
return builder.AddResource(resource)
.WithImage(image, tag)
.WithIconName(JavaIconName)
.WithOtlpExporter()
// Requested explicitly because the JVM's trust store setting replaces the default certificate
// authorities instead of adding to them, so the bundle has to carry the system roots too.
.WithCertificateTrustScope(CertificateTrustScope.System)
.WithCertificateTrustConfiguration(JavaCertificateTrustCallback);
}
/// <summary>
/// Adds a Spring Boot application to the application model, built and launched with its own Maven or Gradle wrapper.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The application directory, containing <c>pom.xml</c>, <c>build.gradle</c>, <c>build.gradle.kts</c>, <c>settings.gradle</c>, or <c>settings.gradle.kts</c>. Relative paths are resolved against the AppHost directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> or <paramref name="appDirectory"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <exception cref="InvalidOperationException">The directory contains neither a Maven nor a Gradle build file, or contains both.</exception>
/// <remarks>
/// This is <see cref="AddJavaApp(IDistributedApplicationBuilder, string, string)"/> with the common
/// Spring Boot configuration already applied: the build tool is detected when the resource starts, the
/// application is launched through that tool's Spring Boot plugin (<c>spring-boot:run</c> or <c>bootRun</c>),
/// and an HTTP endpoint is declared through <c>SERVER_PORT</c>, which is the environment
/// variable Spring Boot reads for its listening port. Everything else is the same, so any <c>With…</c> method
/// that works on <see cref="AddJavaApp(IDistributedApplicationBuilder, string, string)"/> works here too.
/// <para>
/// The launch goal compiles the application itself, so Aspire does not add a separate build resource before
/// it. Publishing still packages the application with tests skipped (<c>-DskipTests</c> for Maven,
/// <c>-x test</c> for Gradle). Call <see cref="WithMavenBuild{T}(IResourceBuilder{T}, string[])"/> or
/// <see cref="WithGradleBuild{T}(IResourceBuilder{T}, string[])"/> afterwards to customize those package arguments.
/// The one thing that does add a build resource is
/// <see cref="WithOtelAgent{T}(IResourceBuilder{T})"/> with a build-produced agent, which cannot be
/// loaded until a build has written it.
/// </para>
/// <para>
/// No health check is added. <c>/actuator/health</c> only exists when the application depends on
/// <c>spring-boot-starter-actuator</c>, and adding it unconditionally would leave applications without that
/// dependency permanently unhealthy and silently stall every <c>WaitFor</c> on them. Add
/// <c>WithHttpHealthCheck("/actuator/health")</c> when the actuator is present.
/// </para>
/// </remarks>
/// <example>
/// Two Spring Boot services and a database:
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var db = builder.AddPostgres("pg").AddDatabase("catalogdb");
///
/// var catalog = builder.AddSpringBootApp("catalog", "../catalog")
/// .WithReference(db);
///
/// builder.AddSpringBootApp("orders", "../orders")
/// .WithReference(catalog)
/// .WaitFor(catalog);
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<JavaAppResource> AddSpringBootApp(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string appDirectory)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory);
var resourceBuilder = builder.AddJavaApp(name, appDirectory)
.WithDetectedBuildTool(
mavenBuildArgs: ["-B", "-ntp", "-DskipTests", "package"],
mavenLaunchArgs: ["spring-boot:run"],
gradleBuildArgs: ["build", "-x", "test"],
gradleLaunchArgs: ["bootRun"]);
// Spring Boot reads SERVER_PORT for its listening port, so the port Aspire allocates reaches the
// application without any code in the application. No targetPort is pinned: these are host
// processes rather than containers, so a fixed target port is a real port on the machine and two
// Spring Boot services both asking for 8080 would collide.
return resourceBuilder.WithHttpEndpoint(env: "SERVER_PORT");
}
/// <summary>
/// Adds a Quarkus application to the application model, built and launched with its own Maven or Gradle wrapper.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The application directory, containing <c>pom.xml</c>, <c>build.gradle</c>, <c>build.gradle.kts</c>, <c>settings.gradle</c>, or <c>settings.gradle.kts</c>. Relative paths are resolved against the AppHost directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> or <paramref name="appDirectory"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <exception cref="InvalidOperationException">The directory contains neither a Maven nor a Gradle build file, or contains both.</exception>
/// <remarks>
/// The build tool is detected when the resource starts, the application runs in Quarkus dev mode
/// (<c>quarkus:dev</c> or <c>quarkusDev</c>) so live coding works, and an HTTP endpoint is declared through
/// <c>QUARKUS_HTTP_PORT</c>, the environment variable Quarkus reads for its listening port. Everything else
/// behaves like <see cref="AddJavaApp(IDistributedApplicationBuilder, string, string)"/>.
/// The dev-mode goal compiles the application itself, so Aspire does not add a separate build resource before it.
/// There are two exceptions. <see cref="WithOtelAgent{T}(IResourceBuilder{T})"/> with a build-produced agent
/// cannot be loaded until a build has written it, and a debug session launches the packaged fast JAR rather
/// than the dev-mode wrapper, which no build has written on a clean checkout.
/// <para>
/// Quarkus Dev Services are left enabled but do not activate for anything Aspire supplies: Dev Services only
/// start a container when the corresponding configuration is missing, and a <c>WithReference</c> to a database
/// or broker provides it. That means Aspire's resources are used rather than a second set started underneath.
/// </para>
/// <para>
/// <c>QUARKUS_PROFILE</c> is set to <c>dev</c> in run mode. Dev mode already selects that profile; setting it
/// explicitly means a debugger, which launches the packaged application rather than the dev-mode wrapper,
/// resolves the same <c>%dev.</c> configuration the application would see when run normally.
/// </para>
/// <para>
/// In run mode the application is bound to all interfaces. Quarkus enables Host header validation
/// whenever it binds a localhost name, and that filter rejects the hostname Aspire publishes, which
/// makes the endpoint link in the dashboard return <c>400</c>. Published output is left alone, where
/// the application already binds all interfaces.
/// </para>
/// <para>
/// No health check is added. <c>/q/health</c> only exists when the application depends on
/// <c>quarkus-smallrye-health</c>, and adding it unconditionally would leave applications without that
/// extension permanently unhealthy and silently stall every <c>WaitFor</c> on them. Add
/// <c>WithHttpHealthCheck("/q/health")</c> when the extension is present.
/// </para>
/// </remarks>
/// <example>
/// A Quarkus service backed by a database Aspire provides:
/// <code language="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var db = builder.AddPostgres("pg").AddDatabase("inventorydb");
///
/// builder.AddQuarkusApp("inventory", "../inventory")
/// .WithReference(db)
/// .WaitFor(db);
///
/// builder.Build().Run();
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<JavaAppResource> AddQuarkusApp(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string appDirectory)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory);
var resourceBuilder = builder.AddJavaApp(name, appDirectory)
.WithAnnotation(new JavaQuarkusAnnotation(), ResourceAnnotationMutationBehavior.Replace)
.WithDetectedBuildTool(
mavenBuildArgs: ["-B", "-ntp", "-DskipTests", "package"],
mavenLaunchArgs: ["quarkus:dev"],
gradleBuildArgs: ["build", "-x", "test"],
gradleLaunchArgs: ["quarkusDev"]);
// Called after the Quarkus and build-tool annotations are in place, because both decide whether a
// build has to run before the IDE launches the fast JAR it will attach to.
EnsureBuildRunsBeforeLaunch(resourceBuilder);
// Declared before the run-mode block so the Host validation configuration below can name this
// endpoint's host. QUARKUS_HTTP_PORT is the variable Quarkus reads for its listening port.
resourceBuilder = resourceBuilder.WithHttpEndpoint(env: "QUARKUS_HTTP_PORT");
if (builder.ExecutionContext.IsRunMode)
{
resourceBuilder.WithEnvironment("QUARKUS_PROFILE", "dev");
// Quarkus turns on its Host header validation filter whenever quarkus.http.host holds a
// localhost name, which is the dev-mode default. The filter compares only the host portion of
// the request authority, lowercased, against an exact set, so it rejects the
// "<resource>.dev.localhost" hostname Aspire publishes with a bare 400 and no body: the
// endpoint link shown in the dashboard fails while the same request to 127.0.0.1 succeeds.
// See io.quarkus.vertx.http.runtime.HostValidationFilter.
//
// Binding all interfaces is what suppresses the filter, because it only auto-enables for a
// localhost bind. The targeted alternative, naming the hostname in
// quarkus.http.host-validation.allowed-hosts, cannot be delivered: the Quarkus Gradle plugin
// re-exports QUARKUS_* environment variables to the dev JVM as system properties by replacing
// every underscore with a dot, so a property whose name contains a dash arrives as
// "quarkus.http.host.validation.allowed.hosts" and is silently ignored. Aspire also has no
// access to the published hostname here - the endpoint's Host resolves to the bind address.
//
// Only run mode is configured, and only ever on a developer machine. A published container
// already binds all interfaces by default, so nothing needs to be said about it there.
resourceBuilder.WithEnvironment("QUARKUS_HTTP_HOST", "0.0.0.0");
// Quarkus dev mode starts an "observability" Dev Service when an application depends on
// quarkus-opentelemetry and no exporter endpoint is configured in the application itself. That
// Dev Service pulls grafana/otel-lgtm (roughly 600 MB), starts it through Testcontainers, and
// then *overrides* the exporter configuration to point at the container it just started:
//
// Dev Service Lgtm started, config: {quarkus.otel.exporter.otlp.endpoint=http://localhost:51845, ...}
//
// Aspire is already the observability stack here, so that override sends every span and metric
// somewhere the Aspire dashboard cannot see, leaves an orphaned container behind, and costs a
// large image pull on first run. Turning the Dev Service off lets the exporter configuration
// below win, which is what an Aspire user expects.
// See https://quarkus.io/guides/observability-devservices-lgtm.
resourceBuilder.WithEnvironment("QUARKUS_OBSERVABILITY_ENABLED", "false");
}
// The quarkus-opentelemetry extension does not read the standard OTEL_* environment variables that
// Aspire sets. It reads its own quarkus.otel.* configuration, so an application with the extension
// compiled in silently keeps its default endpoint and fails every export:
//
// WARNING [io.quarkus.opentelemetry.runtime.exporter.otlp.sender.VertxGrpcSender]
// Failed to export . The request could not be executed.
// Full error message: Connection refused: localhost/127.0.0.1:4317
//
// SmallRye Config maps QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT onto quarkus.otel.exporter.otlp.endpoint,
// so mirroring the values Aspire already resolved is enough to point the extension at the dashboard.
// See https://quarkus.io/guides/opentelemetry-tracing#create-the-configuration.
//
// The callback runs after the one WithOtlpExporter installed in AddJavaApp, so the OTEL_* entries are
// already present and carry the resolved endpoint reference rather than a literal. An application that
// does not use the extension ignores these, at the cost of a "unrecognized configuration key" warning.
//
// This covers `aspire run`. It cannot cover a published image, because there the compute environment
// supplies OTEL_* from a callback it appends while preparing the deployment target — after every
// callback the AppHost registered — so there is nothing here to copy. A SmallRye config expression
// would sidestep the ordering, but it cannot be passed as an environment variable: Docker Compose
// interpolates '${...}' in its own file and rejects SmallRye's '${VAR:default}' form outright
// ("invalid interpolation format"). A deployed application therefore maps the value in its own
// application.properties, which the README documents and both playgrounds do.
resourceBuilder.WithEnvironment(context =>
{
MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpEndpoint, "QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT");
MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpProtocol, "QUARKUS_OTEL_EXPORTER_OTLP_PROTOCOL");
MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpHeaders, "QUARKUS_OTEL_EXPORTER_OTLP_HEADERS");
MirrorOtelVariable(context, KnownOtelConfigNames.ResourceAttributes, "QUARKUS_OTEL_RESOURCE_ATTRIBUTES");
MirrorOtelVariable(context, KnownOtelConfigNames.ServiceName, "QUARKUS_OTEL_SERVICE_NAME");
MirrorOtelVariable(context, KnownOtelConfigNames.BspScheduleDelay, "QUARKUS_OTEL_BSP_SCHEDULE_DELAY");
MirrorOtelVariable(context, KnownOtelConfigNames.BlrpScheduleDelay, "QUARKUS_OTEL_BLRP_SCHEDULE_DELAY");
MirrorOtelVariable(context, KnownOtelConfigNames.MetricExportInterval, "QUARKUS_OTEL_METRIC_EXPORT_INTERVAL");
MirrorOtelVariable(context, KnownOtelConfigNames.TracesSampler, "QUARKUS_OTEL_TRACES_SAMPLER");
});
return resourceBuilder;
}
/// <summary>
/// Copies an OpenTelemetry environment variable Aspire already resolved to the name Quarkus reads it under.
/// </summary>
/// <remarks>
/// The value is copied by reference rather than converted to a string: several of these are endpoint
/// references or DCP templates that only resolve once the resource starts.
/// </remarks>
private static void MirrorOtelVariable(EnvironmentCallbackContext context, string standardName, string quarkusName)
{
if (context.EnvironmentVariables.TryGetValue(standardName, out var value))
{
context.EnvironmentVariables[quarkusName] = value;
}
}
/// <summary>
/// Requires an application directory to declare a Maven or Gradle project.
/// </summary>
/// <remarks>
/// Detection is shared with publishing so the same project files cannot select different tools in each path.
/// </remarks>
/// <exception cref="InvalidOperationException">The directory has no build file, or has both Maven and Gradle files.</exception>
private static JavaBuildTool RequireBuildTool(string workingDirectory, string resourceName)
{
if (JavaBuildToolResolver.Detect(
workingDirectory,
resourceName,
static message => new InvalidOperationException(message)) is not { } tool)
{
throw new InvalidOperationException(
$"Directory '{workingDirectory}' contains no pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts, " +
$"so the build tool for resource '{resourceName}' cannot be detected. " +
$"Check the path, or use AddJavaApp for an application laid out differently.");
}
return tool;
}
private static IResourceBuilder<T> WithDetectedBuildTool<T>(
this IResourceBuilder<T> builder,
string[] mavenBuildArgs,
string[] mavenLaunchArgs,
string[] gradleBuildArgs,
string[] gradleLaunchArgs)
where T : JavaAppResource
{
builder.WithAnnotation(
new JavaDetectedBuildToolAnnotation(
mavenBuildArgs,
mavenLaunchArgs,
gradleBuildArgs,
gradleLaunchArgs),
ResourceAnnotationMutationBehavior.Replace);
// Maven and Gradle both launch through the platform's command interpreter. The wrapper and its
// arguments are resolved later, after the complete AppHost has had a chance to supply an override.
builder.WithCommand(WrapperCommand());
return builder.OnBeforeResourceStarted((resource, _, _) =>
{
var (tool, configuration) = ResolveDetectedBuildTool(resource);
// Explicit WithMaven*/WithGradle* calls identify the tool without disk detection and must
// keep their authored arguments. The deferred defaults only fill the missing half.
if (!resource.HasAnnotationOfType<JavaBuildToolAnnotation>())
{
builder.WithAnnotation(
new JavaBuildToolAnnotation(tool, configuration.LaunchArgs),
ResourceAnnotationMutationBehavior.Replace);
}
if (!resource.HasAnnotationOfType<JavaBuildStepAnnotation>())
{
builder.WithAnnotation(
new JavaBuildStepAnnotation(ResourceName: null, tool, configuration.BuildArgs),
ResourceAnnotationMutationBehavior.Replace);
}
ValidateWrapperExists(resource, tool);
return Task.CompletedTask;
});
}
private static (JavaBuildTool Tool, (string[] BuildArgs, string[] LaunchArgs) Configuration) ResolveDetectedBuildTool(
JavaAppResource resource)
{
var annotation = resource.Annotations.OfType<JavaDetectedBuildToolAnnotation>().Single();
var tool = TryResolveConfiguredBuildTool(resource, out var configuredTool)
? configuredTool
: RequireBuildTool(resource.WorkingDirectory, resource.Name);
return (tool, annotation.GetConfiguration(tool));
}
private static bool TryResolveConfiguredBuildTool(JavaAppResource resource, out JavaBuildTool tool)
{
if (resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep))
{
tool = buildStep.Tool;
return true;
}
if (resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var launch))
{
tool = launch.Tool;
return true;
}
if (resource.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>())
{
tool = RequireBuildTool(resource.WorkingDirectory, resource.Name);
return true;
}
tool = default;
return false;
}
/// <summary>
/// Launches the Java application through a Maven goal instead of <c>java</c>, for example <c>spring-boot:run</c>.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="goal">The Maven goal to execute.</param>
/// <param name="args">Additional arguments passed to the Maven wrapper after the goal.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="goal"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <exception cref="InvalidOperationException">The application is already configured to run a prebuilt JAR or a Gradle task.</exception>
/// <remarks>
/// The wrapper defaults to <c>mvnw</c> (<c>mvnw.cmd</c> on Windows) in the resource's working directory
/// and can be overridden with <see cref="WithWrapperPath{T}(IResourceBuilder{T}, string)"/>.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithMavenGoal<T>(
this IResourceBuilder<T> builder,
string goal,
params string[] args) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(goal);
ArgumentNullException.ThrowIfNull(args);
return builder.WithBuildToolLaunch(JavaBuildTool.Maven, goal, args, nameof(WithMavenGoal));
}
/// <summary>
/// Launches the Java application through a Gradle task instead of <c>java</c>, for example <c>bootRun</c>.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="task">The Gradle task to execute.</param>
/// <param name="args">Additional arguments passed to the Gradle wrapper after the task.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="task"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <exception cref="InvalidOperationException">The application is already configured to run a prebuilt JAR or a Maven goal.</exception>
/// <remarks>
/// The wrapper defaults to <c>gradlew</c> (<c>gradlew.bat</c> on Windows) in the resource's working
/// directory and can be overridden with <see cref="WithWrapperPath{T}(IResourceBuilder{T}, string)"/>.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithGradleTask<T>(
this IResourceBuilder<T> builder,
string task,
params string[] args) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(task);
ArgumentNullException.ThrowIfNull(args);
return builder.WithBuildToolLaunch(JavaBuildTool.Gradle, task, args, nameof(WithGradleTask));
}
private static IResourceBuilder<T> WithBuildToolLaunch<T>(
this IResourceBuilder<T> builder,
JavaBuildTool tool,
string goalOrTask,
string[] args,
string methodName) where T : JavaAppResource
{
// A prebuilt JAR and a build-tool launch are mutually exclusive: the build tool decides what to
// run, so -jar would be ignored. CommunityToolkit rejects both combinations; this port only
// rejected the Gradle half, letting a Maven+JAR application silently drop its JAR.
if (builder.Resource.HasAnnotationOfType<JavaJarPathAnnotation>())
{
throw new InvalidOperationException(
$"{methodName} cannot be used when a JAR path has been specified. Use either the " +
$"{nameof(AddJavaApp)} overload that takes a jarPath, or {methodName}, not both.");
}
if (builder.Resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var existing) && existing.Tool != tool)
{
throw new InvalidOperationException(
$"{methodName} cannot be used when the application is already configured to launch with " +
$"{existing.Tool}. A Java application is launched by a single build tool.");
}
if (builder.Resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep) && buildStep.Tool != tool)
{
throw new InvalidOperationException(
$"{methodName} cannot be used when the application is already configured to build with " +
$"{buildStep.Tool}. A Java application is built and launched by a single build tool.");
}
builder.WithAnnotation(
new JavaBuildToolAnnotation(tool, args.Length > 0 ? [goalOrTask, .. args] : [goalOrTask]),
ResourceAnnotationMutationBehavior.Replace);
// The launch goal now compiles the application, so the build resource is redundant — unless
// something outside the launch goal needs the build's output before the application starts.
if (buildStep is not null
&& builder.ApplicationBuilder.ExecutionContext.IsRunMode
&& !RequiresBuildBeforeLaunch(builder))
{
RemoveRunBuildResource(builder, buildStep);
}
// Set the command in every execution context. Setting it only in run mode left publish emitting
// "java" as the command while the goal was still contributed as an argument, producing the
// uninvokable command line "java spring-boot:run".
return builder
.WithCommand(ResolveWrapperInvocation(builder.Resource, tool).Command)
.WithDeferredWrapperValidation(tool);
}
/// <summary>
/// Runs a Maven build before the Java application starts.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="args">Arguments passed to the Maven wrapper. Defaults to <c>clean package</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The application is already configured to build with Gradle.</exception>
/// <remarks>
/// When the application launches with <see cref="WithMavenGoal{T}(IResourceBuilder{T}, string, string[])"/>,
/// that goal performs the local compilation, so these arguments normally configure publishing without adding a
/// second run-mode build. Otherwise, the build step is a child resource that the application waits for.
/// No child is created when publishing because the generated container image performs the build.
/// <para>
/// This runs a build before the application starts. To launch the application <em>through</em> Maven,
/// use <see cref="WithMavenGoal{T}(IResourceBuilder{T}, string, string[])"/> instead.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithMavenBuild<T>(
this IResourceBuilder<T> builder,
params string[] args) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(args);
return builder.WithJavaBuildStep(
JavaBuildTool.Maven,
buildResourceName: $"{builder.Resource.Name}-maven-build",
buildArgs: args.Length > 0 ? args : ["clean", "package"]);
}
/// <summary>
/// Runs a Gradle build before the Java application starts.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="args">Arguments passed to the Gradle wrapper. Defaults to <c>clean build</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The application is already configured to build with Maven.</exception>
/// <remarks>
/// When the application launches with <see cref="WithGradleTask{T}(IResourceBuilder{T}, string, string[])"/>,
/// that task performs the local compilation, so these arguments normally configure publishing without adding a
/// second run-mode build. Otherwise, the build step is a child resource that the application waits for.
/// No child is created when publishing because the generated container image performs the build.
/// <para>
/// This runs a build before the application starts. To launch the application <em>through</em> Gradle,
/// use <see cref="WithGradleTask{T}(IResourceBuilder{T}, string, string[])"/> instead.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithGradleBuild<T>(
this IResourceBuilder<T> builder,
params string[] args) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(args);
return builder.WithJavaBuildStep(
JavaBuildTool.Gradle,
buildResourceName: $"{builder.Resource.Name}-gradle-build",
buildArgs: args.Length > 0 ? args : ["clean", "build"]);
}
private static IResourceBuilder<T> WithJavaBuildStep<T>(
this IResourceBuilder<T> builder,
JavaBuildTool tool,
string buildResourceName,
string[] buildArgs)
where T : JavaAppResource
{
// Building with both tools would produce two artifacts and leave the container build with no way
// to choose between them, so it is rejected the same way conflicting launch modes are.
if (builder.Resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var existing) && existing.Tool != tool)
{
throw new InvalidOperationException(
$"Resource '{builder.Resource.Name}' is already configured to build with {existing.Tool}. " +
$"Call either WithMavenBuild or WithGradleBuild, not both.");
}
if (builder.Resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var launchTool) && launchTool.Tool != tool)
{
throw new InvalidOperationException(
$"Resource '{builder.Resource.Name}' is already configured to launch with {launchTool.Tool}. " +
"A Java application is built and launched by a single build tool.");
}
// A launch goal such as spring-boot:run or bootRun compiles the application on its way to running
// it, so a build resource in front of it would only repeat work. That holds only while the launch
// goal is what actually starts the application and nothing else needs the build's output first.
var createRunResource = builder.ApplicationBuilder.ExecutionContext.IsRunMode
&& (RequiresBuildBeforeLaunch(builder)
|| launchTool is null && !builder.Resource.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>());
// Recorded in every execution context: in publish mode there is no build-step resource, but the
// generated Dockerfile still runs this tool and these arguments to produce the deployable JAR.
builder.WithAnnotation(
new JavaBuildStepAnnotation(
createRunResource ? buildResourceName : null,
tool,
buildArgs),
ResourceAnnotationMutationBehavior.Replace);
if (!createRunResource)
{
return builder;
}
// Calling the same method twice must not add a second resource under the same name. The
// annotation was just replaced, and the arguments are read from it on every run, so the existing
// resource already reflects the new arguments.
//
// The check is on ResourceName rather than on the annotation, because an earlier call can record
// a build step without creating a resource: createRunResource is false when the launch goal
// already compiles the application. If something later makes the build mandatory - a relative
// WithOtelAgent path, for instance, whose agent JAR the build has to produce before launch -
// this call is the one that has to create it. Returning early on the annotation alone left the
// new annotation naming a resource that was never added, and the application then failed at
// startup loading an agent that nothing had built.
if (existing?.ResourceName is not null)
{
return builder;
}
var resource = builder.Resource;
var wrapperInvocation = ResolveWrapperInvocation(resource, tool);
var buildResource = new JavaBuildResource(buildResourceName, wrapperInvocation.Command, resource.WorkingDirectory, tool);
var buildBuilder = builder.ApplicationBuilder.AddResource(buildResource)
.WithArgs(ctx =>
{
// Resolved on every evaluation rather than captured, because WithWrapperPath can replace
// the wrapper after this resource exists and the leading argument has to follow it.
foreach (var leadingArg in ResolveWrapperInvocation(resource, tool).LeadingArgs)
{
ctx.Args.Add(leadingArg);
}
if (resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep))
{
foreach (var arg in buildStep.Args)
{
ctx.Args.Add(arg);
}
}
})
.WithIconName(JavaIconName)
.WithParentRelationship(resource)
.ExcludeFromManifest()
// The build step runs before the application, so without this a missing wrapper would first
// surface as this resource failing to exec, rather than as the actionable message.
.OnBeforeResourceStarted((_, _, _) =>
{
ValidateWrapperExists(resource, tool);
return Task.CompletedTask;
});
return builder.WaitForCompletion(buildBuilder);
}
private static void RemoveRunBuildResource<T>(
IResourceBuilder<T> builder,
JavaBuildStepAnnotation buildStep)
where T : JavaAppResource
{
if (buildStep.ResourceName is not { } buildResourceName)
{
return;
}
var buildResource = builder.ApplicationBuilder.Resources
.OfType<ExecutableResource>()
.FirstOrDefault(resource => string.Equals(resource.Name, buildResourceName, StringComparisons.ResourceName));
if (buildResource is null)
{
return;
}
// The child and both dependency annotations were added as one unit by WithJavaBuildStep. Removing
// all three prevents a launch goal configured later from leaving a dangling wait on a resource
// that no longer runs.
builder.ApplicationBuilder.Resources.Remove(buildResource);
foreach (var annotation in builder.Resource.Annotations
.Where(annotation =>
annotation is WaitAnnotation wait && ReferenceEquals(wait.Resource, buildResource)
|| annotation is ResourceRelationshipAnnotation relationship
&& ReferenceEquals(relationship.Resource, buildResource))
.ToArray())
{
builder.Resource.Annotations.Remove(annotation);
}
builder.WithAnnotation(
buildStep with { ResourceName = null },
ResourceAnnotationMutationBehavior.Replace);
}
/// <summary>
/// Overrides the build tool wrapper script path, for repositories whose wrapper is not in the
/// default location or does not use the default name.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The <see cref="IResourceBuilder{T}"/> to configure.</param>
/// <param name="wrapperPath">The path to the wrapper script, absolute or relative to the resource's working directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="wrapperPath"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <remarks>
/// May be called before or after the build tool is configured. A later call re-points anything that
/// already resolved the default wrapper, so the result does not depend on the order of builder calls.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithWrapperPath<T>(
this IResourceBuilder<T> builder,
string wrapperPath) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(wrapperPath);
var resolvedWrapperPath = PathNormalizer.NormalizePathForCurrentPlatform(
Path.Combine(builder.Resource.WorkingDirectory, wrapperPath));
builder.WithAnnotation(new WrapperAnnotation(resolvedWrapperPath), ResourceAnnotationMutationBehavior.Replace);
// Re-point anything that already captured the default wrapper. Without this, calling
// WithWrapperPath after WithMavenGoal was silently ignored.
if (builder.Resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var buildTool))
{
builder.WithCommand(ResolveWrapperInvocation(builder.Resource, buildTool.Tool).Command);
}
foreach (var buildStep in builder.Resource.Annotations.OfType<JavaBuildStepAnnotation>())
{
// Null outside run mode, where no build-step resource is created.
if (buildStep.ResourceName is not { } buildStepName)
{
continue;
}
var buildResource = builder.ApplicationBuilder.Resources
.OfType<ExecutableResource>()
.FirstOrDefault(r => string.Equals(r.Name, buildStepName, StringComparisons.ResourceName));
if (buildResource is not null)
{
builder.ApplicationBuilder.CreateResourceBuilder(buildResource)
.WithCommand(ResolveWrapperInvocation(builder.Resource, buildStep.Tool).Command);
}
}
return builder;
}
/// <summary>
/// Sets the main class an IDE launches when running or debugging this application. Has no effect on
/// how Aspire starts the process, which is decided by the JAR path, Maven goal, or Gradle task.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="mainClass">The fully qualified name of the class declaring <c>main</c>, for example <c>com.example.Application</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="mainClass"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <remarks>
/// Only affects IDE execution. When omitted, the IDE resolves the main class from the project's build
/// files; set it explicitly when a project declares more than one class with a <c>main</c> method.
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithMainClass<T>(
this IResourceBuilder<T> builder,
string mainClass) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(mainClass);
return builder.WithAnnotation(new JavaMainClassAnnotation(mainClass), ResourceAnnotationMutationBehavior.Replace);
}
/// <summary>
/// Selects the JAR the generated container image runs, for projects whose build produces more than one.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder for the Java application.</param>
/// <param name="jarPath">The path to the JAR produced by the build, relative to the application directory, for example <c>target/app.jar</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="jarPath"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <remarks>
/// Only affects publishing, and only when the application is built in the image. Without it the
/// container build selects the single JAR that is not a <c>-plain</c>, <c>-sources</c>, or
/// <c>-javadoc</c> artifact, and fails the build if that is ambiguous.
/// <para>
/// This takes precedence over the JAR named by the <c>jarPath</c> overload of
/// <see cref="AddJavaApp(IDistributedApplicationBuilder, string, string, string, string[])"/>, so a
/// resource can run one JAR locally and publish another. It has no effect on an application published
/// from a prebuilt JAR, because nothing is built in the image for it to select from.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithJarArtifact<T>(
this IResourceBuilder<T> builder,
string jarPath) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(jarPath);
return builder.WithAnnotation(new JavaJarArtifactAnnotation(jarPath), ResourceAnnotationMutationBehavior.Replace);
}
/// <summary>
/// Adds arguments to the Java Virtual Machine that runs the application.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="args">The JVM arguments, for example <c>-Xmx512m</c>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="args"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Arguments are passed through the <c>JAVA_TOOL_OPTIONS</c> environment variable, which the JVM reads
/// however it was started — <c>java -jar</c>, a Maven goal, a Gradle task, or a container image's own
/// entrypoint, including the JVM those build tools fork. Values containing spaces are quoted, because
/// the JVM splits this variable on whitespace.
/// <para>
/// This is also how a container image that already carries the OpenTelemetry Java agent turns it on,
/// since <see cref="WithOtelAgent{T}(IResourceBuilder{T}, string)"/> copies an agent from the build
/// context and so applies only to applications Aspire itself launches or builds:
/// <c>WithJvmArgs("-javaagent:/app/opentelemetry-javaagent.jar")</c>.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithJvmArgs<T>(
this IResourceBuilder<T> builder,
params string[] args) where T : IJavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(args);
if (args.Length == 0)
{
return builder;
}
return builder.WithEnvironment(context =>
{
// Keep JAVA_TOOL_OPTIONS as the single JVM-argument source even when an IDE owns the launch.
// DCP passes the resource environment to that JVM, so also emitting vmArgs would apply
// single-instance options such as -javaagent twice and can double-instrument the application.
AppendJavaToolOptions(context.EnvironmentVariables, args);
});
}
/// <summary>
/// Runs the application with the OpenTelemetry Java agent from the location the build tool writes it to.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The resource has no Maven or Gradle build configured, so the agent location cannot be inferred. Raised when the application model is built, not when this is called.</exception>
/// <remarks>
/// The agent is expected at <c>target/agent/opentelemetry-javaagent.jar</c> for Maven and
/// <c>build/agent/opentelemetry-javaagent.jar</c> for Gradle — the conventional output directory of each tool
/// with an <c>agent</c> subdirectory. The build has to put it there; nothing is downloaded. With Maven, copy it
/// with <c>maven-dependency-plugin</c>'s <c>copy</c> goal bound to <c>process-resources</c>; with Gradle,
/// declare the agent in its own configuration and add a <c>Copy</c> task that <c>compileJava</c> depends on.
/// <para>
/// May be called before or after the build tool is configured: which directory the agent is read from is
/// decided when the application model is built, so the result does not depend on the order of builder calls.
/// </para>
/// <para>
/// Because the build writes the agent, Aspire runs that build as its own resource in run mode and holds
/// the application until it finishes — including for Spring Boot and Quarkus, whose launch goals would
/// otherwise be the only build. This is required rather than an optimization: <c>JAVA_TOOL_OPTIONS</c> is
/// read by every JVM started beneath the resource, and the first of those is the wrapper's own, so an
/// agent the build has not written yet kills that JVM during VM initialization with "Error opening zip
/// file or JAR manifest missing" before the launch goal runs.
/// </para>
/// <para>
/// Use <see cref="WithOtelAgent{T}(IResourceBuilder{T}, string)"/> when the agent lives anywhere else, including
/// when it is committed to the repository or supplied by the container base image.
/// </para>
/// </remarks>
[AspireExport("withOtelAgentDefaultPath")]
public static IResourceBuilder<T> WithOtelAgent<T>(
this IResourceBuilder<T> builder) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
// Deliberately not resolved here. WithMavenBuild may not have been called yet, and an eager read
// would make .WithOtelAgent().WithMavenBuild() throw while .WithMavenBuild().WithOtelAgent()
// worked - exactly the order dependence WithWrapperPath goes out of its way to avoid.
return builder.WithOtelAgentCore(agentPath: null);
}
/// <summary>
/// The agent path to use, resolving the build tool's conventional location when none was authored.
/// </summary>
/// <remarks>
/// The launch tool is not consulted, only the build: a resource can be launched from a prebuilt JAR and
/// still be built by Maven, and it is the build that decides whether the agent lands in <c>target</c> or
/// <c>build</c>.
/// </remarks>
internal static string ResolveOtelAgentPath(IResource resource, JavaOtelAgentAnnotation annotation)
{
if (annotation.AgentPath is { } authored)
{
return authored;
}
JavaBuildTool tool;
if (resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep))
{
tool = buildStep.Tool;
}
else if (resource is JavaAppResource app && app.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>())
{
tool = ResolveDetectedBuildTool(app).Tool;
}
else
{
throw new InvalidOperationException(
$"Resource '{resource.Name}' has no Maven or Gradle build configured, so the OpenTelemetry agent location cannot be inferred. " +
$"Call WithMavenBuild or WithGradleBuild, or pass the agent path to WithOtelAgent.");
}
var outputDirectory = tool is JavaBuildTool.Gradle ? "build" : "target";
return Path.Combine(outputDirectory, "agent", "opentelemetry-javaagent.jar");
}
/// <summary>
/// Runs the application with the OpenTelemetry Java agent so it exports traces, metrics, and logs to Aspire.
/// </summary>
/// <typeparam name="T">The Java application resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="agentPath">The path to the <c>opentelemetry-javaagent.jar</c> file.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="agentPath"/> is <see langword="null"/>, empty, or whitespace.</exception>
/// <remarks>
/// The agent is not downloaded. Obtain it as a build dependency, or from
/// https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases, and point this at the
/// resulting file. The OTLP exporter is configured by <c>AddJavaApp</c> regardless of whether an agent
/// is used, so call this only when you want the agent's automatic instrumentation.
/// <para>
/// A relative <paramref name="agentPath"/> is resolved against the application directory and made absolute
/// when running locally. This is required, not cosmetic: <c>JAVA_TOOL_OPTIONS</c> is inherited by every JVM
/// started beneath the resource, and build tools start JVMs whose working directory is not the application
/// directory. The Gradle daemon, for example, starts from its own distribution directory, so a relative
/// <c>-javaagent:</c> path fails to resolve and the daemon dies during VM initialization with
/// "Error opening zip file or JAR manifest missing".
/// </para>
/// <para>
/// A relative path names a file the build produces, so Aspire runs that build as its own resource in run
/// mode and holds the application until it finishes. An absolute path names a file that exists
/// independently of the build, so no build resource is added.
/// </para>
/// <para>
/// In publish mode a relative path is rewritten to the location the generated Dockerfile copies the
/// agent to, because the path has to be interpreted inside the container rather than on the build
/// machine. An absolute path is emitted unchanged, since it cannot have come from the build context
/// and must be supplied by the base image or a mount.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<T> WithOtelAgent<T>(
this IResourceBuilder<T> builder,
string agentPath) where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(agentPath);
return builder.WithOtelAgentCore(agentPath);
}
private static IResourceBuilder<T> WithOtelAgentCore<T>(
this IResourceBuilder<T> builder,
string? agentPath) where T : JavaAppResource
{
// Recorded so the container build can copy the agent forward. The environment variable alone
// would leave a published image pointing at a JAR that is not in it.
var isFirstCall = !builder.Resource.HasAnnotationOfType<JavaOtelAgentAnnotation>();
builder.WithAnnotation(new JavaOtelAgentAnnotation(agentPath), ResourceAnnotationMutationBehavior.Replace);
EnsureBuildRunsBeforeLaunch(builder);
// Callbacks accumulate even though the annotation replaces, so registering one per call would
// put a -javaagent: entry per call into JAVA_TOOL_OPTIONS and start the JVM with several agents.
// Only the first call registers, and it reads the replaced annotation so it sees the last path.
if (!isFirstCall)
{
return builder;
}
return builder.WithEnvironment(context =>
{
if (!builder.Resource.TryGetLastAnnotation<JavaOtelAgentAnnotation>(out var agent))
{
return;
}
var authored = ResolveOtelAgentPath(builder.Resource, agent);
string resolved;
if (context.ExecutionContext.IsRunMode)
{
resolved = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, authored));
}
else if (JavaDockerfileGenerator.TryGetBuildProducedAgentPath(builder.Resource, out _))
{
// /app/agent.jar is where the generated Dockerfile copies the agent to. When the
// developer wrote the Dockerfile, nothing put it there, and a JVM told to load an agent
// that is not in the image dies during VM initialization with "Error opening zip file or
// JAR manifest missing" — which says nothing about the cause.
if (builder.Resource.HasAnnotationOfType<JavaAuthoredDockerfileAnnotation>())
{
throw new DistributedApplicationException(
$"Java application '{builder.Resource.Name}' cannot be published because it uses " +
$"the Dockerfile in '{builder.Resource.WorkingDirectory}' and its OpenTelemetry " +
$"agent path '{authored}' is relative to the build output. Aspire copies a " +
$"build-produced agent into the image only in the Dockerfile it generates. Copy " +
$"the agent in your Dockerfile and pass its path inside the image to " +
$"{nameof(WithOtelAgent)}, for example WithOtelAgent(\"/opt/otel/javaagent.jar\").");
}
resolved = JavaDockerfileGenerator.ContainerAgentPath;
}
else
{
resolved = authored;
}
AppendJavaToolOptions(context.EnvironmentVariables, [$"-javaagent:{resolved}"]);
});
}
/// <summary>
/// Adds the build whose output the application needs before it starts, when there is one.
/// </summary>
/// <remarks>
/// <para>
/// Two things need a build in front of a launch goal that would otherwise compile as it runs.
/// </para>
/// <para>
/// A build-produced OpenTelemetry agent is the first. <c>JAVA_TOOL_OPTIONS</c> is read by every JVM
/// started beneath the resource, and for a Maven or Gradle launch the first of those is the wrapper's
/// own. A <c>-javaagent:</c> naming a file the build has not written yet therefore kills that JVM
/// during VM initialization with "Error opening zip file or JAR manifest missing", before the launch
/// goal that would have produced the agent ever runs. The build has to be a resource of its own so it
/// runs in a JVM that is not carrying the agent.
/// </para>
/// <para>
/// A Quarkus resource handed to an IDE is the second: the IDE starts it from the fast JAR the build
/// packages rather than from the dev-mode goal. See <see cref="IdeLaunchesAPackagedArtifact"/>.
/// </para>
/// </remarks>
private static void EnsureBuildRunsBeforeLaunch<T>(IResourceBuilder<T> builder)
where T : JavaAppResource
{
// Publish resolves the agent to the path the generated Dockerfile copies it to, the image build
// runs the packaging command itself, and no IDE launches anything, so there is no resource to add.
if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode
|| !RequiresBuildBeforeLaunch(builder))
{
return;
}
// Naming the resource and resolving its wrapper both need the tool, which is unknown when the
// agent is configured before the build tool is. That ordering stays supported: the later
// WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask call reaches
// WithJavaBuildStep, which adds the resource once RequiresBuildBeforeLaunch is true.
if (!TryResolveConfiguredBuildTool(builder.Resource, out var tool))
{
return;
}
var buildArgs = builder.Resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep)
? buildStep.Args
: builder.Resource.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>()
? ResolveDetectedBuildTool(builder.Resource).Configuration.BuildArgs
: DefaultBuildArgs(tool);
builder.WithJavaBuildStep(
tool,
buildResourceName: $"{builder.Resource.Name}-{(tool is JavaBuildTool.Gradle ? "gradle" : "maven")}-build",
buildArgs: buildArgs);
}
/// <summary>
/// The packaging arguments <see cref="WithMavenBuild{T}(IResourceBuilder{T}, string[])"/> and
/// <see cref="WithGradleBuild{T}(IResourceBuilder{T}, string[])"/> default to.
/// </summary>
private static string[] DefaultBuildArgs(JavaBuildTool tool) =>
tool is JavaBuildTool.Gradle ? ["clean", "build"] : ["clean", "package"];
/// <summary>
/// Whether something other than the launch goal needs the build's output before the application
/// starts, which makes a build resource mandatory even for a goal that compiles as it runs.
/// </summary>
private static bool RequiresBuildBeforeLaunch<T>(IResourceBuilder<T> builder)
where T : JavaAppResource
=> HasBuildProducedOtelAgent(builder.Resource)
|| IdeLaunchesAPackagedArtifact(builder);
/// <summary>
/// Whether this run hands the resource to an IDE that starts it from an artifact the build produces
/// rather than from the launch goal.
/// </summary>
/// <remarks>
/// Only Quarkus is in this position. Its entry point lives in the fast JAR's boot classpath rather
/// than in the project, so <see cref="ResolveEntryPointForIde"/> has to hand the debug adapter
/// <c>quarkus-app/quarkus-run.jar</c> — and on a clean checkout no build has written it. The adapter's
/// response to being given no entry point is to ask which of the workspace's main classes to start,
/// a prompt nobody who has not read the AppHost can answer, so the build has to run first.
/// <para>
/// Spring Boot needs nothing here: the adapter starts it from the classpath the Java language server
/// already compiled, so a build would only delay the session.
/// </para>
/// </remarks>
private static bool IdeLaunchesAPackagedArtifact<T>(IResourceBuilder<T> builder)
where T : JavaAppResource
=> builder.Resource.HasAnnotationOfType<JavaQuarkusAnnotation>()
&& builder.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out _);
/// <summary>
/// Whether the resource's OpenTelemetry agent is one its own build writes, rather than one the machine
/// or the base image already provides.
/// </summary>
private static bool HasBuildProducedOtelAgent(JavaAppResource resource)
{
if (!resource.TryGetLastAnnotation<JavaOtelAgentAnnotation>(out var agent))
{
return false;
}
// The default location is under the build tool's output directory, so it is always build-produced.
// An authored absolute path names a file that exists independently of the build; a relative one is
// resolved against the application directory, which is where the build writes.
return agent.AgentPath is not { } authored || !JavaDockerfileGenerator.IsPathRootedOnAnyPlatform(authored);
}
/// <summary>
/// Contributes the arguments that turn the resource's command into a complete invocation:
/// <c>-jar <path></c> for a prebuilt JAR, or the goal/task for a build tool launch.
/// </summary>
private static void AddLaunchArgs(JavaAppResource resource, CommandLineArgsCallbackContext ctx)
{
JavaBuildTool tool;
string[] args;
if (resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var buildTool))
{
tool = buildTool.Tool;
args = buildTool.Args;
}
else if (resource.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>())
{
var detected = ResolveDetectedBuildTool(resource);
tool = detected.Tool;
args = detected.Configuration.LaunchArgs;
}
else
{
tool = default;
args = [];
}
if (args.Length > 0)
{
foreach (var leadingArg in ResolveWrapperInvocation(resource, tool).LeadingArgs)
{
ctx.Args.Add(leadingArg);
}
foreach (var arg in args)
{
ctx.Args.Add(arg);
}
return;
}
if (resource.TryGetLastAnnotation<JavaJarPathAnnotation>(out var jar))
{
ctx.Args.Add("-jar");
ctx.Args.Add(NormalizeJarPathForJava(jar.JarPath));
return;
}
// Reached when AddJavaApp was called without a jar path and without a Maven goal or Gradle task.
// The resource would otherwise start as a bare "java" with no arguments, which prints the JVM
// usage text and exits.
throw new InvalidOperationException(
$"Java application '{resource.Name}' has no launch mode configured. Call {nameof(WithMavenGoal)} " +
$"or {nameof(WithGradleTask)} to run it through a build tool, or use the {nameof(AddJavaApp)} " +
"overload that takes a jarPath to run a prebuilt JAR.");
}
private static string NormalizeJarPathForJava(string path)
{
// Java accepts '/' on Windows, so one target-neutral form preserves authored forward slashes
// while also making a Windows-authored relative path usable on Linux and macOS.
return path.Replace('\\', '/');
}
/// <summary>
/// Resolves the wrapper script for <paramref name="tool"/>, honouring an override set by
/// <see cref="WithWrapperPath{T}(IResourceBuilder{T}, string)"/>.
/// </summary>
/// <remarks>
/// <para>
/// A wrapper is required; a globally installed <c>mvn</c> or <c>gradle</c> is deliberately not used as
/// a fallback. The wrapper pins the exact tool version in the repository, so the AppHost, CI, and the
/// published container image all build with the same one. Falling back to whatever happens to be on
/// <c>PATH</c> would make the build depend on each developer's machine and silently change behaviour
/// when that version differs, which is precisely what the wrapper exists to prevent.
/// </para>
/// <para>
/// Existence is deliberately not checked here. This runs while the AppHost is still being authored,
/// and <see cref="WithWrapperPath{T}(IResourceBuilder{T}, string)"/> is documented as usable after the
/// build tool is configured — so a project whose only wrapper is a custom one would otherwise fail
/// inside <c>WithMavenGoal</c>/<c>WithGradleTask</c>, before the override could be applied.
/// <see cref="ValidateWrapperExists"/> performs the check once the configuration is final.
/// </para>
/// </remarks>
/// <summary>
/// The command that launches a build tool wrapper, together with any arguments that must precede
/// the wrapper's own.
/// </summary>
/// <remarks>
/// On Unix the wrapper is invoked through <c>sh</c> instead of being executed directly. Git does not
/// record an executable bit on Windows, so a repository committed from there checks out <c>mvnw</c>
/// and <c>gradlew</c> as mode 644 and executing them fails with "permission denied". Both are POSIX
/// shell scripts, so <c>sh</c> runs them either way. The container build already does this for the
/// same reason (see <see cref="JavaDockerfileGenerator"/>), and run mode has to match or an identical
/// checkout fails on Linux and macOS while succeeding inside the image.
/// <para>
/// On Windows the wrappers are the <c>mvnw.cmd</c> and <c>gradlew.bat</c> batch files, which <c>sh</c>
/// cannot run. They are launched through the command interpreter rather than directly, because a
/// batch file started with redirected stdout can silently produce no output — the same constraint
/// <c>NpmRunner</c> hits with <c>npm.cmd</c>, and the same one
/// <c>JavaAppHostToolchainResolver.GetToolInvocation</c> handles for Java AppHosts.
/// </para>
/// </remarks>
private static (string Command, string[] LeadingArgs) ResolveWrapperInvocation(JavaAppResource resource, JavaBuildTool tool)
=> WrapperInvocationFor(
JavaBuildToolResolver.ResolveWrapperPath(resource, tool, OperatingSystem.IsWindows()),
resource.WorkingDirectory,
OperatingSystem.IsWindows());
/// <inheritdoc cref="ResolveWrapperInvocation" />
internal static (string Command, string[] LeadingArgs) WrapperInvocationFor(string wrapperPath, string workingDirectory, bool isWindows)
{
if (!isWindows)
{
return ("sh", [wrapperPath]);
}
// Passing the wrapper as a path relative to the resource's working directory keeps it short and
// usually free of spaces, which matters because cmd.exe strips quotes in a way that does not
// match how arguments are escaped for it: when the *first* token on the line is quoted, cmd
// removes that quote and the last one on the line, mangling everything in between.
var relativeWrapperPath = Path.GetRelativePath(workingDirectory, wrapperPath);
// "call" makes that unreachable rather than merely unlikely. A wrapper reached through a
// directory with a space in its name — WithWrapperPath("../build tools/mvnw.cmd") — is quoted
// when the command line is built, and quoting the first token is exactly what triggers the
// stripping. With "call" ahead of it the first character is never a quote, so the rule cannot
// apply, and "call" is how a batch file is meant to be invoked from another anyway: it returns
// control and propagates the wrapper's exit code.
// See the quote-processing rules printed by `cmd /?`.
return (Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", ["/c", "call", relativeWrapperPath]);
}
private static string WrapperCommand()
=> OperatingSystem.IsWindows()
? Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"
: "sh";
/// <summary>
/// Throws when the wrapper the resource will launch is not on disk.
/// </summary>
/// <remarks>
/// Deferred to resource start so that the whole AppHost has been authored first: only then is it
/// known whether a <see cref="WithWrapperPath{T}(IResourceBuilder{T}, string)"/> override supplied
/// the wrapper that the default location lacks.
/// </remarks>
private static void ValidateWrapperExists(JavaAppResource resource, JavaBuildTool tool)
{
var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, tool, OperatingSystem.IsWindows());
if (File.Exists(wrapperPath))
{
return;
}
if (resource.HasAnnotationOfType<WrapperAnnotation>())
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' has no wrapper at '{wrapperPath}'. That path came " +
$"from {nameof(WithWrapperPath)} and is resolved relative to the application's working " +
$"directory '{resource.WorkingDirectory}'.");
}
var wrapperName = JavaBuildToolResolver.GetDefaultWrapperName(tool, OperatingSystem.IsWindows());
throw new DistributedApplicationException(
$"Java application '{resource.Name}' has no {wrapperName} in '{resource.WorkingDirectory}' " +
$"or in the build root above it. Aspire runs Java applications through the project's own " +
$"wrapper so that every build uses the tool version the repository pins. Generate one with " +
$"{GenerateWrapperCommand(tool)}, or point at an existing wrapper with {nameof(WithWrapperPath)}.");
}
/// <summary>
/// Arranges for the resource's wrapper to be validated once its configuration is final.
/// </summary>
private static IResourceBuilder<T> WithDeferredWrapperValidation<T>(
this IResourceBuilder<T> builder,
JavaBuildTool tool) where T : JavaAppResource
{
// WithMavenGoal and WithMavenBuild both want this, and either may be called more than once, so
// the subscription is registered at most once per tool.
if (builder.Resource.Annotations.OfType<JavaWrapperValidationAnnotation>().Any(a => a.Tool == tool))
{
return builder;
}
builder.WithAnnotation(new JavaWrapperValidationAnnotation(tool));
return builder.OnBeforeResourceStarted((resource, _, _) =>
{
ValidateWrapperExists(resource, tool);
return Task.CompletedTask;
});
}
/// <summary>
/// The command that adds a wrapper to an existing project, named in the error raised when one is missing.
/// </summary>
internal static string GenerateWrapperCommand(JavaBuildTool tool) => tool switch
{
// -N keeps the goal from recursing into the modules of a multi-module build, which would litter
// every module with a wrapper that only the root needs.
JavaBuildTool.Maven => "'mvn -N wrapper:wrapper'",
JavaBuildTool.Gradle => "'gradle wrapper'",
_ => throw new ArgumentOutOfRangeException(nameof(tool), tool, null)
};
/// <summary>
/// Appends <paramref name="values"/> to the <c>JAVA_TOOL_OPTIONS</c> environment variable, preserving
/// whatever is already there.
/// </summary>
/// <remarks>
/// The existing value may be any expression Aspire supports — a plain string, a
/// <see cref="ReferenceExpression"/>, a parameter, or an endpoint reference — so a non-string value is
/// folded into a new <see cref="ReferenceExpression"/> rather than being read as a string. An earlier
/// string-only implementation silently discarded non-string values.
/// </remarks>
private static void AppendJavaToolOptions(Dictionary<string, object> environmentVariables, string[] values)
{
var appended = string.Join(' ', values.Select(QuoteIfNeeded));
if (!environmentVariables.TryGetValue(JavaToolOptions, out var existing) || existing is null)
{
environmentVariables[JavaToolOptions] = appended;
return;
}
environmentVariables[JavaToolOptions] = existing switch
{
string s when string.IsNullOrEmpty(s) => appended,
string s => $"{s} {appended}",
ReferenceExpression re => ReferenceExpression.Create($"{re} {appended}"),
IValueProvider valueProvider when existing is IManifestExpressionProvider manifestProvider
=> ReferenceExpression.Create($"{new ComposableValue(valueProvider, manifestProvider)} {appended}"),
// Anything else is a plain value (a number, a bool, a string-convertible object) that the
// environment layer would format the same way.
_ => $"{existing} {appended}"
};
}
/// <summary>
/// Pairs the two facets Aspire needs to compose a value into a <see cref="ReferenceExpression"/>.
/// </summary>
/// <remarks>
/// <see cref="ReferenceExpression.ExpressionInterpolatedStringHandler.AppendFormatted{T}(T)"/> is
/// constrained to a single type implementing both interfaces, which a value typed as <c>object</c>
/// cannot satisfy without this adapter.
/// </remarks>
private sealed class ComposableValue(IValueProvider valueProvider, IManifestExpressionProvider manifestExpressionProvider)
: IValueProvider, IManifestExpressionProvider
{
public string ValueExpression => manifestExpressionProvider.ValueExpression;
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken) => valueProvider.GetValueAsync(cancellationToken);
}
/// <summary>
/// Quotes a JVM option whose value contains whitespace.
/// </summary>
/// <remarks>
/// The JVM tokenizes <c>JAVA_TOOL_OPTIONS</c> on whitespace, so an unquoted
/// <c>-Djavax.net.ssl.trustStore=C:\Users\First Last\AppData\...\bundle.p12</c> arrives as two
/// unrelated options and TLS fails with no useful diagnostic. Only the value after the first
/// <c>=</c> is quoted, because the JVM does not accept a quoted <c>-Dkey=value</c> as a whole.
/// <para>
/// An option that already contains a quote is passed through untouched. Quoting it again would turn
/// an author's own <c>-Dmsg="hi there"</c> into <c>-Dmsg=""hi there""</c>, and deciding whether the
/// existing quotes already cover the whitespace needs a real shell-style parser. Handing the option
/// back unchanged leaves quoting to the author who introduced it, which is the only reading that
/// cannot corrupt an already-correct value.
/// </para>
/// </remarks>
private static string QuoteIfNeeded(string option)
{
if (!option.Any(char.IsWhiteSpace) || option.Contains('"'))
{
return option;
}
var separatorIndex = option.IndexOf('=');
return separatorIndex < 0
? $"\"{option}\""
: $"{option[..(separatorIndex + 1)]}\"{option[(separatorIndex + 1)..]}\"";
}
/// <summary>
/// Builds a PKCS#12 trust store containing Aspire's development certificate and any configured
/// certificate authorities, and points the JVM at it through <c>JAVA_TOOL_OPTIONS</c>.
/// </summary>
/// <remarks>
/// The JVM ignores the <c>SSL_CERT_DIR</c> and <c>SSL_CERT_FILE</c> variables Aspire sets for other
/// languages, so without this a Java application fails to export telemetry over HTTPS with
/// <c>PKIX path building failed</c>. See https://github.com/CommunityToolkit/Aspire/issues/1517.
/// <para>
/// <c>javax.net.ssl.trustStore</c> <em>replaces</em> the JVM's trust anchors rather than adding to
/// them: once it is set, <c>cacerts</c> is not consulted at all. Aspire therefore has to request
/// <see cref="CertificateTrustScope.System"/> so the generated bundle carries the system roots
/// alongside the development certificate. If the scope is still
/// <see cref="CertificateTrustScope.Append"/>, the bundle would contain only Aspire's own
/// certificates, and pointing the JVM at it would strip every public CA — breaking outbound HTTPS
/// from the application and, because <c>JAVA_TOOL_OPTIONS</c> is inherited by the build tool's JVM,
/// breaking Maven Central and Gradle distribution downloads too. In that case the override is
/// skipped instead.
/// </para>
/// </remarks>
private static async Task JavaCertificateTrustCallback(CertificateTrustConfigurationCallbackAnnotationContext ctx)
{
if (ctx.Scope == CertificateTrustScope.Append)
{
var resourceLoggerService = ctx.ExecutionContext.Services.GetRequiredService<ResourceLoggerService>();
resourceLoggerService.GetLogger(ctx.Resource).LogInformation(
"Certificate trust scope is set to 'Append', but the JVM's trust store setting replaces the default " +
"certificate authorities rather than adding to them. Skipping the trust store override so the JVM " +
"keeps trusting its built-in certificate authorities.");
return;
}
var bundlePath = ctx.CreateCustomBundle((certificates, ct) =>
{
var pkcs12Builder = new Pkcs12Builder();
var safeContents = new Pkcs12SafeContents();
// Oracle/OpenJDK trusted cert bag attribute OID. Without it the JDK reads the entries as
// key entry candidates rather than trustedCertEntry, and the store trusts nothing.
// See sun.security.pkcs12.PKCS12KeyStore in the OpenJDK sources.
var trustAnchorOid = new Oid("2.16.840.1.113894.746875.1.1");
var asnWriter = new AsnWriter(AsnEncodingRules.DER);
asnWriter.WriteObjectIdentifier("2.5.29.37.0"); // anyExtendedKeyUsage
var trustAnchorValue = asnWriter.Encode();
for (var i = 0; i < certificates.Count; i++)
{
// Re-import the public part only so no private key can reach the trust store.
using var publicCert = X509CertificateLoader.LoadCertificate(certificates[i].Export(X509ContentType.Cert));
var certBag = safeContents.AddCertificate(publicCert);
certBag.Attributes.Add(
new CryptographicAttributeObject(
trustAnchorOid,
new AsnEncodedDataCollection(new AsnEncodedData(trustAnchorOid, trustAnchorValue))));
}
pkcs12Builder.AddSafeContentsUnencrypted(safeContents);
// Sealed with an empty password on purpose. The MAC still protects integrity, and a trust
// store holds only public certificates, so a password would protect nothing while appearing
// in JAVA_TOOL_OPTIONS — which the dashboard shows unmasked, which every process the build
// tool forks inherits, and which the JVM itself echoes to stderr as
// "Picked up JAVA_TOOL_OPTIONS: ...".
pkcs12Builder.SealWithMac(string.Empty, HashAlgorithmName.SHA256, iterationCount: 2048);
return Task.FromResult(pkcs12Builder.Encode());
});
var bundlePathValue = await bundlePath.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(bundlePathValue))
{
return;
}
AppendJavaToolOptions(
ctx.EnvironmentVariables,
[
$"-Djavax.net.ssl.trustStore={bundlePathValue}",
"-Djavax.net.ssl.trustStoreType=PKCS12"
]);
}
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static IResourceBuilder<T> WithVSCodeDebugging<T>(this IResourceBuilder<T> builder)
where T : JavaAppResource
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithDebugSupport(
mode =>
{
var (mainClass, classPaths) = ResolveEntryPointForIde(builder.Resource);
return new JavaLaunchConfiguration
{
Mode = mode,
WorkingDirectory = builder.Resource.WorkingDirectory,
MainClass = mainClass,
ClassPaths = classPaths,
// projectName scopes the adapter's entry point resolution to this resource's own
// project. It is sent alongside mainClass rather than only as a fallback: given
// mainClass alone the adapter searches every project in the workspace, and a class
// that turns up in more than one fails the launch outright with
// "Main class ... isn't unique in the workspace". That happens whenever a directory
// is covered both by its own build file and by another project's source root, which
// is easy to arrange by accident and impossible to diagnose from the error.
//
// The name is read out of pom.xml or settings.gradle rather than derived from the
// resource name, so it is the name m2e and Buildship import the project under.
//
// It is omitted when explicit class paths are supplied, because such a resource runs
// from a prebuilt archive rather than from a project the language server compiled,
// so there may be no imported project to scope to.
ProjectName = classPaths is { Length: > 0 }
? null
: TryResolveIdeProjectName(builder.Resource),
BuildTool = TryResolveConfiguredBuildTool(builder.Resource, out var buildTool)
? buildTool.ToString().ToLowerInvariant()
: null
};
},
"java");
}
/// <summary>
/// Determines what the IDE should launch: the entry point class and, when the resource runs a
/// prebuilt JAR, the classpath that contains it.
/// </summary>
/// <remarks>
/// A JAR is never sent as the main class. The Java debug adapter documents that attribute as a
/// fully qualified class name or a <c>.java</c> source path, so it does not open archives; passing
/// a JAR path made the adapter fail to resolve an entry point. Instead the archive goes on the
/// classpath and its manifest's <c>Main-Class</c> becomes the main class, which is exactly what
/// <c>java -jar</c> does and works for Spring Boot fat JARs too (their manifest names
/// <c>JarLauncher</c>, the same class <c>java -jar</c> would run).
/// </remarks>
private static (string? MainClass, string[]? ClassPaths) ResolveEntryPointForIde(JavaAppResource resource)
{
// An explicit WithMainClass always wins, including over a JAR's manifest, so a resource that
// ships a launcher manifest can still be debugged at its real entry point.
var explicitMainClass = resource.TryGetLastAnnotation<JavaMainClassAnnotation>(out var mainClass)
? mainClass.MainClass
: null;
if (!resource.TryGetLastAnnotation<JavaJarPathAnnotation>(out var jar))
{
if (explicitMainClass is not null)
{
return (explicitMainClass, null);
}
// Quarkus is the exception to the rule below. Its entry point lives in the fast JAR's boot
// classpath rather than in the project, so the language server's classpath cannot start it and
// the archive has to be supplied. Breakpoints still bind, because the debugger maps loaded
// classes back to project sources by name.
if (TryGetQuarkusRunJar(resource) is { } quarkusRunJar)
{
return (TryReadJarManifestMainClass(quarkusRunJar), [quarkusRunJar]);
}
// A Maven or Gradle resource deliberately sends no classpath: the language server already
// knows the project's, and supplying one built from the JAR would bind breakpoints to
// compiled classes instead of the source the user is editing.
return (TryDiscoverProjectMainClass(resource), null);
}
// Resolved to an absolute path because the adapter reads the archive before the debuggee's
// working directory exists. Normalize first because a Windows-authored relative path otherwise
// names a literal backslash-containing file when the AppHost runs on Linux or macOS.
var jarPath = Path.GetFullPath(Path.Combine(resource.WorkingDirectory, NormalizeJarPathForJava(jar.JarPath)));
return (explicitMainClass ?? TryReadJarManifestMainClass(jarPath), [jarPath]);
}
/// <summary>
/// Recovers the entry point of a Maven or Gradle resource from the JAR its build produced, or
/// returns <see langword="null"/> when no single application JAR is there to read.
/// </summary>
/// <remarks>
/// <para>
/// Without this, a Spring Boot resource launched through <c>spring-boot:run</c> or <c>bootRun</c>
/// sends no main class, and <c>vscjava.vscode-java-debug</c> responds by asking the user to pick one
/// from every main class in the workspace. That prompt appears on each launch, offers classes
/// belonging to other resources, and cannot be answered correctly by anyone who has not read the
/// AppHost — so the entry point is resolved here instead.
/// </para>
/// <para>
/// The build output is the right place to look because it is the only artifact that names the entry
/// point without parsing a build file: the Spring Boot plugins record it while repackaging, and a
/// plain JAR carries it as <c>Main-Class</c>.
/// </para>
/// </remarks>
private static string? TryDiscoverProjectMainClass(JavaAppResource resource)
{
if (!TryResolveConfiguredBuildTool(resource, out var buildTool))
{
return null;
}
// Both tools have a single conventional output directory, and neither lets the AppHost know
// about a redirected one without parsing the build file.
var outputDirectory = buildTool switch
{
JavaBuildTool.Maven => Path.Combine(resource.WorkingDirectory, "target"),
JavaBuildTool.Gradle => Path.Combine(resource.WorkingDirectory, "build", "libs"),
_ => null
};
if (outputDirectory is null || !Directory.Exists(outputDirectory))
{
return null;
}
string[] jars;
try
{
jars = Directory.GetFiles(outputDirectory, "*.jar", SearchOption.TopDirectoryOnly);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
// A build can leave several archives behind: Maven publishes -sources and -javadoc alongside the
// application, and the Gradle Spring Boot plugin writes the unrepackaged classes to -plain.jar.
// Only one of them is the application, and guessing between two candidates would be worse than
// letting the adapter resolve the class itself.
var applicationJars = jars
.Where(jarPath => !IsAuxiliaryJar(jarPath))
.ToArray();
if (applicationJars.Length != 1)
{
return null;
}
if (TryReadJarManifest(applicationJars[0]) is not { } manifest)
{
return null;
}
// Spring Boot repackaging points Main-Class at its own launcher and moves the application's
// entry point to Start-Class, so Start-Class is what a debugger should start.
// https://docs.spring.io/spring-boot/specification/executable-jar/launching.html
if (manifest.TryGetValue("Start-Class", out var startClass) && !string.IsNullOrWhiteSpace(startClass))
{
return startClass;
}
if (!manifest.TryGetValue("Main-Class", out var entryPoint) || string.IsNullOrWhiteSpace(entryPoint))
{
return null;
}
// A launcher is only startable with the fat JAR on the classpath, which is exactly what this
// path does not send. Reporting it would launch a JVM that fails with ClassNotFoundException.
return entryPoint.StartsWith("org.springframework.boot.loader.", StringComparison.Ordinal)
? null
: entryPoint;
}
/// <summary>
/// Gets the Quarkus fast JAR the build produced, or <see langword="null"/> when the resource is not a
/// Quarkus application or has not been built yet.
/// </summary>
/// <remarks>
/// Quarkus's default packaging leaves the runnable artifact at <c>quarkus-app/quarkus-run.jar</c> under the
/// build tool's output directory. The JAR that sits directly in that output directory is the plain,
/// unrunnable one — it has no <c>Main-Class</c> — so the ordinary discovery path finds nothing to report and
/// the debugger would fall back to asking the user which application to start.
/// See https://quarkus.io/guides/maven-tooling#fast-jar.
/// </remarks>
private static string? TryGetQuarkusRunJar(JavaAppResource resource)
{
if (!resource.HasAnnotationOfType<JavaQuarkusAnnotation>()
|| !TryResolveConfiguredBuildTool(resource, out var buildTool))
{
return null;
}
var runJar = Path.Combine(
resource.WorkingDirectory,
buildTool is JavaBuildTool.Gradle ? "build" : "target",
QuarkusFastJarDirectory,
QuarkusRunJarName);
return File.Exists(runJar) ? Path.GetFullPath(runJar) : null;
}
/// <summary>
/// Compares two directory paths for equivalence, tolerating a trailing separator and, on Windows and
/// macOS, differences in case that the file system itself ignores.
/// </summary>
private static bool ArePathsEquivalent(string left, string right)
=> string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
private static bool IsAuxiliaryJar(string jarPath)
{
var fileName = Path.GetFileName(jarPath);
return fileName.EndsWith("-sources.jar", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith("-javadoc.jar", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith("-plain.jar", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Resolves the name the Java language server imported this resource's project under, so the debug
/// adapter can scope entry point resolution to it instead of searching the whole workspace.
/// Returns <see langword="null"/> when the imported name cannot be predicted with confidence.
/// </summary>
private static string? TryResolveIdeProjectName(JavaAppResource resource)
{
if (!TryResolveConfiguredBuildTool(resource, out var buildTool))
{
return null;
}
var declaredName = buildTool switch
{
JavaBuildTool.Maven => TryReadMavenArtifactId(Path.Combine(resource.WorkingDirectory, "pom.xml")),
JavaBuildTool.Gradle => TryReadGradleProjectName(resource.WorkingDirectory),
_ => null
};
if (declaredName is null)
{
return null;
}
// The declared name is only used when the project directory is named the same, because that is
// the case where the language server is known to import the project under it. The two are not
// always the same: a Gradle build declaring `rootProject.name = 'javaspringboot-apphost'` inside
// a folder named JavaSpringBoot.AppHost.Java is imported as
// "javaspringboot-apphost-JavaSpringBoot.AppHost.Java", appending the directory to keep the name
// unambiguous.
//
// Guessing wrong is worse than not guessing. Without a project name the adapter resolves the
// entry point across the whole workspace, which succeeds whenever that entry point is unique;
// with a name no project answers to, every launch fails.
var directoryName = new DirectoryInfo(resource.WorkingDirectory).Name;
return string.Equals(declaredName, directoryName, StringComparison.Ordinal) ? declaredName : null;
}
/// <summary>
/// Reads a POM's own <c>artifactId</c>, which is the name the language server imports a Maven
/// project under.
/// </summary>
private static string? TryReadMavenArtifactId(string pomPath)
{
if (!File.Exists(pomPath))
{
return null;
}
try
{
var document = XDocument.Load(pomPath);
// Only a direct child of <project> identifies this module. Nearly every Spring Boot POM also
// declares <parent><artifactId>spring-boot-starter-parent</artifactId></parent>, and a
// descendant search would find that one first and name the wrong project.
//
// Matched on LocalName because a POM may or may not declare the Maven namespace:
// <project xmlns="http://maven.apache.org/POM/4.0.0"> ... <artifactId>catalog</artifactId>
var artifactId = document.Root?
.Elements()
.FirstOrDefault(element => element.Name.LocalName == "artifactId")?
.Value;
return string.IsNullOrWhiteSpace(artifactId) ? null : artifactId.Trim();
}
catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Reads <c>rootProject.name</c> from a Gradle settings file, falling back to the directory name
/// that Gradle itself defaults a project's name to.
/// </summary>
private static string? TryReadGradleProjectName(string workingDirectory)
{
foreach (var fileName in (ReadOnlySpan<string>)["settings.gradle", "settings.gradle.kts"])
{
var settingsPath = Path.Combine(workingDirectory, fileName);
if (!File.Exists(settingsPath))
{
continue;
}
try
{
// Groovy and the Kotlin DSL differ only in quoting:
// rootProject.name = 'orders'
// rootProject.name = "orders"
if (GradleRootProjectNameRegex().Match(File.ReadAllText(settingsPath)) is { Success: true } match)
{
return match.Groups["name"].Value;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Fall through to the directory name.
}
}
// https://docs.gradle.org/current/userguide/multi_project_builds.html — a build that does not
// name itself takes the name of the directory containing it.
var directoryName = Path.GetFileName(workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
return string.IsNullOrEmpty(directoryName) ? null : directoryName;
}
[GeneratedRegex(@"rootProject\.name\s*=\s*['""](?<name>[^'""]+)['""]")]
private static partial Regex GradleRootProjectNameRegex();
/// <summary>
/// Reads <c>Main-Class</c> from a JAR's manifest, or returns <see langword="null"/> when the archive
/// is missing, unreadable, or declares no entry point.
/// </summary>
/// <remarks>
/// <para>
/// Failures are non-fatal on purpose. The JAR is usually produced by a build step that runs before
/// launch, but a user can start a debug session against a stale or half-written archive. Returning
/// null lets the IDE resolve the entry point from the project instead of failing the whole run.
/// </para>
/// <para>
/// <c>META-INF/MANIFEST.MF</c> is a line-oriented <c>Name: value</c> format where each line is
/// limited to 72 bytes and longer values continue on the next line with a single leading space,
/// which the space must be stripped from. Names are case-insensitive. For example:
/// </para>
/// <code>
/// Manifest-Version: 1.0
/// Main-Class: com.example.catalog.averylongpackagename.that.wraps.Catalo
/// gApplication
/// </code>
/// <para>
/// See https://docs.oracle.com/en/java/javase/25/docs/specs/jar/jar.html#jar-manifest.
/// </para>
/// </remarks>
private static string? TryReadJarManifestMainClass(string jarPath)
{
if (TryReadJarManifest(jarPath) is not { } manifest)
{
return null;
}
return manifest.TryGetValue("Main-Class", out var mainClass) && !string.IsNullOrWhiteSpace(mainClass)
? mainClass
: null;
}
/// <summary>
/// Reads the main section of a JAR's manifest, or returns <see langword="null"/> when the archive is
/// missing, unreadable, or carries no manifest.
/// </summary>
private static Dictionary<string, string>? TryReadJarManifest(string jarPath)
{
try
{
using var archive = ZipFile.OpenRead(jarPath);
// The JAR specification requires this exact name, but archive entry lookup is
// case-sensitive while some tools write the directory in a different case.
var manifestEntry = archive.Entries.FirstOrDefault(
entry => string.Equals(entry.FullName, "META-INF/MANIFEST.MF", StringComparison.OrdinalIgnoreCase));
if (manifestEntry is null)
{
return null;
}
using var reader = new StreamReader(manifestEntry.Open());
// Attribute names are case-insensitive per the specification.
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? name = null;
var value = new StringBuilder();
while (reader.ReadLine() is { } line)
{
// A blank line ends the main section. Everything after it describes individual archive
// entries and would overwrite the main attributes with per-entry ones of the same name.
if (line.Length == 0)
{
break;
}
// Values longer than 72 bytes continue on the following line behind a single space,
// which belongs to the encoding rather than the value.
if (line[0] == ' ')
{
if (name is not null)
{
value.Append(line, 1, line.Length - 1);
}
continue;
}
Commit();
var separator = line.IndexOf(':');
if (separator < 0)
{
name = null;
continue;
}
name = line[..separator];
value.Append(line[(separator + 1)..].TrimStart());
}
Commit();
return attributes;
void Commit()
{
if (name is not null && value.Length > 0)
{
attributes[name] = value.ToString();
}
name = null;
value.Clear();
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
return null;
}
}
}
#pragma warning restore ASPIREPIPELINES003
#pragma warning restore ASPIREPIPELINES001
#pragma warning restore ASPIREDOCKERFILEBUILDER001