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); + } + } + } +}