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);
 
        // 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="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 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,
                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