// 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 ASPIREEXTENSION001
#pragma warning disable ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIRECOMMAND001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using System.Text.Json;
namespace Aspire.Hosting.Go.Tests;
public class AddGoAppTests(ITestOutputHelper outputHelper)
{
// ---- Manifest: go run . (baseline) ------------------------------------
[Fact]
public async Task VerifyManifest_GoRunDot()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithHttpEndpoint(port: 8080, env: "PORT");
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = $$"""
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"."
],
"env": {
"PORT": "{api.bindings.http.targetPort}"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"port": 8080,
"targetPort": 8000
}
}
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: packagePath ----------------------------------------------
[Fact]
public async Task VerifyManifest_AddGoApp_PackagePath()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, packagePath: "./cmd/server");
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"./cmd/server"
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyPublish_PackagePath_UsedInDockerfileBuildCommand()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName, packagePath: "./cmd/server");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
// ---- Manifest: AddGoApp build params ------------------------------------
[Fact]
public async Task VerifyManifest_AddGoApp_BuildTagsParam()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, buildTags: ["netgo", "osusergo"]);
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"-tags=netgo,osusergo",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_AddGoApp_LdFlagsParam()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, ldFlags: "-X main.version=1.0.0");
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"-ldflags=-X main.version=1.0.0",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_AddGoApp_GcFlagsParam()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, gcFlags: "all=-N -l");
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"-gcflags=all=-N -l",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_AddGoApp_RaceDetectorParam()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, raceDetector: true);
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"-race",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_AddGoApp_AllBuildParams()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory,
buildTags: ["netgo"],
ldFlags: "-s -w",
gcFlags: "all=-N -l",
raceDetector: true);
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"-race",
"-tags=netgo",
"-ldflags=-s -w",
"-gcflags=all=-N -l",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithAppArgs --------------------------------------------
[Fact]
public async Task VerifyManifest_WithAppArgs()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithAppArgs("--config", "prod.yaml");
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
".",
"--config",
"prod.yaml"
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithModTidy does not appear in manifest ---------------
[Fact]
public async Task VerifyManifest_WithModTidy_DoesNotAlterMainManifest()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
// WithModTidy only creates a sibling in run mode; in publish mode the manifest is unchanged.
var app = builder.AddGoApp("api", AppContext.BaseDirectory).WithModTidy();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithModVendor does not appear in manifest -------------
[Fact]
public async Task VerifyManifest_WithModVendor_DoesNotAlterMainManifest()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory).WithModVendor();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithModDownload does not appear in manifest -----------
[Fact]
public async Task VerifyManifest_WithModDownload_DoesNotAlterMainManifest()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory).WithModDownload();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "go",
"args": [
"run",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithDelveServer changes command to dlv -----------------
[Fact]
public async Task VerifyManifest_WithDelveServer()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_WithDelveServer_EnableAcceptMultiClient()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer(new DelveServerOptions { AcceptMultiClient = true });
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"--accept-multiclient",
"debug",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_WithDelveServer_DisableOnlySameUser()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer(new DelveServerOptions { OnlySameUser = false });
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"--only-same-user=false",
"debug",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_WithDelveServer_ContinueOnStart()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer(new DelveServerOptions { ContinueOnStart = true });
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
"--continue",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task VerifyManifest_WithDelveServer_EnableLog()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer(new DelveServerOptions
{
Log = true,
LogOutput = "rpc,dap,debugger"
});
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"--log",
"--log-output=rpc,dap,debugger",
"debug",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
[Fact]
public async Task WithDelveServer_UsesDelveCommandWhenGoLaunchConfigurationIsSupported()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["go"]
};
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithDelveServer();
var application = builder.Build();
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services);
Assert.Equal("dlv", app.Resource.Command);
Assert.Equal(["--headless=true", "--listen=127.0.0.1:2345", "--api-version=2", "debug", "."], commandArguments);
}
// ---- VS Code debugging --------------------------------------------------
[Fact]
public async Task WithVSCodeDebugging_PopulatesGoLaunchConfiguration()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var packageDirectory = Path.Combine(sourceDir.FullName, "cmd", "server");
var app = builder.AddGoApp("api", sourceDir.FullName,
packagePath: "./cmd/server",
buildTags: ["integration"],
gcFlags: "all=-N -l",
raceDetector: true);
var launchConfig = await CreateLaunchConfigurationAsync(app.Resource);
Assert.Equal("go", launchConfig.Type);
Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode);
Assert.Equal(packageDirectory, launchConfig.Program);
Assert.Equal(sourceDir.FullName, launchConfig.WorkingDirectory);
Assert.Equal("-race -tags='integration' -gcflags='all=-N -l'", launchConfig.BuildFlags);
}
[Fact]
public async Task WithVSCodeDebugging_OmitsBuildFlagsWhenNoneConfigured()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var app = builder.AddGoApp("api", sourceDir.FullName);
var launchConfig = await CreateLaunchConfigurationAsync(app.Resource);
Assert.Null(launchConfig.BuildFlags);
}
[Fact]
public async Task WithVSCodeDebugging_KeepsGoToolArgumentsInTheAppModel()
{
// The `go run [build flags] <pkg>` prefix is declared as launch tool arguments, so it stays part of the
// resource's command line even during a debug session. Withholding it from the launched program is a
// DCP-level concern (see DcpExecutorTests), which keeps the app model and the dashboard accurate.
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["go"]
};
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var app = builder.AddGoApp("api", sourceDir.FullName,
packagePath: "./cmd/server",
buildTags: ["integration"],
ldFlags: "-X main.version=1.0.0",
gcFlags: "all=-N -l",
raceDetector: true)
.WithAppArgs("--config", "prod.yaml");
var application = builder.Build();
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services);
Assert.Collection(commandArguments,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("-race", arg),
arg => Assert.Equal("-tags=integration", arg),
arg => Assert.Equal("-ldflags=-X main.version=1.0.0", arg),
arg => Assert.Equal("-gcflags=all=-N -l", arg),
arg => Assert.Equal("./cmd/server", arg),
arg => Assert.Equal("--config", arg),
arg => Assert.Equal("prod.yaml", arg));
// The "go" launch configuration owns that prefix, which is what makes DCP withhold it from the debugger.
var debugAnnotation = app.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().Last();
Assert.True(app.Resource.HasLaunchToolArgsOwnedBy(debugAnnotation));
}
[Fact]
public async Task WithVSCodeDebugging_GoToolArgumentsLeadTheCommandLineRegardlessOfCallOrder()
{
// Regression coverage for https://github.com/microsoft/aspire/issues/18929: the tool prefix used to be
// removed by an ordinary WithArgs callback, which silently no-opped when it ran before the callback that
// added the prefix. Arguments added by the user must always land after the prefix, whenever they are added.
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var app = builder.AddGoApp("api", sourceDir.FullName, packagePath: "./cmd/server")
.WithArgs("--login", "user");
var application = builder.Build();
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services);
Assert.Collection(commandArguments,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("./cmd/server", arg),
arg => Assert.Equal("--login", arg),
arg => Assert.Equal("user", arg));
}
[Fact]
public async Task WithVSCodeDebugging_DoesNotRemoveGoToolArguments_WhenGoLaunchConfigurationUnsupported()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["python"]
};
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var app = builder.AddGoApp("api", sourceDir.FullName,
packagePath: "./cmd/server",
buildTags: ["integration"],
raceDetector: true)
.WithAppArgs("--config", "prod.yaml");
var application = builder.Build();
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services);
Assert.Collection(commandArguments,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("-race", arg),
arg => Assert.Equal("-tags=integration", arg),
arg => Assert.Equal("./cmd/server", arg),
arg => Assert.Equal("--config", arg),
arg => Assert.Equal("prod.yaml", arg));
}
// ---- Manifest: WithDelveServer with build flags -----------------------
[Fact]
public async Task VerifyManifest_WithDelveServer_AndBuildFlags()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory,
buildTags: ["netgo"],
ldFlags: "-s -w")
.WithDelveServer();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
"--build-flags=-tags=\u0027netgo\u0027 -ldflags=\u0027-s -w\u0027",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithDelveServer with race detector --------------------
[Fact]
public async Task VerifyManifest_WithDelveServer_AndRaceDetector()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, raceDetector: true)
.WithDelveServer();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
"--build-flags=-race",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithDelveServer with gcflags --------------------------
[Fact]
public async Task VerifyManifest_WithDelveServer_AndGcFlags()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory, gcFlags: "all=-N -l")
.WithDelveServer();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
"--build-flags=-gcflags=\u0027all=-N -l\u0027",
"."
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Manifest: WithDelveServer with extra program args ----------------
[Fact]
public async Task VerifyManifest_WithDelveServer_AndAppArgs()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory)
.WithAppArgs("--port", "9090")
.WithDelveServer();
var manifest = await ManifestUtils.GetManifest(app.Resource);
var expected = """
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "dlv",
"args": [
"--headless=true",
"--listen=127.0.0.1:2345",
"--api-version=2",
"debug",
".",
"--",
"--port",
"9090"
]
}
""";
Assert.Equal(expected, manifest.ToString());
}
// ---- Publish: Dockerfile generation -------------------------------------
[Fact]
public async Task VerifyPublish_GeneratesDockerfile_WithGoVersionFromGoMod()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.23\n");
File.WriteAllText(Path.Combine(sourceDir.FullName, "main.go"), "package main\nfunc main() {}");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName);
builder.Build().Run();
var dockerfilePath = Path.Combine(outputDir.FullName, "api.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated in publish mode");
var content = await File.ReadAllTextAsync(dockerfilePath);
await Verify(content);
}
[Fact]
public async Task VerifyPublish_UsesDefaultGoVersion_WhenGoModAbsent()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName);
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
[Fact]
public async Task VerifyPublish_PropagatesBuildFlagsToDockerfile()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.22\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName,
buildTags: ["netgo", "osusergo"],
ldFlags: "-X main.version=1.0.0",
raceDetector: true);
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
[Fact]
public async Task VerifyPublish_ShellQuote_HandlesEmbeddedSingleQuotes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
// ldFlags contains an embedded single quote (e.g. a message string).
// ShellQuote must escape it using the POSIX '\'' technique so the
// generated Dockerfile RUN command is valid shell.
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName, ldFlags: "-X main.msg=it's alive");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
[Fact]
public void VerifyPublish_SkipsDockerfileGeneration_WhenDockerfileExists()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
// Pre-existing Dockerfile — generator should leave it alone
File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
var app = builder.AddGoApp("api", sourceDir.FullName);
Assert.False(app.Resource.TryGetLastAnnotation<DockerfileBuilderCallbackAnnotation>(out _),
"No DockerfileBuilderCallbackAnnotation should be added when a Dockerfile already exists");
}
[Fact]
public async Task VerifyPublish_RespectsDockerfileBaseImageAnnotation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.22\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName)
.WithDockerfileBaseImage(buildImage: "golang:1.22-bookworm", runtimeImage: "debian:bookworm-slim");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
// ---- Publish: private module authentication --------------------------------
[Fact]
public async Task VerifyPublish_WithGoPrivate_GeneratesNetrcAndGoprivate()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName)
.WithGoPrivate(["github.com/myorg"], "github.com", usernameArgName: "GIT_USER", tokenSecretId: "gittoken");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
[Fact]
public async Task VerifyPublish_WithGoPrivate_CustomTokenSecretId()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName)
.WithGoPrivate(["gitlab.mycompany.com"], "gitlab.mycompany.com", tokenSecretId: "gl_token");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
// ---- Container files (IContainerFilesDestinationResource) ---------------
[Fact]
public void GoAppResource_ImplementsIContainerFilesDestinationResource()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory);
Assert.IsType<GoAppResource>(app.Resource, exactMatch: false);
Assert.True(app.Resource is IContainerFilesDestinationResource);
}
[Fact]
public void PublishWithContainerFiles_AddsAnnotationToGoResource()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputDir = workspace.CreateDirectory("output");
// PublishWithContainerFiles only adds the annotation in publish mode.
using var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
var source = builder.AddResource(new GoFilesContainer("frontend", "node", "."))
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" });
var api = builder.AddGoApp("api", AppContext.BaseDirectory);
api.PublishWithContainerFiles(source, "/app/static");
Assert.True(
api.Resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var annotations),
"ContainerFilesDestinationAnnotation should be present after PublishWithContainerFiles");
var annotation = Assert.Single(annotations);
Assert.Same(source.Resource, annotation.Source);
Assert.Equal("/app/static", annotation.DestinationPath);
}
[Fact]
public async Task VerifyPublish_ContainerFiles_GeneratesFromAndCopyInstructions()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
// A container resource that exposes static files (e.g. a built frontend).
var frontend = builder.AddResource(new GoFilesContainer("frontend", "node", "."))
.PublishAsDockerFile(c =>
c.WithDockerfileBuilder(".", ctx => ctx.Builder.From("scratch"))
.WithImageTag("deterministic-tag"))
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" });
var api = builder.AddGoApp("api", sourceDir.FullName);
api.PublishWithContainerFiles(frontend, "/app/static");
builder.Build().Run();
var dockerfile = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
// The builder stage ARG + FROM should reference the frontend image.
Assert.Contains("frontend", dockerfile);
// The runtime stage should COPY the static files from the frontend stage.
Assert.Contains("COPY --from=", dockerfile);
Assert.Contains("/app/dist", dockerfile);
Assert.Contains("/app/static", dockerfile);
}
[Fact]
public async Task VerifyPublish_ContainerFiles_MultipleSourcesAllPresent()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
var frontend = builder.AddResource(new GoFilesContainer("frontend", "node", "."))
.PublishAsDockerFile(c =>
c.WithDockerfileBuilder(".", ctx => ctx.Builder.From("scratch"))
.WithImageTag("frontend-tag"))
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" });
var assets = builder.AddResource(new GoFilesContainer("assets", "node", "."))
.PublishAsDockerFile(c =>
c.WithDockerfileBuilder(".", ctx => ctx.Builder.From("scratch"))
.WithImageTag("assets-tag"))
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/public" });
var api = builder.AddGoApp("api", sourceDir.FullName);
api.PublishWithContainerFiles(frontend, "/app/static");
api.PublishWithContainerFiles(assets, "/app/public");
builder.Build().Run();
var dockerfile = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
// Both sources should have a FROM stage and COPY instruction.
Assert.Contains("frontend", dockerfile);
Assert.Contains("assets", dockerfile);
Assert.Contains("/app/dist", dockerfile);
Assert.Contains("/app/public", dockerfile);
}
// Minimal resource that implements IResourceWithContainerFiles so tests can
// call PublishWithContainerFiles without depending on a real container integration.
// ---- Issue 1: required command annotations ------------------------------
[Fact]
public void AddGoApp_HasRequiredCommandAnnotationForGo()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory);
Assert.True(
app.Resource.TryGetAnnotationsOfType<RequiredCommandAnnotation>(out var annotations),
"GoAppResource should have at least one RequiredCommandAnnotation");
Assert.Contains(annotations, a => a.Command == "go");
}
[Fact]
public void WithDelveServer_AddsRequiredCommandAnnotationForDlv()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var app = builder.AddGoApp("api", AppContext.BaseDirectory).WithDelveServer();
Assert.True(
app.Resource.TryGetAnnotationsOfType<RequiredCommandAnnotation>(out var annotations));
Assert.Contains(annotations, a => a.Command == "dlv");
}
// ---- Issue 3: race detector not in Dockerfile ---------------------------
[Fact]
public async Task VerifyPublish_RaceDetector_NotPropagatedToDockerfile()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName, raceDetector: true);
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
// ---- Issue 4: mod tool ordering -----------------------------------------
[Fact]
public void WithModTidy_ThenWithModVendor_VendorWaitsForTidy()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
builder.AddGoApp("api", AppContext.BaseDirectory)
.WithModTidy()
.WithModVendor();
var tidyResource = builder.Resources.First(r => r.Name == "api-mod-tidy");
var vendorResource = builder.Resources.First(r => r.Name == "api-mod-vendor");
// The vendor sibling must carry a WaitAnnotation pointing to tidy.
Assert.True(
vendorResource.TryGetAnnotationsOfType<WaitAnnotation>(out var waitAnnotations),
"vendor sibling should have a WaitAnnotation");
Assert.Contains(waitAnnotations, w => w.Resource == tidyResource);
}
[Fact]
public void WithModTidy_ThenWithModDownload_DownloadWaitsForTidy()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
builder.AddGoApp("api", AppContext.BaseDirectory)
.WithModTidy()
.WithModDownload();
var tidyResource = builder.Resources.First(r => r.Name == "api-mod-tidy");
var downloadResource = builder.Resources.First(r => r.Name == "api-mod-download");
Assert.True(
downloadResource.TryGetAnnotationsOfType<WaitAnnotation>(out var waitAnnotations));
Assert.Contains(waitAnnotations, w => w.Resource == tidyResource);
}
[Fact]
public void WithModTidy_ThenWithModVendor_ThenWithModDownload_DownloadWaitsForVendor()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
builder.AddGoApp("api", AppContext.BaseDirectory)
.WithModTidy()
.WithModVendor()
.WithModDownload();
var vendorResource = builder.Resources.First(r => r.Name == "api-mod-vendor");
var downloadResource = builder.Resources.First(r => r.Name == "api-mod-download");
Assert.True(
downloadResource.TryGetAnnotationsOfType<WaitAnnotation>(out var waitAnnotations));
Assert.Contains(waitAnnotations, w => w.Resource == vendorResource);
}
// ---- Issue 5: non-root user in Dockerfile --------------------------------
[Fact]
public async Task VerifyPublish_RuntimeStage_HasNonRootUser_Alpine()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName);
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
[Fact]
public async Task VerifyPublish_RuntimeStage_HasNonRootUser_NonAlpine()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var sourceDir = workspace.CreateDirectory("source");
var outputDir = workspace.CreateDirectory("output");
File.WriteAllText(Path.Combine(sourceDir.FullName, "go.mod"), "module example.com/api\n\ngo 1.24\n");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest");
builder.AddGoApp("api", sourceDir.FullName)
.WithDockerfileBaseImage(runtimeImage: "debian:bookworm-slim");
builder.Build().Run();
var content = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"));
await Verify(content);
}
private static async Task<GoLaunchConfiguration> CreateLaunchConfigurationAsync(IResource resource)
{
var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
return Assert.IsType<GoLaunchConfiguration>(
await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext));
}
private sealed class GoFilesContainer(string name, string command, string workingDirectory)
: ExecutableResource(name, command, workingDirectory), IResourceWithContainerFiles;
}