Skip to content

Commit

Permalink
Java app build extension (#348)
Browse files Browse the repository at this point in the history
* Adding the ability to do a maven build in the app host

This uses an event to run the mvnw build (or it can be customised via options), so it is only used when the app host is running the resource

Fixes #339

* Adding a 'build with maven' command

Allows you to rebuild the java app without having to restart the whole app host.

* Expanding test coverage

* Some more tests
  • Loading branch information
aaronpowell authored Jan 21, 2025
1 parent 6787af5 commit 1c00b94
Show file tree
Hide file tree
Showing 10 changed files with 494 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,4 @@
<ProjectReference Include="..\..\..\src\CommunityToolkit.Aspire.Hosting.Java\CommunityToolkit.Aspire.Hosting.Java.csproj" IsAspireProjectResource="false" />
</ItemGroup>

<Target Name="PublishRunMaven" AfterTargets="Build">
<!-- As part of publishing, ensure the Java app is freshly built -->
<Exec WorkingDirectory="$(JavaAppRoot)" Command="./mvnw --quiet clean package" />
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Port = 8085,
OtelAgentPath = "../../../agents",
})
.WithMavenBuild()
.PublishAsDockerFile(
[
new DockerBuildArg("JAR_NAME", "spring-maven-0.0.1-SNAPSHOT.jar"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Aspire.Hosting.Java.Tests" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Utils;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for adding Java applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static partial class JavaAppHostingExtension
{
/// <summary>
/// Adds a Java application to the application model. Executes the containerized Java app.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="options">The <see cref="JavaAppContainerResourceOptions"/> to configure the Java application.</param>"
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<JavaAppContainerResource> AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options)
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
ArgumentNullException.ThrowIfNull(options, nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name));
ArgumentException.ThrowIfNullOrWhiteSpace(options.ContainerImageName, nameof(options.ContainerImageName));

var resource = new JavaAppContainerResource(name);

var rb = builder.AddResource(resource)
.WithAnnotation(new ContainerImageAnnotation { Image = options.ContainerImageName, Tag = options.ContainerImageTag, Registry = options.ContainerRegistry })
.WithHttpEndpoint(port: options.Port, targetPort: options.TargetPort, name: JavaAppContainerResource.HttpEndpointName)
.WithJavaDefaults(options);

if (options.Args is { Length: > 0 })
{
rb.WithArgs(options.Args);
}

return rb;
}

/// <summary>
/// Adds a Spring application to the application model. Executes the containerized Spring app.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="options">The <see cref="JavaAppContainerResourceOptions"/> to configure the Java application.</param>"
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<JavaAppContainerResource> AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, JavaAppContainerResourceOptions options) =>
builder.AddJavaApp(name, options);

private static IResourceBuilder<JavaAppContainerResource> WithJavaDefaults(
this IResourceBuilder<JavaAppContainerResource> builder,
JavaAppContainerResourceOptions options) =>
builder.WithOtlpExporter()
.WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for adding Java applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static partial class JavaAppHostingExtension
{
/// <summary>
/// Adds a Java application to the application model. Executes the executable Java app.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command. If null, the working directory of the current process is used.</param>
/// <param name="options">The <see cref="JavaAppExecutableResourceOptions"/> to configure the Java application.</param>"
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<JavaAppExecutableResource> AddJavaApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options)
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
ArgumentNullException.ThrowIfNull(options, nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name));
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory, nameof(workingDirectory));

#pragma warning disable CS8601 // Possible null reference assignment.
string[] allArgs = options.Args is { Length: > 0 }
? ["-jar", options.ApplicationName, .. options.Args]
: ["-jar", options.ApplicationName];
#pragma warning restore CS8601 // Possible null reference assignment.

workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory));
var resource = new JavaAppExecutableResource(name, "java", workingDirectory);

return builder.AddResource(resource)
.WithJavaDefaults(options)
.WithHttpEndpoint(port: options.Port, name: JavaAppContainerResource.HttpEndpointName, isProxied: false)
.WithArgs(allArgs);
}

/// <summary>
/// Adds a Spring application to the application model. Executes the executable Spring app.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command. If null, the working directory of the current process is used.</param>
/// <param name="options">The <see cref="JavaAppExecutableResourceOptions"/> to configure the Java application.</param>"
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<JavaAppExecutableResource> AddSpringApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, JavaAppExecutableResourceOptions options) =>
builder.AddJavaApp(name, workingDirectory, options);

