Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ public static IResourceBuilder<NodeAppResource> AddNodeApp(this IDistributedAppl

if (resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(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<JavaScriptInstallCommandAnnotation>(out var installCommand))
{
Expand Down Expand Up @@ -334,6 +339,10 @@ private static IResourceBuilder<TResource> CreateDefaultJavaScriptAppBuilder<TRe
.From(baseImage)
.WorkDir("/app");

// Initialize the Docker build stage with package manager-specific setup commands
// for the default JavaScript app builder (used by Vite and other build-less apps).
packageManager.InitializeDockerBuildStage?.Invoke(dockerBuilder);

var copiedAllSource = false;

// Copy package files first for better layer caching
Expand Down Expand Up @@ -588,7 +597,9 @@ public static IResourceBuilder<TResource> WithPnpm<TResource>(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]));

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -38,4 +40,10 @@ public sealed class JavaScriptPackageManagerAnnotation(string executableName, st
/// Gets the file patterns for package dependency files.
/// </summary>
public List<CopyFilePattern> PackageFilesPatterns { get; } = [];

/// <summary>
/// Gets or sets a callback to initialize the Docker build stage before installing packages.
/// </summary>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public Action<DockerfileStage>? InitializeDockerBuildStage { get; init; }
}
94 changes: 94 additions & 0 deletions tests/Aspire.Hosting.JavaScript.Tests/AddJavaScriptAppTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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}");
}
}
Original file line number Diff line number Diff line change
@@ -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 . .
Expand Down
Original file line number Diff line number Diff line change
@@ -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 . .
Expand Down
Loading