diff --git a/src/CommunityToolkit.Aspire.Hosting.Golang/CommunityToolkit.Aspire.Hosting.Golang.csproj b/src/CommunityToolkit.Aspire.Hosting.Golang/CommunityToolkit.Aspire.Hosting.Golang.csproj
index 5ce3671d1..8c02b1c83 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Golang/CommunityToolkit.Aspire.Hosting.Golang.csproj
+++ b/src/CommunityToolkit.Aspire.Hosting.Golang/CommunityToolkit.Aspire.Hosting.Golang.csproj
@@ -8,6 +8,10 @@
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Golang/GolangAppHostingExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Golang/GolangAppHostingExtension.cs
index 37f9f179b..7e1adaa84 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Golang/GolangAppHostingExtension.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Golang/GolangAppHostingExtension.cs
@@ -1,5 +1,10 @@
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Utils;
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Hosting;
@@ -68,10 +73,165 @@ public static IResourceBuilder AddGolangApp(this ID
return builder.AddResource(resource)
.WithGolangDefaults()
- .WithArgs([.. allArgs]);
+ .WithArgs([.. allArgs])
+ .PublishAsGolangDockerfile(workingDirectory, executable, buildTags);
}
private static IResourceBuilder WithGolangDefaults(
this IResourceBuilder builder) =>
builder.WithOtlpExporter();
-}
+
+ ///
+ /// Configures the Golang application to be published as a Dockerfile with automatic multi-stage build generation.
+ ///
+ /// The resource builder.
+ /// The working directory containing the Golang application.
+ /// The path to the Golang package directory or source file to be executed.
+ /// The optional build tags to be used when building the Golang application.
+ /// A reference to the .
+#pragma warning disable ASPIREDOCKERFILEBUILDER001
+ private static IResourceBuilder PublishAsGolangDockerfile(
+ this IResourceBuilder builder,
+ string workingDirectory,
+ string executable,
+ string[]? buildTags)
+ {
+ const string DefaultAlpineVersion = "3.21";
+
+ return builder.PublishAsDockerFile(publish =>
+ {
+ publish.WithDockerfileBuilder(workingDirectory, context =>
+ {
+ var buildArgs = new List { "build", "-o", "server" };
+
+ if (buildTags is { Length: > 0 })
+ {
+ buildArgs.Add("-tags");
+ buildArgs.Add(string.Join(",", buildTags));
+ }
+
+ buildArgs.Add(executable);
+
+ // Get custom base image from annotation, if present
+ context.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
+ var goVersion = baseImageAnnotation?.BuildImage ?? GetDefaultGoBaseImage(workingDirectory, context.Services);
+
+ var buildStage = context.Builder
+ .From(goVersion, "builder")
+ .WorkDir("/build")
+ .Copy(".", "./")
+ .Run(string.Join(" ", ["CGO_ENABLED=0", "go", .. buildArgs]));
+
+ var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"alpine:{DefaultAlpineVersion}";
+
+ context.Builder
+ .From(runtimeImage)
+ .Run("apk --no-cache add ca-certificates")
+ .WorkDir("/app")
+ .CopyFrom(buildStage.StageName!, "/build/server", "/app/server")
+ .Entrypoint(["/app/server"]);
+ });
+ });
+ }
+#pragma warning restore ASPIREDOCKERFILEBUILDER001
+
+ private static string GetDefaultGoBaseImage(string workingDirectory, IServiceProvider serviceProvider)
+ {
+ const string DefaultGoVersion = "1.23";
+ var logger = serviceProvider.GetService>() ?? NullLogger.Instance;
+ var goVersion = DetectGoVersion(workingDirectory, logger) ?? DefaultGoVersion;
+ return $"golang:{goVersion}";
+ }
+
+ ///
+ /// Detects the Go version to use for a project by checking go.mod and the installed Go toolchain.
+ ///
+ /// The working directory of the Go project.
+ /// The logger for diagnostic messages.
+ /// The detected Go version as a string, or null if no version is detected.
+ internal static string? DetectGoVersion(string workingDirectory, ILogger logger)
+ {
+ // Check go.mod file
+ var goModPath = Path.Combine(workingDirectory, "go.mod");
+ if (File.Exists(goModPath))
+ {
+ try
+ {
+ var goModContent = File.ReadAllText(goModPath);
+ // Look for "go X.Y" or "go X.Y.Z" line in go.mod
+ var match = Regex.Match(goModContent, @"^\s*go\s+(\d+\.\d+(?:\.\d+)?)", RegexOptions.Multiline);
+ if (match.Success)
+ {
+ var version = match.Groups[1].Value;
+ // Extract major.minor (e.g., "1.22" from "1.22.3")
+ var versionParts = version.Split('.');
+ if (versionParts.Length >= 2)
+ {
+ var majorMinor = $"{versionParts[0]}.{versionParts[1]}";
+ logger.LogDebug("Detected Go version {Version} from go.mod file", majorMinor);
+ return majorMinor;
+ }
+ }
+ }
+ catch (IOException ex)
+ {
+ logger.LogDebug(ex, "Failed to parse go.mod file due to IO error");
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogDebug(ex, "Failed to parse go.mod file due to unauthorized access");
+ }
+ catch (RegexMatchTimeoutException ex)
+ {
+ logger.LogDebug(ex, "Failed to parse go.mod file due to regex timeout");
+ }
+ }
+
+ // Try to detect from installed Go toolchain
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "go",
+ Arguments = "version",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = Process.Start(startInfo);
+ if (process != null)
+ {
+ // Read both output and error asynchronously to avoid deadlock
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
+ process.WaitForExit();
+ var output = outputTask.GetAwaiter().GetResult();
+
+ if (process.ExitCode == 0)
+ {
+ // Output format: "go version goX.Y.Z ..."
+ var match = Regex.Match(output, @"go version go(\d+\.\d+)");
+ if (match.Success)
+ {
+ var version = match.Groups[1].Value;
+ logger.LogDebug("Detected Go version {Version} from installed toolchain", version);
+ return version;
+ }
+ }
+ }
+ }
+ catch (IOException ex)
+ {
+ logger.LogDebug(ex, "Failed to detect Go version from installed toolchain due to IO error");
+ }
+ catch (System.ComponentModel.Win32Exception ex)
+ {
+ logger.LogDebug(ex, "Failed to detect Go version from installed toolchain - go command not found or not executable");
+ }
+
+ logger.LogDebug("No Go version detected, will use default version");
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Golang/README.md b/src/CommunityToolkit.Aspire.Hosting.Golang/README.md
index b95f40d6b..e7de82f24 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Golang/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Golang/README.md
@@ -29,6 +29,40 @@ To have the Golang application listen on the correct port, you can use the follo
r.Run(":"+os.Getenv("PORT"))
```
+## Publishing
+
+When publishing your Aspire application, the Golang resource automatically generates a multi-stage Dockerfile for containerization. This means you don't need to manually create a Dockerfile for your Golang application.
+
+### Automatic Version Detection
+
+The integration automatically detects the Go version to use by:
+1. Checking the `go.mod` file for the Go version directive
+2. Falling back to the installed Go toolchain version
+3. Using Go 1.23 as the default if no version is detected
+
+### Customizing Base Images
+
+You can customize the base images used in the Dockerfile:
+
+```csharp
+var golang = builder.AddGolangApp("golang", "../gin-api")
+ .WithHttpEndpoint(env: "PORT")
+ .WithDockerfileBaseImage(
+ buildImage: "golang:1.22-alpine",
+ runtimeImage: "alpine:3.20");
+```
+
+### Generated Dockerfile
+
+The automatically generated Dockerfile:
+- Uses the detected or default Go version (e.g., `golang:1.22`) as the build stage
+- Uses `alpine:3.21` as the runtime stage for a smaller final image
+- Installs CA certificates in the runtime image for HTTPS support
+- Respects your build tags if specified
+- Builds the executable specified in your `AddGolangApp` call
+
+This automatic Dockerfile generation happens when you publish your Aspire application and requires no additional configuration.
+
## Additional Information
https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-golang
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Golang.Tests/GoVersionDetectionTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Golang.Tests/GoVersionDetectionTests.cs
new file mode 100644
index 000000000..f664193ea
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Golang.Tests/GoVersionDetectionTests.cs
@@ -0,0 +1,147 @@
+using Aspire.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CommunityToolkit.Aspire.Hosting.Golang.Tests;
+
+public class GoVersionDetectionTests
+{
+ [Fact]
+ public void DetectGoVersionFromGoMod()
+ {
+ // Arrange
+ var workingDirectory = Path.GetFullPath(Path.Combine("..", "..", "..", "..", "..", "examples", "golang", "gin-api"));
+ var logger = NullLogger.Instance;
+
+ // Act
+ var version = GolangAppHostingExtension.DetectGoVersion(workingDirectory, logger);
+
+ // Assert
+ Assert.NotNull(version);
+ Assert.Equal("1.22", version);
+ }
+
+ [Fact]
+ public void DetectGoVersionFromGoMod_NonExistentDirectory()
+ {
+ // Arrange
+ var workingDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ var logger = NullLogger.Instance;
+
+ // Act
+ var version = GolangAppHostingExtension.DetectGoVersion(workingDirectory, logger);
+
+ // Assert - should fall back to checking installed toolchain or return null
+ // We don't assert a specific value because it depends on the system's Go installation
+ Assert.True(version == null || !string.IsNullOrEmpty(version));
+ }
+
+ [Fact]
+ public void DetectGoVersionFromGoMod_WithPatchVersion()
+ {
+ // Arrange - Create a temporary directory with a go.mod file
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var goModPath = Path.Combine(tempDir, "go.mod");
+ File.WriteAllText(goModPath, @"module testmodule
+
+go 1.21.5
+
+require (
+ github.com/example/package v1.0.0
+)
+");
+
+ var logger = NullLogger.Instance;
+
+ // Act
+ var version = GolangAppHostingExtension.DetectGoVersion(tempDir, logger);
+
+ // Assert
+ Assert.NotNull(version);
+ Assert.Equal("1.21", version);
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public void DetectGoVersionFromGoMod_WithMajorMinorOnly()
+ {
+ // Arrange - Create a temporary directory with a go.mod file
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var goModPath = Path.Combine(tempDir, "go.mod");
+ File.WriteAllText(goModPath, @"module testmodule
+
+go 1.20
+
+require (
+ github.com/example/package v1.0.0
+)
+");
+
+ var logger = NullLogger.Instance;
+
+ // Act
+ var version = GolangAppHostingExtension.DetectGoVersion(tempDir, logger);
+
+ // Assert
+ Assert.NotNull(version);
+ Assert.Equal("1.20", version);
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public void DetectGoVersionFromGoMod_InvalidFormat()
+ {
+ // Arrange - Create a temporary directory with an invalid go.mod file
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var goModPath = Path.Combine(tempDir, "go.mod");
+ File.WriteAllText(goModPath, @"module testmodule
+
+go invalid
+
+require (
+ github.com/example/package v1.0.0
+)
+");
+
+ var logger = NullLogger.Instance;
+
+ // Act
+ var version = GolangAppHostingExtension.DetectGoVersion(tempDir, logger);
+
+ // Assert - should fall back to checking installed toolchain or return null
+ Assert.True(version == null || !string.IsNullOrEmpty(version));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+}