/// <summary>
/// Adds a Maven build step to the application model.
/// </summary>
/// <param name="builder">The <see cref="IResourceBuilder{T}"/> to add the Maven build step to.</param>
/// <param name="mavenOptions">The <see cref="MavenOptions"/> to configure the Maven build step.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// This method adds a Maven build step to the application model. The Maven build step is executed before the Java application is started.
///
/// The Maven build step is added as an executable resource named "maven" with the command "mvnw --quiet clean package".
///
/// The Maven build step is excluded from the manifest file.
/// </remarks>
public static IResourceBuilder<JavaAppExecutableResource> WithMavenBuild(
this IResourceBuilder<JavaAppExecutableResource> builder,
MavenOptions? mavenOptions = null)
{
mavenOptions ??= new MavenOptions();

if (mavenOptions.WorkingDirectory is null)
{
mavenOptions.WorkingDirectory = builder.Resource.WorkingDirectory;
}

var annotation = new MavenBuildAnnotation(mavenOptions);

if (builder.Resource.TryGetLastAnnotation<MavenBuildAnnotation>(out _))
{
// Replace the existing annotation, but don't continue on and subscribe to the event again.
builder.WithAnnotation(annotation, ResourceAnnotationMutationBehavior.Replace);
return builder;
}

builder.WithAnnotation(annotation);

builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (e, ct) =>
{
if (e.Resource is not JavaAppExecutableResource javaAppResource)
{
return;
}

await BuildWithMaven(javaAppResource, e.Services, ct).ConfigureAwait(false);
});

builder.WithCommand(
"build-with-maven",
"Build with Maven",
async (context) =>
await BuildWithMaven(builder.Resource, context.ServiceProvider, context.CancellationToken, false).ConfigureAwait(false) ?
new ExecuteCommandResult { Success = true } :
new ExecuteCommandResult { Success = false, ErrorMessage = "Failed to build with Maven" },
(context) => context.ResourceSnapshot.State switch
{
{ Text: "Stopped" } or
{ Text: "Exited" } or
{ Text: "Finished" } or
{ Text: "FailedToStart" } => ResourceCommandState.Enabled,
_ => ResourceCommandState.Disabled
},
iconName: "build"
);

return builder;

static async Task<bool> BuildWithMaven(JavaAppExecutableResource javaAppResource, IServiceProvider services, CancellationToken ct, bool useNotificationService = true)
{
if (!javaAppResource.TryGetLastAnnotation<MavenBuildAnnotation>(out var mavenOptionsAnnotation))
{
return false;
}

var mavenOptions = mavenOptionsAnnotation.MavenOptions;
var logger = services.GetRequiredService<ResourceLoggerService>().GetLogger(javaAppResource);
var notificationService = services.GetRequiredService<ResourceNotificationService>();

if (useNotificationService)
{
await notificationService.PublishUpdateAsync(javaAppResource, state => state with
{
State = new("Building Maven project", KnownResourceStates.Starting)
}).ConfigureAwait(false);
}

logger.LogInformation("Building Maven project");

var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

var mvnw = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = isWindows ? "cmd" : "sh",
Arguments = isWindows ? $"/c {mavenOptions.Command} {string.Join(" ", mavenOptions.Args)}" : $"./{mavenOptions.Command} {string.Join(" ", mavenOptions.Args)}",
WorkingDirectory = mavenOptions.WorkingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
UseShellExecute = false,
}
};

mvnw.OutputDataReceived += async (sender, args) =>
{
if (!string.IsNullOrWhiteSpace(args.Data))
{
if (useNotificationService)
{
await notificationService.PublishUpdateAsync(javaAppResource, state => state with
{
State = new(args.Data, KnownResourceStates.Starting)
}).ConfigureAwait(false);
}

logger.LogInformation("{Data}", args.Data);
}
};

mvnw.ErrorDataReceived += async (sender, args) =>
{
if (!string.IsNullOrWhiteSpace(args.Data))
{
if (useNotificationService)
{
await notificationService.PublishUpdateAsync(javaAppResource, state => state with
{
State = new(args.Data, KnownResourceStates.FailedToStart)
}).ConfigureAwait(false);
}

logger.LogError("{Data}", args.Data);
}
};

mvnw.Start();
mvnw.BeginOutputReadLine();
mvnw.BeginErrorReadLine();

await mvnw.WaitForExitAsync(ct).ConfigureAwait(false);

if (mvnw.ExitCode != 0)
{
// always use notification service to push out errors in the maven build
await notificationService.PublishUpdateAsync(javaAppResource, state => state with
{
State = new($"mvnw exited with {mvnw.ExitCode}", KnownResourceStates.FailedToStart)
}).ConfigureAwait(false);

return false;
}
return true;
}
}

private static IResourceBuilder<JavaAppExecutableResource> WithJavaDefaults(
this IResourceBuilder<JavaAppExecutableResource> builder,
JavaAppExecutableResourceOptions options) =>
builder.WithOtlpExporter()
.WithEnvironment("JAVA_TOOL_OPTIONS", $"-javaagent:{options.OtelAgentPath?.TrimEnd('/')}/opentelemetry-javaagent.jar")
.WithEnvironment("SERVER_PORT", options.Port.ToString(CultureInfo.InvariantCulture));
}
Loading

0 comments on commit 1c00b94

Please sign in to comment.