File: JavaDockerfileGenerator.cs
Web Access
Project: src\src\Aspire.Hosting.Java\Aspire.Hosting.Java.csproj (Aspire.Hosting.Java)
// 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