diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 73caa78d9a2..2025c941e1e 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -109,6 +109,11 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl if (resource.TryGetLastAnnotation(out var packageManager)) { + // Initialize the Docker build stage with package manager-specific setup commands. + // This allows package managers to add prerequisite commands (e.g., enabling pnpm via corepack) + // before package installation and build steps. + packageManager.InitializeDockerBuildStage?.Invoke(builderStage); + var copiedAllSource = false; if (resource.TryGetLastAnnotation(out var installCommand)) { @@ -334,6 +339,10 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder WithPnpm(this IResourceBuil { PackageFilesPatterns = { new CopyFilePattern(packageFilesSourcePattern, "./") }, // pnpm does not strip the -- separator and passes it to the script, causing Vite to ignore subsequent arguments. - CommandSeparator = null + CommandSeparator = null, + // pnpm is not included in the Node.js Docker image by default, so we need to enable it via corepack + InitializeDockerBuildStage = stage => stage.Run("corepack enable pnpm") }) .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs index 6e08e29f013..420447e4c76 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptPackageManagerAnnotation.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; namespace Aspire.Hosting.JavaScript; @@ -38,4 +40,10 @@ public sealed class JavaScriptPackageManagerAnnotation(string executableName, st /// Gets the file patterns for package dependency files. /// public List PackageFilesPatterns { get; } = []; + + /// + /// Gets or sets a callback to initialize the Docker build stage before installing packages. + /// + [Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public Action? InitializeDockerBuildStage { get; init; } } diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs index a7a841b085b..6afab2d5692 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; +using Aspire.TestUtilities; namespace Aspire.Hosting.JavaScript.Tests; @@ -72,4 +74,96 @@ public async Task VerifyPnpmDockerfile(bool hasLockFile) await Verify(dockerfileContents); } + + [Fact] + [RequiresDocker] + [OuterloopTest("Builds a Docker image to verify the generated pnpm Dockerfile works")] + public async Task VerifyPnpmDockerfileBuildSucceeds() + { + using var tempDir = new TempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true); + + // Create app directory + var appDir = Path.Combine(tempDir.Path, "pnpm-app"); + Directory.CreateDirectory(appDir); + + // Create a minimal package.json with no dependencies + var packageJson = """ + { + "name": "pnpm-test-app", + "version": "1.0.0", + "scripts": { + "build": "echo 'build completed'" + } + } + """; + await File.WriteAllTextAsync(Path.Combine(appDir, "package.json"), packageJson); + + var pnpmApp = builder.AddJavaScriptApp("pnpm-app", appDir) + .WithPnpm() + .WithBuildScript("build"); + + await ManifestUtils.GetManifest(pnpmApp.Resource, tempDir.Path); + + var dockerfilePath = Path.Combine(tempDir.Path, "pnpm-app.Dockerfile"); + Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}"); + + // Read the generated Dockerfile and verify it contains the corepack enable pnpm command + var dockerfileContent = await File.ReadAllTextAsync(dockerfilePath); + Assert.Contains("corepack enable pnpm", dockerfileContent); + + // Modify the Dockerfile to add NODE_TLS_REJECT_UNAUTHORIZED=0 for test environments + // that may have corporate proxies with self-signed certificates + var modifiedDockerfile = dockerfileContent.Replace( + "WORKDIR /app", + "WORKDIR /app\nENV NODE_TLS_REJECT_UNAUTHORIZED=0"); + var dockerfileInContext = Path.Combine(appDir, "Dockerfile"); + await File.WriteAllTextAsync(dockerfileInContext, modifiedDockerfile); + + // Build the Docker image using docker build with host network for registry access + var imageName = $"aspire-pnpm-test-{Guid.NewGuid():N}"; + var processStartInfo = new ProcessStartInfo + { + FileName = "docker", + Arguments = $"build --network=host -t {imageName} -f Dockerfile .", + WorkingDirectory = appDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processStartInfo); + Assert.NotNull(process); + + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + // Clean up the image regardless of success/failure + try + { + using var cleanupProcess = Process.Start(new ProcessStartInfo + { + FileName = "docker", + Arguments = $"rmi {imageName}", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + if (cleanupProcess != null) + { + await cleanupProcess.WaitForExitAsync(); + } + } + catch + { + // Ignore cleanup errors + } + + // Assert the build succeeded + Assert.True(process.ExitCode == 0, $"Docker build failed with exit code {process.ExitCode}.\nStdout: {stdout}\nStderr: {stderr}"); + } } diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=False.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=False.verified.txt index c2a887080ff..b31c97cb6dd 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=False.verified.txt +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=False.verified.txt @@ -1,5 +1,6 @@ FROM node:22-slim WORKDIR /app +RUN corepack enable pnpm COPY package.json ./ RUN --mount=type=cache,target=/pnpm/store pnpm install --prefer-frozen-lockfile COPY . . diff --git a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=True.verified.txt b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=True.verified.txt index ed4b8c1f693..a21b6a1d840 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=True.verified.txt +++ b/tests/Aspire.Hosting.JavaScript.Tests/Snapshots/AddJavaScriptAppTests.VerifyPnpmDockerfile_hasLockFile=True.verified.txt @@ -1,5 +1,6 @@ FROM node:22-slim WORKDIR /app +RUN corepack enable pnpm COPY package.json pnpm-lock.yaml ./ RUN --mount=type=cache,target=/pnpm/store pnpm install --prefer-frozen-lockfile COPY . .