diff --git a/Directory.Packages.props b/Directory.Packages.props index 668a4938a..5a00caca0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + @@ -130,4 +131,4 @@ - + \ No newline at end of file diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json b/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json index aead87517..ddb61c69c 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json @@ -1,13 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:5050", - "sslPort": 5051 - } - }, + "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", @@ -28,14 +20,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } \ No newline at end of file diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj index 6b1d993eb..674809a89 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs index aa0bc1b0d..7c414327c 100644 --- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs @@ -1,17 +1,22 @@ var builder = DistributedApplication.CreateBuilder(args); -var apiapp = builder.AddProject("apiapp"); +builder.AddDockerComposeEnvironment("env"); + +var apiapp = builder.AddProject("apiapp") + .WithExternalHttpEndpoints(); var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample") .WithOtelAgent("/agents/opentelemetry-javaagent.jar") - .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT") + .WithExternalHttpEndpoints(); var executableapp = builder.AddJavaApp("executableapp", workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven") .WithMavenGoal("spring-boot:run") .WithOtelAgent("../../../agents/opentelemetry-javaagent.jar") .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT") - .WithHttpHealthCheck("/health"); + .WithHttpHealthCheck("/health") + .WithExternalHttpEndpoints(); var webapp = builder.AddProject("webapp") .WithExternalHttpEndpoints() diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env new file mode 100644 index 000000000..fd6d20986 --- /dev/null +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env @@ -0,0 +1,15 @@ +# Container image name for apiapp +APIAPP_IMAGE= + +# Default container port for apiapp +APIAPP_PORT= + +# Container image name for executableapp +EXECUTABLEAPP_IMAGE= + +# Container image name for webapp +WEBAPP_IMAGE= + +# Default container port for webapp +WEBAPP_PORT= + diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production new file mode 100644 index 000000000..17f52f115 --- /dev/null +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production @@ -0,0 +1,15 @@ +# Container image name for apiapp +APIAPP_IMAGE=apiapp:aspire-deploy-20260320212742 + +# Default container port for apiapp +APIAPP_PORT=8080 + +# Container image name for executableapp +EXECUTABLEAPP_IMAGE=executableapp:aspire-deploy-20260320212742 + +# Container image name for webapp +WEBAPP_IMAGE=webapp:aspire-deploy-20260320212742 + +# Default container port for webapp +WEBAPP_PORT=8080 + diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml new file mode 100644 index 000000000..aa2c96477 --- /dev/null +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + env-dashboard: + image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" + ports: + - "18888" + expose: + - "18889" + - "18890" + networks: + - "aspire" + restart: "always" + apiapp: + image: "${APIAPP_IMAGE}" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + HTTP_PORTS: "${APIAPP_PORT}" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "apiapp" + ports: + - "${APIAPP_PORT}" + networks: + - "aspire" + containerapp: + image: "docker.io/aliencube/aspire-spring-maven-sample:latest" + environment: + JAVA_TOOL_OPTIONS: "-javaagent:/agents/opentelemetry-javaagent.jar" + SERVER_PORT: "8080" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "containerapp" + ports: + - "8080" + networks: + - "aspire" + executableapp: + image: "${EXECUTABLEAPP_IMAGE}" + environment: + JAVA_TOOL_OPTIONS: "-javaagent:../../../agents/opentelemetry-javaagent.jar" + SERVER_PORT: "8080" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "executableapp" + ports: + - "8080" + networks: + - "aspire" + webapp: + image: "${WEBAPP_IMAGE}" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + HTTP_PORTS: "${WEBAPP_PORT}" + CONTAINERAPP_HTTP: "http://containerapp:8080" + services__containerapp__http__0: "http://containerapp:8080" + EXECUTABLEAPP_HTTP: "http://executableapp:8080" + services__executableapp__http__0: "http://executableapp:8080" + APIAPP_HTTP: "http://apiapp:${APIAPP_PORT}" + services__apiapp__http__0: "http://apiapp:${APIAPP_PORT}" + APIAPP_HTTPS: "https://apiapp:${APIAPP_PORT}" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "webapp" + ports: + - "${WEBAPP_PORT}" + networks: + - "aspire" +networks: + aspire: + driver: "bridge" diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json new file mode 100644 index 000000000..cec127f4d --- /dev/null +++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs index 5214b8654..99857a6c5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs @@ -84,7 +84,8 @@ public static IResourceBuilder AddJavaApp(this IDistr context.Args.Add("-jar"); context.Args.Add(resource.JarPath); } - }); + }) + .PublishAsJavaDockerfile(); return resourceBuilder; } @@ -116,12 +117,14 @@ public static IResourceBuilder AddJavaApp(this IDistr workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); var resource = new JavaAppExecutableResource(name, "java", workingDirectory); + resource.JarPath = options.ApplicationName; return builder.AddResource(resource) .WithJavaDefaults(options) .WithIconName("DrinkCoffee") .WithHttpEndpoint(port: options.Port, name: JavaAppContainerResource.HttpEndpointName, isProxied: false) - .WithArgs(allArgs); + .WithArgs(allArgs) + .PublishAsJavaDockerfile(); } /// @@ -179,11 +182,14 @@ public static IResourceBuilder WithMavenBuild( ? wrapper.WrapperPath : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper)); - return builder.WithJavaBuildStep( - buildResourceName: $"{builder.Resource.Name}-maven-build", - createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory), - wrapperPath: resolvedWrapper, - buildArgs: args.Length > 0 ? args : ["clean", "package"]); + string[] resolvedBuildArgs = args.Length > 0 ? args : ["clean", "package"]; + + return builder.WithPublishBuildAnnotation(JavaBuildTool.Maven, resolvedBuildArgs) + .WithJavaBuildStep( + buildResourceName: $"{builder.Resource.Name}-maven-build", + createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory), + wrapperPath: resolvedWrapper, + buildArgs: resolvedBuildArgs); } /// @@ -205,11 +211,14 @@ public static IResourceBuilder WithGradleBuild( ? wrapper.WrapperPath : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); - return builder.WithJavaBuildStep( - buildResourceName: $"{builder.Resource.Name}-gradle-build", - createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory), - wrapperPath: resolvedWrapper, - buildArgs: args.Length > 0 ? args : ["clean", "build"]); + string[] resolvedBuildArgs = args.Length > 0 ? args : ["clean", "build"]; + + return builder.WithPublishBuildAnnotation(JavaBuildTool.Gradle, resolvedBuildArgs) + .WithJavaBuildStep( + buildResourceName: $"{builder.Resource.Name}-gradle-build", + createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory), + wrapperPath: resolvedWrapper, + buildArgs: resolvedBuildArgs); } private static IResourceBuilder WithJavaBuildStep( @@ -235,6 +244,20 @@ private static IResourceBuilder WithJavaBuildStep WithPublishBuildAnnotation( + this IResourceBuilder builder, + JavaBuildTool tool, + string[] buildArgs) + { + string? wrapperPath = builder.Resource.TryGetLastAnnotation(out var wrapper) + ? wrapper.WrapperPath + : null; + + builder.Resource.Annotations.Add(new JavaPublishBuildAnnotation(tool, wrapperPath, buildArgs)); + + return builder; + } + /// /// Configures the Java application to run using a Maven goal (e.g., spring-boot:run). /// In run mode, the resource command is changed from java to the Maven wrapper. @@ -264,7 +287,8 @@ public static IResourceBuilder WithMavenGoal( : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper)); builder.Resource.Annotations.Add( - new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal])); + new JavaBuildToolAnnotation(JavaBuildTool.Maven, resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal])); + builder.WithPublishBuildAnnotation(JavaBuildTool.Maven, ["package"]); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { @@ -303,7 +327,8 @@ public static IResourceBuilder WithGradleTask( : Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper)); builder.Resource.Annotations.Add( - new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task])); + new JavaBuildToolAnnotation(JavaBuildTool.Gradle, resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task])); + builder.WithPublishBuildAnnotation(JavaBuildTool.Gradle, ["build"]); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs new file mode 100644 index 000000000..9eaeb3ee2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs @@ -0,0 +1,134 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; + +namespace Aspire.Hosting; + +public static partial class JavaAppHostingExtension +{ + private const string DefaultJavaBuildImage = "eclipse-temurin:21-jdk"; + private const string DefaultJavaRuntimeImage = "eclipse-temurin:21-jre"; + +#pragma warning disable ASPIREDOCKERFILEBUILDER001 + private static IResourceBuilder PublishAsJavaDockerfile( + this IResourceBuilder builder) + { + return builder.PublishAsDockerFile(publish => + { + if (File.Exists(Path.Combine(builder.Resource.WorkingDirectory, "Dockerfile"))) + { + return; + } + + publish.WithDockerfileBuilder(builder.Resource.WorkingDirectory, context => + { + JavaAppExecutableResource resource = (JavaAppExecutableResource)context.Resource; + context.Resource.TryGetLastAnnotation(out var baseImageAnnotation); + + string buildImage = baseImageAnnotation?.BuildImage ?? DefaultJavaBuildImage; + string runtimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultJavaRuntimeImage; + resource.TryGetLastAnnotation(out var publishBuildAnnotation); + + var buildStage = context.Builder + .From(buildImage, "builder") + .WorkDir("/workspace") + .Copy(".", "./"); + + if (publishBuildAnnotation is not null) + { + buildStage.Run(GetPublishBuildCommand(resource, publishBuildAnnotation)); + } + + buildStage.Run(GetPublishArtifactCommand(resource, publishBuildAnnotation)); + + context.Builder + .From(runtimeImage, "app") + .WorkDir("/app") + .CopyFrom(buildStage.StageName!, "/out/app.jar", "/app/app.jar") + .Entrypoint(["java", "-jar", "/app/app.jar"]); + }); + }); + } +#pragma warning restore ASPIREDOCKERFILEBUILDER001 + + private static string GetPublishBuildCommand( + JavaAppExecutableResource resource, + JavaPublishBuildAnnotation publishBuildAnnotation) + { + string wrapperPath = publishBuildAnnotation.WrapperPath is not null + ? GetPathRelativeToWorkingDirectory(resource.WorkingDirectory, publishBuildAnnotation.WrapperPath, "wrapper script") + : publishBuildAnnotation.Tool switch + { + JavaBuildTool.Maven => "mvnw", + JavaBuildTool.Gradle => "gradlew", + _ => throw new InvalidOperationException($"Unsupported Java build tool '{publishBuildAnnotation.Tool}'.") + }; + + if (wrapperPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) || + wrapperPath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Java publish requires a Unix-compatible wrapper path, but '{wrapperPath}' points to a Windows wrapper."); + } + + if (!wrapperPath.StartsWith("./", StringComparison.Ordinal) && + !wrapperPath.StartsWith("/", StringComparison.Ordinal)) + { + wrapperPath = $"./{wrapperPath}"; + } + + string quotedWrapperPath = QuoteShellArgument(wrapperPath); + IEnumerable quotedArgs = publishBuildAnnotation.Args.Select(QuoteShellArgument); + + return $"chmod +x {quotedWrapperPath} && {quotedWrapperPath} {string.Join(" ", quotedArgs)}"; + } + + private static string GetPublishArtifactCommand( + JavaAppExecutableResource resource, + JavaPublishBuildAnnotation? publishBuildAnnotation) + { + if (resource.JarPath is not null) + { + string jarPath = GetPathRelativeToWorkingDirectory(resource.WorkingDirectory, resource.JarPath, "JAR path"); + return $"mkdir -p /out && cp {QuoteShellArgument(jarPath)} /out/app.jar"; + } + + if (publishBuildAnnotation is null) + { + throw new InvalidOperationException($"Java publish for resource '{resource.Name}' requires either a JAR path or a Maven/Gradle build configuration."); + } + + string searchDirectory = publishBuildAnnotation.Tool switch + { + JavaBuildTool.Maven => "target", + JavaBuildTool.Gradle => "build/libs", + _ => throw new InvalidOperationException($"Unsupported Java build tool '{publishBuildAnnotation.Tool}'.") + }; + + return + $"mkdir -p /out && artifact=$(find {QuoteShellArgument(searchDirectory)} -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' ! -name '*-tests.jar' ! -name '*-plain.jar' ! -name 'original-*.jar' | sort | head -n 1) && test -n \"$artifact\" && cp \"$artifact\" /out/app.jar"; + } + + private static string GetPathRelativeToWorkingDirectory( + string workingDirectory, + string path, + string description) + { + string fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(workingDirectory, path)); + + string relativePath = Path.GetRelativePath(workingDirectory, fullPath); + + if (relativePath.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath)) + { + throw new InvalidOperationException($"Java publish requires the {description} '{path}' to be inside the application's working directory."); + } + + return NormalizeContainerPath(relativePath); + } + + private static string NormalizeContainerPath(string path) => + path.Replace('\\', '/'); + + private static string QuoteShellArgument(string value) => + $"'{value.Replace("'", "'\"'\"'")}'"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs index 62763bc01..d38edca05 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs @@ -1,12 +1,24 @@ namespace Aspire.Hosting.ApplicationModel; +internal enum JavaBuildTool +{ + Maven, + Gradle +} + /// /// Represents a metadata annotation that specifies the build tool used to run the Java application. /// +/// The build tool used to run the Java application. /// The full path to the build tool wrapper script. /// The arguments to pass to the build tool (e.g., the goal or task name). -internal sealed class JavaBuildToolAnnotation(string wrapperPath, string[] args) : IResourceAnnotation +internal sealed class JavaBuildToolAnnotation(JavaBuildTool tool, string wrapperPath, string[] args) : IResourceAnnotation { + /// + /// The build tool used to run the Java application. + /// + public JavaBuildTool Tool { get; } = tool; + /// /// The full path to the build tool wrapper script (e.g., mvnw or gradlew). /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs new file mode 100644 index 000000000..4d7b4c684 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs @@ -0,0 +1,25 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents publish-time metadata for building a Java application inside a container image. +/// +/// The build tool used during publish. +/// The configured wrapper path, or null to use the default wrapper. +/// The arguments to pass to the publish build command. +internal sealed class JavaPublishBuildAnnotation(JavaBuildTool tool, string? wrapperPath, string[] args) : IResourceAnnotation +{ + /// + /// The build tool used during publish. + /// + public JavaBuildTool Tool { get; } = tool; + + /// + /// The configured wrapper path, or null to use the default wrapper for the tool. + /// + public string? WrapperPath { get; } = wrapperPath; + + /// + /// The arguments to pass to the publish build command. + /// + public string[] Args { get; } = args; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md index 65d7155c0..e31893966 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md @@ -59,6 +59,16 @@ var app = builder.AddJavaApp("app", "../java-project", "target/app.jar") .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); ``` +## Publish as a container + +When you run `aspire publish`, executable Java apps are published as a multi-stage Dockerfile-based container image. + +- `AddJavaApp(..., jarPath)` copies the configured JAR into the runtime image. If that JAR is produced during publish, pair it with `WithMavenBuild` or `WithGradleBuild`. +- Apps configured with `WithMavenGoal` or `WithGradleTask` keep using those commands in run mode. During publish, the integration switches to a packaging command in the container build so the produced JAR can be copied into the runtime image. +- By default, the generated Dockerfile uses `eclipse-temurin:21-jdk` for the build stage and `eclipse-temurin:21-jre` for the runtime stage. +- If the application directory already contains a `Dockerfile`, publish uses that file instead of generating one. +- If you customize the wrapper path, keep the wrapper script inside the application's working directory so it is available in the Docker build context. + ## Add a containerized Java application To run a Java application from a container image, use `AddJavaContainerApp`: diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs new file mode 100644 index 000000000..85c0e7d0f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs @@ -0,0 +1,107 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; + +namespace CommunityToolkit.Aspire.Hosting.Java.Tests; + +public class PublishResourceCreationTests +{ + [Fact] + public void AddJavaAppAddsManifestPublishingAnnotationInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var java = builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var publishedResource = Assert.Single(appModel.Resources, resource => resource.Name == "java"); + Assert.Contains("ExecutableContainerResource", publishedResource.GetType().Name); + + Assert.True(java.Resource.TryGetAnnotationsOfType(out var annotations)); + Assert.NotEmpty(annotations); + } + + [Fact] + public void MavenBuildUsesPublishAnnotationWithoutCreatingBuildResourceInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var java = builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar") + .WithMavenBuild(); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + Assert.Empty(appModel.Resources.OfType()); + Assert.False(java.Resource.TryGetAnnotationsOfType(out _)); + Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation)); + Assert.Equal(JavaBuildTool.Maven, publishAnnotation.Tool); + Assert.Null(publishAnnotation.WrapperPath); + Assert.Equal(["clean", "package"], publishAnnotation.Args); + } + + [Fact] + public void MavenGoalKeepsJavaCommandAndAddsPublishBuildInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var java = builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithMavenGoal("spring-boot:run"); + + using var app = builder.Build(); + + Assert.Equal("java", java.Resource.Command); + Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation)); + Assert.Equal(JavaBuildTool.Maven, runAnnotation.Tool); + Assert.Equal(["spring-boot:run"], runAnnotation.Args); + + Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation)); + Assert.Equal(JavaBuildTool.Maven, publishAnnotation.Tool); + Assert.Null(publishAnnotation.WrapperPath); + Assert.Equal(["package"], publishAnnotation.Args); + } + + [Fact] + public void GradleTaskKeepsJavaCommandAndAddsPublishBuildInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var java = builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithGradleTask("bootRun"); + + using var app = builder.Build(); + + Assert.Equal("java", java.Resource.Command); + Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation)); + Assert.Equal(JavaBuildTool.Gradle, runAnnotation.Tool); + Assert.Equal(["bootRun"], runAnnotation.Args); + + Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation)); + Assert.Equal(JavaBuildTool.Gradle, publishAnnotation.Tool); + Assert.Null(publishAnnotation.WrapperPath); + Assert.Equal(["build"], publishAnnotation.Args); + } + + [Fact] + public void CustomWrapperPathIsPreservedForPublishMetadata() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var java = builder.AddJavaApp("java", Environment.CurrentDirectory) + .WithWrapperPath("tools/mvnw") + .WithMavenGoal("spring-boot:run"); + + using var app = builder.Build(); + + string expectedWrapper = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "tools/mvnw")); + + Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation)); + Assert.Equal(expectedWrapper, runAnnotation.WrapperPath); + + Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation)); + Assert.Equal(expectedWrapper, publishAnnotation.WrapperPath); + } +}