// 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
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Java;
/// <summary>
/// Generates the multi-stage Dockerfile that publishes a <see cref="JavaAppResource"/>.
/// </summary>
/// <remarks>
/// The container build is the only build: nothing here compiles the project on the host. The build stage
/// runs the project's own Maven or Gradle wrapper so the image is produced by exactly the tool version the
/// repository pins, and the runtime stage carries only a JRE and the resulting JAR.
/// </remarks>
internal static partial class JavaDockerfileGenerator
{
// Kept outside /app so a build that writes into its own working directory cannot move the JAR
// somewhere COPY --from does not look.
private const string ContainerArtifactPath = "/build/app.jar";
// Quarkus's fast JAR is a directory of interdependent parts rather than a single file, so it is staged
// as a directory and copied into /app whole.
private const string ContainerArtifactDirectory = "/build/app";
/// <summary>
/// Where a build-produced OpenTelemetry agent lands in the runtime image. Fixed rather than mirroring
/// the source layout so the entrypoint environment does not depend on the build tool's output paths.
/// </summary>
internal const string ContainerAgentPath = "/app/agent.jar";
/// <summary>
/// Build-script fragments that mean the produced artifact depends on the architecture of the machine
/// that built it.
/// </summary>
/// <remarks>
/// <c>os-maven-plugin</c> and Gradle's <c>osdetector</c> both publish the detected host as
/// <c>${os.detected.classifier}</c>, which is how netty-tcnative, protobuf and gRPC pick their native
/// artifact; <c>${os.arch}</c> is the same idea spelled with a JVM system property; and GraalVM's
/// native-image plugins compile to a host-architecture executable.
/// See https://github.com/trustin/os-maven-plugin and https://github.com/google/osdetector-gradle-plugin.
/// </remarks>
private static readonly string[] s_hostArchitectureMarkers =
[
"os-maven-plugin",
"os.detected.",
"osdetector",
"os.arch",
"org.graalvm.buildtools",
"native-maven-plugin",
];
// Without this, target/ and build/ — routinely hundreds of megabytes after a local build — are
// uploaded to the daemon and copied into the image by `COPY . .`. Multi-module projects put one next
// to every module, hence the recursive patterns.
// See https://docs.docker.com/build/concepts/context/#filename-and-location.
private const string DefaultJavaBuildContextIgnoreContent = """
# Generated by Aspire. Author <contextRoot>/.dockerignore to take over these rules.
target
**/target
build
**/build
.gradle
**/.gradle
.git
.gitignore
.DS_Store
.env
.env.*
.aspire
aspire-output
Dockerfile
Dockerfile.*
*.Dockerfile
*.Dockerfile.dockerignore
.dockerignore
""";
/// <summary>
/// Determines which build tool the container image uses to produce the deployable JAR, and with which
/// arguments.
/// </summary>
/// <remarks>
/// Exposed separately from <see cref="Write"/> so the resolution rules — including the failure when no
/// build tool can be found — can be exercised without running a publish pipeline.
/// </remarks>
/// <exception cref="DistributedApplicationException">No build tool is configured and none can be found on disk.</exception>
internal static (JavaBuildTool Tool, string[] Args) ResolveBuildTool(JavaAppResource resource, string appDirectory)
=> JavaContainerBuild.ResolveToolAndArgs(resource, appDirectory);
/// <summary>
/// Resolves the full container build, including wrapper selection, without running a publish pipeline.
/// </summary>
/// <remarks>
/// The publish pipeline reports a failure by throwing while reading a Dockerfile that was never
/// written, which hides the message that explains what to fix, so the rejections are asserted here.
/// </remarks>
internal static void ResolveContainerBuildForTesting(JavaAppResource resource, string appDirectory)
=> JavaContainerBuild.Resolve(resource, appDirectory);
public static void Write(JavaAppResource resource, string appDirectory, DockerfileBuilderCallbackContext context)
{
var logger = context.Services.GetService<ILogger<JavaAppResource>>();
// An application added with a prebuilt JAR and no build configuration has nothing to build: the
// artifact already exists in the context, so the image just carries it. Requiring a build tool here
// would make a runnable application unpublishable.
var prebuiltJar = TryGetPrebuiltJarPath(resource, appDirectory, out var jarPath) ? jarPath : null;
// A <dockerfile>.dockerignore replaces the context root's .dockerignore rather than merging with
// it, so an authored one wins outright.
if (context.Resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation)
&& !File.Exists(Path.Combine(appDirectory, ".dockerignore")))
{
dockerfileBuildAnnotation.BuildContextIgnoreContent ??= BuildContextIgnoreContent(resource, prebuiltJar);
}
var build = prebuiltJar is null ? JavaContainerBuild.Resolve(resource, appDirectory) : null;
var javaVersion = JavaVersionDetector.Detect(appDirectory, build?.Tool, build?.Args);
// ctx.Resource is the ContainerResource PublishAsDockerFile substitutes in, but it shares the
// original JavaAppResource's annotation collection, which is why WithDockerfileBaseImage authored
// on the Java resource is visible from here.
context.Resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out var baseImageAnnotation);
// A plain JDK image is always enough because a wrapper is required: the wrapper downloads the exact
// tool version the project pins, so nothing has to come from the image. That also keeps the build
// stage off the maven/gradle images, whose tags only exist for a subset of JDK releases and which
// would otherwise pin a second, unrelated tool version.
var buildImage = baseImageAnnotation?.BuildImage ?? $"docker.io/library/eclipse-temurin:{BuildJdkVersion(javaVersion, build)}-jdk";
var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"docker.io/library/eclipse-temurin:{javaVersion}-jre";
if (build is not null)
{
WriteBuildStage(context, build, buildImage);
}
// Add intermediate FROM stages for any container files sources (e.g. FROM frontend AS frontend_stage).
context.Builder.AddContainerFilesStages(context.Resource, logger);
var runtimeStage = context.Builder.From(runtimeImage);
runtimeStage
.WorkDir("/app")
// Add COPY --from=<source> instructions for each container files source.
.AddContainerFiles(context.Resource, "/app", logger);
// Quarkus's fast JAR is a directory whose parts reference each other by relative path, so the whole
// staged directory is copied into /app and the entry point names the runnable JAR inside it.
var applicationJarPath = build?.ArtifactIsDirectory == true
? $"/app/{JavaHostingExtensions.QuarkusRunJarName}"
: "/app/app.jar";
// Everything under /app has to be readable by the unprivileged runtime user. That is not automatic:
// Quarkus's fast JAR stages its dependencies by copying them out of the Maven/Gradle cache, and the
// cache is a BuildKit cache mount whose files are mode 600 and owned by root. Copying those through
// unchanged produces an image that cannot start, because the JVM cannot read lib/boot:
//
// Error: Could not find or load main class io.quarkus.bootstrap.runner.QuarkusEntryPoint
//
// COPY --chown assigns ownership as the layer is written, so the 600 modes still grant the app user
// access, and it costs no extra layer.
//
// A numeric UID/GID is used rather than a named account created with groupadd/useradd, because the
// runtime image is overridable. Distros disagree on the tools (busybox adduser on Alpine takes
// different switches from shadow's useradd on Debian) and distroless images have neither, so any
// RUN that creates a user only works for the images it was written against. USER accepts a numeric
// id whether or not /etc/passwd names it, which works on every image including distroless.
const string RuntimeUser = "999:999";
if (prebuiltJar is null)
{
runtimeStage.CopyFrom(
"build",
build?.ArtifactIsDirectory == true ? ContainerArtifactDirectory : ContainerArtifactPath,
build?.ArtifactIsDirectory == true ? "/app" : "/app/app.jar",
RuntimeUser);
}
else
{
runtimeStage.Copy(prebuiltJar, "/app/app.jar", RuntimeUser);
}
// A relative agent path names a file the build produced, so it only exists in the build stage.
// Carry it into the runtime image; the matching JAVA_TOOL_OPTIONS value is written by
// WithOtelAgent, which points at ContainerAgentPath in publish mode.
if (TryGetBuildProducedAgentPath(resource, out var agentPath))
{
if (prebuiltJar is null)
{
runtimeStage.CopyFrom("build", $"/app/{agentPath}", ContainerAgentPath, RuntimeUser);
}
else
{
// No build stage exists, so the agent has to already be in the context alongside the JAR.
runtimeStage.Copy(agentPath, ContainerAgentPath, RuntimeUser);
}
}
runtimeStage
.User(RuntimeUser)
// No shell form: with an ENTRYPOINT array the JVM is PID 1 and receives SIGTERM directly, so
// Spring's shutdown hooks run instead of the container being killed after the stop timeout.
.Entrypoint(["java", "-jar", applicationJarPath]);
}
private static void WriteBuildStage(DockerfileBuilderCallbackContext context, JavaContainerBuild build, string buildImage)
{
// A JAR is normally architecture-neutral, so the build runs natively even when the image targets
// another architecture. There is nothing to gain from emulating a portable build, and a great deal
// to lose: cross-building an amd64 image on an arm64 machine runs Maven under QEMU, whose syscall
// translation is incomplete enough that the Maven wrapper cannot even unpack itself.
//
// tar: apache-maven-3.9.9/lib/maven-artifact-3.9.9.jar: Cannot open: Function not implemented
//
// Only the runtime stage inherits the requested platform, which is where it normally matters.
// A project that selects dependencies by the build machine's architecture is the exception: its
// JAR is not portable, so it has to be built on the platform it will run on even though that is
// slower and, when emulated, may not work at all. A broken build is easier to diagnose than an
// image that starts and then fails on the first call into a native library.
// https://docs.docker.com/build/building/multi-platform/#cross-compilation
var fromArguments = build.BuildOnTargetPlatform ? buildImage : $"--platform=$BUILDPLATFORM {buildImage}";
var buildStage = context.Builder
.From(fromArguments, "build")
.WorkDir("/app");
if (build.CacheHomeVariable is { } cacheHomeVariable)
{
// Pinned rather than inherited: the cache mount targets a fixed path, and the official tool
// images point their cache elsewhere (the gradle image defaults GRADLE_USER_HOME to
// /home/gradle/.gradle), which would leave the mount unused and re-download on every build.
buildStage.Env(cacheHomeVariable, build.ToolHome);
}
// Copied ahead of the sources and unpacked in a layer of its own. The build tool is a fixed input
// that only changes when the wrapper does, so this layer survives every source change, while the
// build layer below is invalidated by any file in the context.
buildStage.Copy(build.WrapperPath, $"./{build.WrapperPath}");
buildStage.Copy(build.WrapperSupportPath, $"./{build.WrapperSupportPath}");
if (build.RequiresUnzip)
{
// Both package managers are attempted because the base image is replaceable, and this runs
// before the wrapper so the failure it prevents cannot happen first.
buildStage.Run(
"if ! command -v unzip >/dev/null 2>&1; then " +
"(apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*) " +
"|| apk add --no-cache unzip; fi");
}
buildStage.Run(build.WarmToolCommand);
buildStage.Copy(".", ".");
buildStage.RunWithMounts(
// The artifact is staged outside /app so a build that writes into its own working directory
// cannot move it somewhere COPY --from does not look.
$"mkdir -p /build && {build.BuildCommand} && {build.SelectArtifactCommand}",
// Both tools resolve dependencies from the network on a cold cache. A BuildKit cache mount
// keeps the local repository across builds without baking it into a layer. Only the
// dependency directory is mounted, so the wrapper's copy of the build tool stays on the
// container filesystem where a damaged cache cannot corrupt it.
// Locked because concurrent builds of sibling modules share one repository directory and
// Maven's local repository is not safe for concurrent writers.
// See https://maven.apache.org/guides/mini/guide-multiple-repositories.html
$"type=cache,id={build.CacheId},target={build.CacheTarget},sharing=locked");
}
/// <summary>
/// Gets the JAR to publish directly, for an application that runs a prebuilt JAR and configures no build.
/// </summary>
/// <remarks>
/// A JAR path alone does not mean the artifact is prebuilt: it is also how a Maven or Gradle application
/// names the JAR its own build produces, and those still have to be built in the image. This is therefore
/// limited to the case where nothing at all describes a build — no build step, no launch tool, and no
/// build file in the directory.
/// </remarks>
/// <exception cref="DistributedApplicationException">The JAR is outside the build context.</exception>
internal static bool TryGetPrebuiltJarPath(JavaAppResource resource, string appDirectory, [NotNullWhen(true)] out string? jarPath)
{
jarPath = null;
if (!resource.TryGetLastAnnotation<JavaJarPathAnnotation>(out var annotation)
|| resource.HasAnnotationOfType<JavaBuildStepAnnotation>()
|| resource.HasAnnotationOfType<JavaBuildToolAnnotation>()
|| resource.HasAnnotationOfType<JavaDetectedBuildToolAnnotation>()
|| DetectBuildToolForPublish(resource, appDirectory) is not null)
{
return false;
}
// Container paths are POSIX even when the AppHost authored a Windows-style relative path.
// Only a single leading "./" is stripped; trimming every leading '.' and '/' would turn
// "../outside.jar" into "outside.jar", erasing the traversal before it could be detected and
// silently publishing a COPY of the wrong file.
jarPath = NormalizeContextRelativePath(annotation.JarPath, resource.Name, appDirectory, "its JAR");
return jarPath.Length > 0;
}
/// <summary>
/// The JAR path the AppHost named, when it can be used to select the artifact the build produced.
/// </summary>
/// <remarks>
/// <see cref="TryGetPrebuiltJarPath"/> answers a different question: whether the JAR exists before the
/// image is built. This one is about a JAR the image's own build produces, where the path is still the
/// author's explicit statement of which artifact matters. Without it a project whose build emits more
/// than one JAR — a shade plugin leaves <c>original-*.jar</c> beside the shaded one — fails the glob's
/// "expected exactly one" check even though the AppHost already named the file.
/// <para>
/// A path that reaches outside the build directory is rejected rather than ignored. Falling back to the
/// glob would publish whichever JAR the build happened to emit, which is not the one the AppHost named,
/// and the divergence from run mode would be silent — the image would start and serve the wrong code.
/// </para>
/// <para>
/// Whitespace is allowed, unlike <see cref="NormalizeContextRelativePath"/>. That method feeds a
/// Dockerfile <c>COPY</c>, which splits its arguments on whitespace and has no quoting form. This one
/// feeds a <c>RUN</c> shell command, where <c>SelectNamedJarCommand</c> quotes the path.
/// </para>
/// </remarks>
internal static bool TryGetBuildOutputJarPath(JavaAppResource resource, [NotNullWhen(true)] out string? jarPath)
{
jarPath = null;
if (!resource.TryGetLastAnnotation<JavaJarPathAnnotation>(out var annotation))
{
return false;
}
// Container paths are POSIX even when the AppHost authored a Windows-style relative path.
var normalized = annotation.JarPath.Replace('\\', '/');
if (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
if (normalized.Length == 0)
{
return false;
}
if (IsPathRootedOnAnyPlatform(annotation.JarPath) || normalized.Split('/').Contains(".."))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its jarPath " +
$"'{annotation.JarPath}' is outside the directory the build runs in. The path is resolved " +
"against the application directory inside the container, so it has to name a file the " +
"build produces there. Pass a jarPath relative to the application directory, or use " +
"WithJarArtifact to name the published artifact separately from the one run locally.");
}
jarPath = normalized;
return true;
}
/// <summary>
/// Normalizes an authored path for use inside the container build, rejecting anything that would
/// reach outside the build context.
/// </summary>
/// <remarks>
/// The build context is the application directory, so only files under it are uploaded to the daemon.
/// A rooted path or one containing a <c>..</c> segment names something that is not in the image, and
/// emitting it anyway fails the build with a path the author never wrote, or silently selects a
/// different in-context file.
/// <para>
/// Only a single leading <c>./</c> is stripped. Trimming <c>.</c> and <c>/</c> as a character set would
/// turn <c>../outside.jar</c> into <c>outside.jar</c>, erasing the traversal before it could be
/// detected.
/// </para>
/// <para>
/// Whitespace is rejected for the same reason a wrapper path is: a Dockerfile COPY separates its arguments on whitespace and the builder emits the
/// shell form, so <c>target/my app.jar</c> becomes three arguments and copies two paths that do not
/// exist. Naming the problem here beats failing inside the container build on a path nobody wrote.
/// </para>
/// </remarks>
/// <exception cref="DistributedApplicationException">The path reaches outside the build context or contains whitespace.</exception>
/// <summary>
/// Whether the authored path is absolute under either platform's rules.
/// </summary>
/// <remarks>
/// <see cref="Path.IsPathRooted(string)"/> applies only the rules of the host it runs on, so an AppHost
/// authored on Windows with <c>C:\artifacts\app.jar</c> looks relative when that same AppHost is
/// published from Linux CI. Publishing then rewrites the backslashes for the container and accepts the
/// result as if it named a file the build produced, so the image is built against a path that cannot
/// exist instead of the author being told the path is outside the build context. Publishing has to
/// reach the same verdict wherever it runs, so both forms are rejected on both platforms.
/// <para>
/// A leading backslash covers Windows root-relative and UNC paths, and a drive qualifier is matched
/// with or without a following separator because <c>C:app.jar</c> is drive-relative rather than
/// context-relative. The drive test would also match a Unix directory named with a single letter and a
/// colon, which is not a name any build tool produces.
/// </para>
/// </remarks>
internal static bool IsPathRootedOnAnyPlatform(string path)
=> Path.IsPathRooted(path)
|| path.StartsWith('/')
|| path.StartsWith('\\')
|| IsWindowsRooted(path);
/// <remarks>
/// Detected without <see cref="Path.IsPathRooted(string)"/> so the answer is the same on every
/// platform: a Windows AppHost publishing to a Linux image has to fail the same way a Linux one
/// does, rather than only when the build happens to run on Windows.
/// </remarks>
private static bool IsWindowsRooted(string path)
=> path.StartsWith('\\')
|| (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':');
private static string NormalizeContextRelativePath(string authored, string resourceName, string appDirectory, string description)
{
// Container paths are POSIX even when the AppHost authored a Windows-style relative path.
var normalized = authored.Replace('\\', '/');
if (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
if (IsPathRootedOnAnyPlatform(authored) || normalized.Split('/').Contains(".."))
{
throw new DistributedApplicationException(
$"Java application '{resourceName}' cannot be published because {description} '{authored}' " +
$"is outside the build context '{appDirectory}'. Only files under the application " +
"directory are uploaded to the container build.");
}
if (normalized.Any(char.IsWhiteSpace))
{
throw new DistributedApplicationException(
$"Java application '{resourceName}' cannot be published because {description} '{authored}' " +
"contains whitespace, which a Dockerfile COPY instruction cannot express. Move it to a " +
"path without spaces.");
}
return normalized;
}
private static JavaBuildTool? DetectBuildToolForPublish(JavaAppResource resource, string appDirectory)
=> JavaBuildToolResolver.Detect(
appDirectory,
resource.Name,
static message => new DistributedApplicationException(message));
/// <summary>
/// The JDK the build stage runs on, which is not necessarily the JDK the application targets.
/// </summary>
/// <remarks>
/// The build tool itself needs a JDK new enough to run it, independently of the bytecode the project
/// produces: Gradle 9 and Maven 4 refuse to start on anything below Java 17. A project targeting Java 8
/// or 11 would otherwise get an <c>eclipse-temurin:8-jdk</c> build stage where the wrapper dies with
/// "Unsupported class file major version" before compiling anything.
/// <para>
/// The requirement is read from the version the wrapper pins rather than applied to every build,
/// because it also runs the other way: Gradle releases before 7.3 cannot run <em>on</em> Java 17, and
/// those are exactly the wrappers an old Java 8 project tends to carry. A build whose tool version
/// cannot be determined keeps the targeted version.
/// </para>
/// <para>
/// Compiling for the older target still works, because that is what <c>--release</c> and
/// <c>maven.compiler.release</c> are for, and JDK 17's javac still supports targets back to 7. Only the
/// build stage is raised; the runtime stage stays on the targeted version so the image is no larger and
/// no newer than the application actually needs.
/// </para>
/// <para>
/// The ceiling is applied the same way, in the other direction. Capping at the newest JDK the tool
/// can run on is never worse than ignoring it: a project whose build actually resolves the target
/// another way — a Gradle toolchain the build downloads or already has — now builds where it
/// previously died on startup with "Unsupported class file major version", and one that genuinely
/// needs the newer javac fails with "release version N not supported", which names the real problem.
/// </para>
/// </remarks>
private static string BuildJdkVersion(string targetVersion, JavaContainerBuild? build)
{
if (build is null || !int.TryParse(targetVersion, CultureInfo.InvariantCulture, out var target))
{
return targetVersion;
}
if (target < build.MinimumBuildJdk)
{
return build.MinimumBuildJdk.ToString(CultureInfo.InvariantCulture);
}
return build.MaximumBuildJdk > 0 && target > build.MaximumBuildJdk
? build.MaximumBuildJdk.ToString(CultureInfo.InvariantCulture)
: targetVersion;
}
/// <summary>
/// The default <c>.dockerignore</c> content, with exceptions for files the image needs from the context.
/// </summary>
/// <remarks>
/// The defaults exclude <c>target</c> and <c>build</c> because they are routinely hundreds of megabytes
/// of build output. A prebuilt JAR normally sits in exactly those directories, so publishing it needs an
/// exception; without one the COPY fails with "file not found in build context" even though the file is
/// plainly there. Exceptions have to follow the exclusion they re-include.
/// See https://docs.docker.com/build/concepts/context/#dockerignore-files.
/// </remarks>
private static string BuildContextIgnoreContent(JavaAppResource resource, string? prebuiltJarPath)
{
if (prebuiltJarPath is null)
{
return DefaultJavaBuildContextIgnoreContent;
}
var exceptions = new List<string> { prebuiltJarPath };
if (TryGetBuildProducedAgentPath(resource, out var agentPath))
{
exceptions.Add(agentPath);
}
// Each parent directory has to be re-included too: Docker does not descend into a directory it has
// already excluded, so "!target/app.jar" alone never matches when "target" itself is excluded.
var reincluded = exceptions
.SelectMany(ParentPathsAndSelf)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.Select(path => $"!{path}");
return DefaultJavaBuildContextIgnoreContent + string.Join('\n', reincluded) + "\n";
}
private static IEnumerable<string> ParentPathsAndSelf(string path)
{
var segments = path.Split('/');
for (var i = 1; i <= segments.Length; i++)
{
yield return string.Join('/', segments.Take(i));
}
}
/// <summary>
/// Gets the OpenTelemetry agent path when it names a file the build produces inside the context.
/// </summary>
/// <remarks>
/// An absolute path is left alone. It cannot have come out of the build context, so it has to be
/// supplied by the base image or a mount, and rewriting it would break that arrangement.
/// </remarks>
internal static bool TryGetBuildProducedAgentPath(JavaAppResource resource, [NotNullWhen(true)] out string? agentPath)
{
agentPath = null;
if (!resource.TryGetLastAnnotation<JavaOtelAgentAnnotation>(out var annotation))
{
return false;
}
var authored = JavaHostingExtensions.ResolveOtelAgentPath(resource, annotation);
if (IsPathRootedOnAnyPlatform(authored))
{
// A POSIX absolute path is a legitimate arrangement: the base image or a mount provides the
// agent, and rewriting it would break that. A Windows-rooted path cannot be, because the
// image the AppHost publishes to is Linux. Leaving it alone puts "-javaagent:C:\..." into
// JAVA_TOOL_OPTIONS, and the JVM then dies during VM initialization with an error that
// names the agent but not the reason. The jar artifact and the wrapper already reject
// Windows-rooted paths on every platform; this keeps the agent consistent with them.
if (IsWindowsRooted(authored))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because the OpenTelemetry agent " +
$"path '{authored}' is a Windows path, which cannot resolve inside the Linux image the " +
"application is published to. Use a path inside the application directory so it is copied " +
"into the image, or an absolute path the base image or a mount provides at runtime.");
}
return false;
}
// Container paths are POSIX even when the AppHost authored a Windows-style relative path.
var normalized = authored.Replace('\\', '/');
// Strip a single leading "./" only. Trimming the '.' and '/' characters as a set would turn
// "../agents/otel.jar" into "agents/otel.jar" and emit a COPY for a path that was never in the
// build context, failing the container build with a path the author never wrote.
if (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
// The Docker build context is the application directory, so a path that walks above it cannot be
// copied forward no matter how it is spelled. Say so instead of silently rewriting it.
if (normalized.Split('/').Any(segment => segment == ".."))
{
throw new DistributedApplicationException(
$"The OpenTelemetry agent path '{authored}' configured on resource '{resource.Name}' " +
$"points outside the application directory, which is the Docker build context, so it cannot be " +
$"published. Use a path inside '{resource.WorkingDirectory}', or an absolute path that the base " +
$"image or a mount provides at runtime.");
}
// The Dockerfile builder emits the shell form of COPY, whose arguments are separated by
// whitespace with no quoted form, so "target/otel agents/javaagent.jar" would copy two paths that
// do not exist. Naming the problem beats failing later inside the container build.
if (normalized.Any(char.IsWhiteSpace))
{
throw new DistributedApplicationException(
$"The OpenTelemetry agent path '{authored}' configured on resource '{resource.Name}' " +
$"contains whitespace, which a Dockerfile COPY instruction cannot express, so it cannot be " +
$"published. Move the agent to a path without spaces.");
}
agentPath = normalized;
return agentPath.Length > 0;
}
/// <summary>
/// The build-tool-specific pieces of the container build.
/// </summary>
/// <param name="Tool">The build tool that produces the JAR.</param>
/// <param name="Args">The resolved arguments passed to the build tool.</param>
/// <param name="BuildCommand">The shell command that runs the build.</param>
/// <param name="SelectArtifactCommand">The shell command that copies the produced JAR to a fixed path.</param>
/// <param name="ToolHome">The container path the build tool treats as its home directory.</param>
/// <param name="CacheTarget">The container path holding the tool's dependency cache, below <paramref name="ToolHome"/>.</param>
/// <param name="CacheHomeVariable">The environment variable that pins <paramref name="ToolHome"/>, if the tool has one.</param>
/// <param name="CacheId">The BuildKit cache identity, scoped per tool and resource.</param>
/// <param name="WrapperPath">The wrapper script, relative to the build context.</param>
/// <param name="WrapperSupportPath">The wrapper's support directory, relative to the build context.</param>
/// <param name="WarmToolCommand">The shell command that makes the wrapper download and unpack the build tool.</param>
/// <param name="RequiresUnzip">Whether the build image needs <c>unzip</c> installed before the wrapper runs.</param>
/// <param name="ArtifactIsDirectory">Whether the staged artifact is a directory rather than a single JAR.</param>
/// <param name="MinimumBuildJdk">The JDK release the pinned build tool needs to start, or 0 when unknown.</param>
/// <param name="MaximumBuildJdk">The newest JDK release the pinned build tool can run on, or 0 when unknown.</param>
/// <param name="BuildOnTargetPlatform">Whether the build produces an architecture-specific artifact and so cannot run on the build machine's platform.</param>
/// <remarks>
/// Internal rather than private so tests can exercise the whole resolution directly. Failures raised
/// during publishing surface as a missing Dockerfile once the pipeline has swallowed them, which hides
/// the message being asserted.
/// </remarks>
internal sealed record JavaContainerBuild(
JavaBuildTool Tool,
string[] Args,
string BuildCommand,
string SelectArtifactCommand,
string ToolHome,
string CacheTarget,
string? CacheHomeVariable,
string CacheId,
string WrapperPath,
string WrapperSupportPath,
string WarmToolCommand,
bool RequiresUnzip,
bool ArtifactIsDirectory,
int MinimumBuildJdk,
int MaximumBuildJdk,
bool BuildOnTargetPlatform)
{
public static JavaContainerBuild Resolve(JavaAppResource resource, string appDirectory)
{
var (tool, buildArgs) = ResolveToolAndArgs(resource, appDirectory);
var wrapper = ResolveWrapperForContext(resource, appDirectory, tool);
// A wrapper checked out from a Windows clone can arrive without the executable bit, and Git
// does not record one on Windows at all. Invoking the interpreter directly sidesteps that
// rather than failing with "permission denied" deep inside the container build.
//
// Every argument is quoted because these values reach a container build as a shell command.
// A version pinned with -Dspring.profiles.active='a b' or any value containing $ or ; would
// otherwise be re-split or expanded by the shell, so the image would build differently from
// the identical arguments used on the host, where they are passed as separate argv entries.
// The wrapper path is quoted for the same reason: WithWrapperPath accepts any path, and an
// unquoted one containing a shell metacharacter would invoke something other than the wrapper.
var quotedWrapper = ShellQuoteIfNeeded($"./{wrapper}");
// A Windows checkout without a .gitattributes rule for mvnw/gradlew leaves CRLF line endings in
// them. Both are POSIX scripts built around `case` statements, and `sh` rejects those with
// "Syntax error: word unexpected (expecting \"in\")" - a message that says nothing about line
// endings and appears halfway through a container build. Stripping the carriage returns in the
// image is idempotent, costs nothing when they are already absent, and leaves the developer's
// working tree untouched.
var invocation = $"sed -i 's/\\r$//' {quotedWrapper} && sh {quotedWrapper}";
var buildCommand = $"{invocation} {string.Join(' ', buildArgs.Select(ShellQuoteIfNeeded))}";
var (outputGlob, toolHome, cacheSubdirectory, cacheHomeVariable, supportDirectoryName, warmArgs) = tool switch
{
// Maven resolves its local repository from the home directory and offers no variable that
// relocates it, so the mount targets root's default and the build stage runs as root.
JavaBuildTool.Maven => ("target/*.jar", "/root/.m2", "repository", (string?)null, ".mvn", "-B -ntp --version"),
JavaBuildTool.Gradle => ("build/libs/*.jar", "/root/.gradle", "caches", "GRADLE_USER_HOME", "gradle", "--no-daemon --version"),
_ => throw new UnreachableException()
};
var wrapperSupportPath = ResolveWrapperSupportPath(resource, appDirectory, tool, wrapper, supportDirectoryName);
var isQuarkus = resource.HasAnnotationOfType<JavaQuarkusAnnotation>();
var outputDirectory = tool is JavaBuildTool.Gradle ? "build" : "target";
var requiresUnzip = tool is JavaBuildTool.Maven
&& MavenWrapperPinsADistributionChecksum(appDirectory, wrapperSupportPath);
var selectArtifact = resource.TryGetLastAnnotation<JavaJarArtifactAnnotation>(out var artifact)
? $"cp {ShellQuote(NormalizeContextRelativePath(artifact.RelativePath, resource.Name, appDirectory, "its JAR artifact"))} {ContainerArtifactPath}"
: isQuarkus
// Quarkus is decided before the JAR path because its fast-jar layout needs the whole
// target/quarkus-app directory; copying only the runner it names produces an image
// that starts and immediately dies on a missing lib directory.
? SelectQuarkusArtifactCommand(outputDirectory, outputGlob, resource.Name)
: TryGetBuildOutputJarPath(resource, out var namedJar)
? SelectNamedJarCommand(namedJar, ContainerArtifactPath, resource.Name)
: SelectSingleJarCommand(outputGlob, ContainerArtifactPath, resource.Name);
// An explicit WithJarArtifact names a single file, so it stages as one even for Quarkus - which
// is how an application packaged as an uber JAR names its runner.
var artifactIsDirectory = isQuarkus && !resource.HasAnnotationOfType<JavaJarArtifactAnnotation>();
var bounds = ResolveBuildJdkBounds(appDirectory, wrapperSupportPath, tool);
return new JavaContainerBuild(
tool,
buildArgs,
buildCommand,
selectArtifact,
toolHome,
// Only the dependency cache is mounted, never the whole tool home. The wrapper downloads
// and extracts the build tool itself into <tool home>/wrapper/dists, and a distribution
// left half-extracted there by an interrupted build would be reused by every later build,
// which fails while untarring over it and cannot be recovered without knowing to run
// `docker builder prune --filter type=exec.cachemount`. Keeping the distribution on the
// container filesystem means a damaged cache can only cost a re-download of dependencies.
CacheTarget: $"{toolHome}/{cacheSubdirectory}",
cacheHomeVariable,
// Scoped per resource so two Java applications built concurrently do not contend on one
// locked mount, and per tool because the two caches have different layouts.
CacheId: $"aspire-java-{tool.ToString().ToLowerInvariant()}-{resource.Name.ToLowerInvariant()}",
WrapperPath: wrapper,
WrapperSupportPath: wrapperSupportPath,
WarmToolCommand: $"{invocation} {warmArgs}",
RequiresUnzip: requiresUnzip,
ArtifactIsDirectory: artifactIsDirectory,
MinimumBuildJdk: bounds.Minimum,
MaximumBuildJdk: bounds.Maximum,
BuildOnTargetPlatform: SelectsDependenciesByBuildArchitecture(appDirectory, tool));
}
/// <summary>
/// Whether the project picks dependencies or produces artifacts based on the architecture of the
/// machine running the build, which makes its output architecture-specific.
/// </summary>
/// <remarks>
/// The usual JAR is bytecode and runs anywhere, but a project can package a native library chosen
/// from the build machine: <c>os-maven-plugin</c> and Gradle's <c>osdetector</c> expose the host
/// as <c>${os.detected.classifier}</c>, which projects using netty-tcnative, protobuf or gRPC pass
/// as a dependency classifier, and GraalVM's native-image plugins emit a host-architecture
/// executable outright. Building such a project on the build machine and shipping the result in an
/// image for another architecture produces an image that starts and then fails on the first call
/// into the native code, so those builds run on the platform they target instead.
/// <para>
/// This reads the build script as text rather than as XML or Groovy/Kotlin, because a match
/// anywhere - including in a comment or a profile that is not active - only costs a slower build,
/// while a miss costs a broken image. A build file that cannot be read is treated as portable,
/// which is the behaviour for every project that does none of this.
/// </para>
/// </remarks>
private static bool SelectsDependenciesByBuildArchitecture(string appDirectory, JavaBuildTool tool)
{
string[] fileNames = tool is JavaBuildTool.Gradle
? ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
: ["pom.xml"];
foreach (var fileName in fileNames)
{
string content;
try
{
var path = Path.Combine(appDirectory, fileName);
if (!File.Exists(path))
{
continue;
}
content = File.ReadAllText(path);
}
catch (IOException)
{
continue;
}
foreach (var marker in s_hostArchitectureMarkers)
{
if (content.Contains(marker, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
/// <summary>
/// Reads the range of JDK releases the pinned build tool can run on.
/// </summary>
/// <remarks>
/// The build tool's own JVM requirement is independent of the bytecode the project produces, and it
/// bounds the build stage from both directions.
/// <para>
/// From below: Gradle 9 and Maven 4 refuse to start on anything under Java 17, so a project
/// targeting Java 8 cannot build on an <c>eclipse-temurin:8-jdk</c> stage. The requirement is read
/// from the pinned version rather than applied to every build, because Gradle 6 refuses to run
/// <em>on</em> Java 17 and those are exactly the wrappers an old Java 8 project tends to carry.
/// </para>
/// <para>
/// From above: each Gradle release only runs on the JDKs that existed when it shipped. Gradle 8.4
/// can target Java 21 through a toolchain but cannot itself run on Java 21 — that starts at 8.5 —
/// so a Java 21 project with an 8.4 wrapper would get a Java 21 build stage where Gradle dies on
/// startup. The bound follows the "Support for running Gradle" column of Gradle's compatibility
/// matrix. Maven has no equivalent ceiling, so none is modelled for it.
/// </para>
/// <para>
/// The version comes from the distribution the wrapper pins, for example:
/// <code>
/// distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
/// distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
/// </code>
/// Note the escaped <c>\:</c> that the Gradle wrapper writes, and that the Maven URL carries the
/// version twice. Gradle's ceiling moves on minor releases, so major and minor are both read.
/// </para>
/// A URL that cannot be parsed contributes no bound at all, which leaves the target version in
/// charge — the behaviour before any of this existed.
/// See https://docs.gradle.org/current/userguide/compatibility.html and
/// https://maven.apache.org/docs/history.html.
/// </remarks>
private static (int Minimum, int Maximum) ResolveBuildJdkBounds(string appDirectory, string wrapperSupportPath, JavaBuildTool tool)
{
var propertiesPath = Path.Combine(
appDirectory,
wrapperSupportPath.Replace('/', Path.DirectorySeparatorChar),
"wrapper",
$"{tool.ToString().ToLowerInvariant()}-wrapper.properties");
string? distributionUrl = null;
try
{
foreach (var line in File.ReadLines(propertiesPath))
{
var trimmed = line.AsSpan().TrimStart();
if (trimmed.StartsWith("distributionUrl", StringComparison.Ordinal))
{
var separator = trimmed.IndexOf('=');
if (separator >= 0)
{
distributionUrl = trimmed[(separator + 1)..].Trim().ToString();
}
break;
}
}
}
catch (IOException)
{
return (0, 0);
}
if (distributionUrl is null)
{
return (0, 0);
}
var match = (tool is JavaBuildTool.Gradle ? GradleDistributionRegex() : MavenDistributionRegex()).Match(distributionUrl);
if (!match.Success || !int.TryParse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture, out var major))
{
return (0, 0);
}
if (tool is JavaBuildTool.Maven)
{
return (major >= 4 ? 17 : 0, 0);
}
// A missing minor reads as 0, which lands on the first ceiling of that major - the conservative
// direction, because a wrapper is never pinned to a bare major version in practice.
_ = int.TryParse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture, out var minor);
return (major >= 9 ? 17 : 0, MaximumGradleRuntimeJdk(major, minor));
}
/// <summary>
/// The newest JDK release a given Gradle version can run on.
/// </summary>
/// <remarks>
/// Inverted from the "Support for running Gradle" column of Gradle's compatibility matrix
/// (https://docs.gradle.org/current/userguide/compatibility.html):
/// Java 20 needs 8.3+, 21 needs 8.5+, 22 needs 8.8+, 23 needs 8.10+, 24 needs 8.14+, 25 needs
/// 9.1+, 26 needs 9.4+, 27 needs 9.8+.
/// <para>
/// Versions newer than the last row are given that row's ceiling rather than "unbounded", so a
/// Gradle release this table has not caught up with never blocks a publish: the check that
/// consumes this only fires when the target exceeds the ceiling, and an unknown-but-newer Gradle
/// always supports at least what the last known release did. Anything older than 7.3 is left
/// unbounded because those releases predate the JDKs this can select.
/// </para>
/// </remarks>
private static int MaximumGradleRuntimeJdk(int major, int minor) => (major, minor) switch
{
(>= 10, _) => 27,
(9, >= 8) => 27,
(9, >= 4) => 26,
(9, >= 1) => 25,
(9, _) => 24,
(8, >= 14) => 24,
(8, >= 10) => 23,
(8, >= 8) => 22,
(8, >= 5) => 21,
(8, >= 3) => 20,
(8, _) => 19,
(7, >= 6) => 19,
(7, >= 5) => 18,
(7, >= 3) => 17,
_ => 0
};
/// <summary>
/// Determines whether the Maven wrapper pins a checksum for the distribution it downloads.
/// </summary>
/// <remarks>
/// <c>distributionSha256Sum</c> is the checksum of the <c>-bin.zip</c> named by
/// <c>distributionUrl</c>, but <c>mvnw</c> silently switches to the <c>-bin.tar.gz</c> of the same
/// release when <c>unzip</c> is not on the path, then compares that archive against the ZIP's
/// checksum and stops the build:
/// <code>
/// Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised.
/// </code>
/// The Quarkus project generator pins this checksum by default, and the Temurin images have no
/// <c>unzip</c>, so without this the build fails for every Quarkus application.
/// </remarks>
private static bool MavenWrapperPinsADistributionChecksum(string appDirectory, string wrapperSupportPath)
{
var propertiesPath = Path.Combine(
appDirectory,
wrapperSupportPath.Replace('/', Path.DirectorySeparatorChar),
"wrapper",
"maven-wrapper.properties");
// The file's presence is already validated by ResolveWrapperSupportPath, so an unreadable file
// here can only be a race with an editor. Treating that as "no checksum" keeps generation
// working and, at worst, produces the same Dockerfile as before this check existed.
try
{
foreach (var line in File.ReadLines(propertiesPath))
{
if (line.AsSpan().TrimStart().StartsWith("distributionSha256Sum", StringComparison.Ordinal))
{
return true;
}
}
}
catch (IOException)
{
return false;
}
return false;
}
/// <summary>
/// Resolves the wrapper's support directory, which holds the properties file naming the build tool
/// distribution to download.
/// </summary>
/// <remarks>
/// This directory is copied into the image ahead of the application sources so the wrapper can unpack
/// the build tool in a layer of its own. Without that, the tool would be downloaded again on every
/// source change, because the single build layer is invalidated by any file in the context.
/// </remarks>
/// <exception cref="DistributedApplicationException">The properties file is missing.</exception>
private static string ResolveWrapperSupportPath(
JavaAppResource resource,
string appDirectory,
JavaBuildTool tool,
string wrapper,
string supportDirectoryName)
{
// The support directory sits next to the wrapper script, so a wrapper in a subdirectory of the
// context keeps its own .mvn/gradle directory there rather than at the context root.
var wrapperDirectory = Path.GetDirectoryName(wrapper.AsSpan());
var supportPath = wrapperDirectory.IsEmpty
? supportDirectoryName
: $"{wrapperDirectory}/{supportDirectoryName}";
// Both wrappers store the distribution URL in <support>/wrapper/<tool>-wrapper.properties.
// https://maven.apache.org/wrapper/ and https://docs.gradle.org/current/userguide/gradle_wrapper.html
var propertiesName = $"{tool.ToString().ToLowerInvariant()}-wrapper.properties";
var propertiesPath = Path.Combine(
appDirectory,
supportPath.Replace('/', Path.DirectorySeparatorChar),
"wrapper",
propertiesName);
if (!File.Exists(propertiesPath))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its {wrapper} has no " +
$"'{supportPath}/wrapper/{propertiesName}'. That file pins the build tool version the " +
$"image is built with. Regenerate the wrapper with " +
$"{JavaHostingExtensions.GenerateWrapperCommand(tool)} and commit the whole " +
$"'{supportPath}' directory.");
}
return supportPath;
}
/// <summary>
/// Resolves the wrapper script as a path relative to the build context.
/// </summary>
/// <remarks>
/// A wrapper is required rather than falling back to a <c>mvn</c>/<c>gradle</c> installed in the
/// build image: the wrapper pins the tool version in the repository, so the container image is
/// produced by the same version that built the project locally and in CI.
/// <para>
/// The wrapper also has to sit inside the build context, because only files under the context are
/// uploaded to the daemon and reachable by <c>COPY . .</c>. A wrapper outside it exists on the host
/// and not in the image, so the build would fail partway through with an opaque "not found".
/// </para>
/// </remarks>
/// <exception cref="DistributedApplicationException">No wrapper is present, or the configured wrapper is outside the build context.</exception>
private static string ResolveWrapperForContext(JavaAppResource resource, string appDirectory, JavaBuildTool tool)
{
// Container builds execute on Linux even when publish runs on Windows. Passing that platform
// to the shared resolver keeps the naming rule identical to run mode without selecting a batch
// script that the build stage cannot execute.
var resolvedWrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, tool, isWindows: false);
var isConfigured = resource.HasAnnotationOfType<WrapperAnnotation>();
var relative = Path.GetRelativePath(appDirectory, resolvedWrapperPath).Replace('\\', '/');
if (relative.StartsWith("../", StringComparison.Ordinal) || IsPathRootedOnAnyPlatform(relative))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its wrapper " +
$"'{resolvedWrapperPath}' is outside the build context '{appDirectory}'. " +
"Move the wrapper into the application directory, or set the build context to a " +
"directory that contains both.");
}
if (!File.Exists(resolvedWrapperPath))
{
if (isConfigured)
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because the wrapper " +
$"configured with WithWrapperPath was not found at '{resolvedWrapperPath}'.");
}
var defaultWrapperName = JavaBuildToolResolver.GetDefaultWrapperName(tool, isWindows: false);
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because there is no " +
$"{defaultWrapperName} in '{appDirectory}'. Aspire builds the image with the project's " +
$"own wrapper so the container uses the tool version the repository pins. Generate one " +
$"with {JavaHostingExtensions.GenerateWrapperCommand(tool)}, or point at an existing " +
"wrapper with WithWrapperPath.");
}
// The build stage is Linux, so a Windows batch wrapper cannot run there even though it is
// the right choice on the developer's machine. Maven and Gradle ship the POSIX script
// alongside the batch one under the same base name, so prefer that sibling and only fail
// when it is genuinely absent.
// https://maven.apache.org/wrapper/ and https://docs.gradle.org/current/userguide/gradle_wrapper.html
if (Path.GetExtension(relative) is ".cmd" or ".bat")
{
var posixSibling = relative[..^Path.GetExtension(relative).Length];
if (!File.Exists(Path.Combine(appDirectory, posixSibling.Replace('/', Path.DirectorySeparatorChar))))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its wrapper " +
$"'{relative}' is a Windows batch script and the container build stage is Linux. " +
$"No '{posixSibling}' was found next to it. Generate the wrapper with " +
$"{JavaHostingExtensions.GenerateWrapperCommand(tool)} so both scripts are present.");
}
relative = posixSibling;
}
// A Dockerfile COPY takes its arguments separated by whitespace and has no quoted form here,
// so a wrapper path containing whitespace would copy two nonexistent paths instead of one real
// one. Rejecting it names the problem, rather than failing later inside the build with "no such
// file or directory" for a path the author never wrote.
if (relative.Any(char.IsWhiteSpace))
{
throw new DistributedApplicationException(
$"Java application '{resource.Name}' cannot be published because its wrapper path " +
$"'{relative}' contains whitespace, which a Dockerfile COPY instruction cannot " +
"express. Move the wrapper to a path without spaces.");
}
return relative;
}
internal static (JavaBuildTool Tool, string[] Args) ResolveToolAndArgs(JavaAppResource resource, string appDirectory)
{
var (tool, args) = ResolveConfiguredToolAndArgs(resource, appDirectory);
return (tool, WithoutGradleDaemon(tool, args));
}
/// <summary>
/// Adds <c>--no-daemon</c> to a Gradle invocation that does not already carry it.
/// </summary>
/// <remarks>
/// The Gradle daemon outlives the <c>RUN</c> instruction's shell and is then killed with the
/// layer, so inside a container build it only adds startup cost and holds memory the build could
/// use. Run mode is deliberately left alone: there the daemon survives between builds and is what
/// makes an incremental rebuild fast.
/// <para>
/// Applied here rather than in each argument list so it also covers arguments the author supplied
/// through <c>WithGradleBuild</c> and the ones the Spring Boot and Quarkus defaults contribute.
/// </para>
/// </remarks>
private static string[] WithoutGradleDaemon(JavaBuildTool tool, string[] args)
{
if (tool is not JavaBuildTool.Gradle
|| args.Contains("--no-daemon", StringComparer.Ordinal)
// An author who asked for the daemon is not overridden; Gradle takes the last flag to win,
// so appending --no-daemon would silently reverse an explicit choice.
|| args.Contains("--daemon", StringComparer.Ordinal))
{
return args;
}
return ["--no-daemon", .. args];
}
private static (JavaBuildTool Tool, string[] Args) ResolveConfiguredToolAndArgs(JavaAppResource resource, string appDirectory)
{
// A build step configured with WithMavenBuild/WithGradleBuild states both the tool and the
// arguments that produce a deployable artifact, so it is the most precise source.
if (resource.TryGetLastAnnotation<JavaBuildStepAnnotation>(out var buildStep))
{
return (buildStep.Tool, buildStep.Args);
}
if (resource.TryGetLastAnnotation<JavaDetectedBuildToolAnnotation>(out var detected))
{
var tool = resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var launch)
? launch.Tool
: DetectBuildToolForPublish(resource, appDirectory)
?? throw new DistributedApplicationException(
$"The Java application '{resource.Name}' cannot be published because no build tool was found. " +
$"Add a pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts to '{appDirectory}'.");
return (tool, detected.GetConfiguration(tool).BuildArgs);
}
// A launch goal such as spring-boot:run or bootRun identifies the tool but never packages, so
// only the tool is taken from it and the packaging arguments are defaulted.
if (resource.TryGetLastAnnotation<JavaBuildToolAnnotation>(out var buildTool))
{
return (buildTool.Tool, DefaultPackageArgs(buildTool.Tool));
}
// Left for an application added with a prebuilt JAR path and no build configuration: the
// container still has to produce that JAR, so the tool comes from what is on disk. This uses
// the same detector as run mode so publish cannot silently choose Maven for an ambiguous
// directory that run mode rejects.
if (DetectBuildToolForPublish(resource, appDirectory) is { } detectedTool)
{
return (detectedTool, DefaultPackageArgs(detectedTool));
}
throw new DistributedApplicationException(
$"The Java application '{resource.Name}' cannot be published because no build tool was found. " +
$"Add a pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts to '{appDirectory}', " +
"or call WithMavenBuild or WithGradleBuild " +
"to state how the deployable JAR is produced.");
}
private static string[] DefaultPackageArgs(JavaBuildTool tool) => tool switch
{
// -B disables the ANSI progress output that renders as noise in a build log, and -ntp drops
// the per-artifact download lines. Tests are skipped because the container build produces a
// deployable artifact; running the test suite belongs to CI, not to `aspire publish`.
JavaBuildTool.Maven => ["-B", "-ntp", "-DskipTests", "package"],
JavaBuildTool.Gradle => ["-x", "test", "build"],
_ => throw new UnreachableException()
};
/// <summary>
/// Emits a shell command that resolves <paramref name="outputGlob"/> to exactly one JAR and copies
/// it to a fixed path.
/// </summary>
/// <remarks>
/// The artifact name is only known after the build, and both tools emit more than one JAR in the
/// common case: the Spring Boot plugin writes the executable <c>app-0.0.1-SNAPSHOT.jar</c> next to
/// the base plugin's <c>app-0.0.1-SNAPSHOT-plain.jar</c>, and <c>-sources</c>/<c>-javadoc</c>
/// artifacts appear as soon as those plugins are enabled. Those suffixes are filtered out, and
/// anything still ambiguous fails the build with an actionable message rather than picking one
/// arbitrarily and producing an image that starts and immediately exits with "no main manifest
/// attribute".
/// </remarks>
/// <summary>
/// Emits a shell command that stages a Quarkus build's output as a directory containing
/// <c>quarkus-run.jar</c>, whichever packaging type the application uses.
/// </summary>
/// <remarks>
/// Quarkus's default "fast JAR" packaging writes <c>quarkus-app/</c>, whose <c>quarkus-run.jar</c> is
/// unusable without the <c>lib</c>, <c>app</c>, and <c>quarkus</c> directories beside it — its manifest
/// <c>Class-Path</c> names them relatively. <c>legacy-jar</c> writes a <c>*-runner.jar</c> at the top of
/// the output directory whose <c>Class-Path</c> names a sibling <c>lib/</c>, and <c>uber-jar</c> writes a
/// single self-contained <c>*-runner.jar</c> with no dependency directory at all. The packaging type is
/// chosen in application configuration, which is not something the AppHost can read, so the choice is
/// made in the build stage where the output already exists and all three are normalised to the same
/// shape: a directory holding <c>quarkus-run.jar</c> and whatever it needs beside it.
/// <para>
/// Which file to run is read from <c>quarkus-artifact.properties</c>, which every packaging type writes
/// next to its output and which names the runnable artifact relative to the output directory
/// (<c>path=quarkus-app/quarkus-run.jar</c> or <c>path=app-runner.jar</c>). Globbing cannot substitute
/// for it: <c>legacy-jar</c> leaves the base plugin's thin JAR beside the runner, so two files match and
/// neither carries a suffix that distinguishes them. The glob fallback is kept only for a Quarkus old
/// enough not to write the file.
/// </para>
/// See https://quarkus.io/guides/maven-tooling#quarkus-package-jar_quarkus.package.jar.type.
/// </remarks>
private static string SelectQuarkusArtifactCommand(string outputDirectory, string outputGlob, string resourceName)
{
var fastJarDirectory = $"{outputDirectory}/{JavaHostingExtensions.QuarkusFastJarDirectory}";
var uberJarFallback = SelectSingleJarCommand(
outputGlob,
$"{ContainerArtifactDirectory}/{JavaHostingExtensions.QuarkusRunJarName}",
resourceName);
// "cp -r <dir>/." rather than "cp -r <dir>" so the contents land directly in the destination
// whether or not it already exists, which "cp -r" alone does not guarantee.
var withoutMetadata = $"if [ -d {fastJarDirectory} ]; then cp -r {fastJarDirectory}/. {ContainerArtifactDirectory}/; "
+ $"else {uberJarFallback}; fi";
// A path with a directory component is a layout whose runner needs everything beside it, so the
// whole directory is staged. A bare file name is a runner at the top of the output directory,
// which is self-contained under uber-jar and needs the sibling lib/ under legacy-jar.
return string.Join(" && ",
$"mkdir -p {ContainerArtifactDirectory}",
$"quarkus_artifact=$(sed -n 's/^path=//p' {outputDirectory}/quarkus-artifact.properties 2>/dev/null | head -1)",
"case \"$quarkus_artifact\" in "
+ $"'') {withoutMetadata} ;; "
+ $"*/*) cp -r \"{outputDirectory}/${{quarkus_artifact%/*}}/.\" {ContainerArtifactDirectory}/ ;; "
+ $"*) cp \"{outputDirectory}/$quarkus_artifact\" {ContainerArtifactDirectory}/{JavaHostingExtensions.QuarkusRunJarName} && "
+ $"if [ -d {outputDirectory}/lib ]; then cp -r {outputDirectory}/lib {ContainerArtifactDirectory}/lib; fi ;; "
+ "esac");
}
/// <summary>
/// Copies the JAR the AppHost named, failing with a message that names it when the build did not
/// produce it.
/// </summary>
/// <remarks>
/// A bare <c>cp</c> would fail with the shell's own "No such file or directory", which does not say
/// which resource or which of the two plausible causes applies.
/// </remarks>
private static string SelectNamedJarCommand(string jarPath, string destination, string resourceName)
{
var quoted = ShellQuote(jarPath);
return string.Join(" && ",
// The path is emitted as its own single-quoted shell word rather than interpolated into the
// double-quoted message, because a double-quoted $(...) would run during the image build.
$"if [ ! -f {quoted} ]; then echo \"Aspire: the build of '{resourceName}' did not produce\" {quoted} >&2; echo \"Check the jarPath passed to AddJavaApp, or use WithJarArtifact to name the published artifact separately.\" >&2; exit 1; fi",
$"cp {quoted} {destination}");
}
private static string SelectSingleJarCommand(string outputGlob, string destination, string resourceName)
{
// Written as a single line because each Dockerfile RUN is one shell invocation.
//
// $jars is quoted at the copy because the artifact name is not guaranteed to be whitespace-free:
// Gradle exposes archiveFileName and archivesName, and Maven exposes finalName, so a build can
// legitimately produce "reports service.jar". Quoting is safe precisely because the copy is only
// reached once the count check has established there is exactly one line.
return string.Join(" && ",
$"jars=$(ls {outputGlob} 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\\.jar$' || true)",
"count=$(printf '%s\\n' \"$jars\" | grep -c . || true)",
$"if [ \"$count\" != \"1\" ]; then echo \"Aspire: expected exactly one application JAR from the build of '{resourceName}' matching {outputGlob}, found $count:\" >&2; echo \"$jars\" >&2; echo \"Use WithJarArtifact(\\\"<relative path>\\\") to select one.\" >&2; exit 1; fi",
$"cp \"$jars\" {destination}");
}
private static string ShellQuote(string value) => $"'{value.Replace("'", "'\\''")}'";
/// <summary>
/// Quotes a build argument only when the shell would otherwise change its meaning.
/// </summary>
/// <remarks>
/// These values reach the container build as a shell command, so a value containing whitespace,
/// quotes, <c>$</c>, or <c>;</c> would be re-split or expanded and the image would build differently
/// from the identical arguments used on the host, where they are passed as separate argv entries.
/// Ordinary arguments such as <c>-DskipTests</c> are left bare so the generated Dockerfile stays
/// readable.
/// </remarks>
private static string ShellQuoteIfNeeded(string value)
=> value.Length > 0 && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '/' or ':' or '=' or '+' or '@' or '-' or ',')
? value
: ShellQuote(value);
}
// Matches the version in a pinned distribution archive name, for example
// .../gradle-9.0.0-bin.zip -> major 9, minor 0
// .../gradle-8.4-bin.zip -> major 8, minor 4
// .../gradle-8.14-rc-1-all.zip -> major 8, minor 14
// The minor group is optional so a hypothetical "gradle-9-bin.zip" still yields a major.
[GeneratedRegex(@"gradle-(?<major>\d+)(?:\.(?<minor>\d+))?(?:[.\-]|-bin|-all)")]
private static partial Regex GradleDistributionRegex();
[GeneratedRegex(@"apache-maven-(?<major>\d+)\.")]
private static partial Regex MavenDistributionRegex();
}
#pragma warning restore ASPIREDOCKERFILEBUILDER001