Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,37 +1,16 @@
var builder = DistributedApplication.CreateBuilder(args);

builder.AddAzureContainerAppEnvironment("env");

var weatherApi = builder.AddProject<Projects.AspireJavaScript_MinimalApi>("weatherapi")
.WithExternalHttpEndpoints();

builder.AddNpmApp("angular", "../AspireJavaScript.Angular")
.WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();

builder.AddNpmApp("react", "../AspireJavaScript.React")
.WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithEnvironment("BROWSER", "none") // Disable opening browser on npm start
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();

builder.AddNpmApp("vue", "../AspireJavaScript.Vue")
.WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();

builder.AddViteApp("reactvite", "../AspireJavaScript.Vite")
var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite")
.WithNpmPackageManager()
.WithReference(weatherApi)
.WithEnvironment("BROWSER", "none")
.WithExternalHttpEndpoints();

weatherApi.PublishWithContainerFiles(reactvite, "./wwwroot");

builder.Build().Run();
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ItemGroup>
<AspireProjectOrPackageReference Include="Aspire.Hosting.AppHost" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.NodeJs" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.Azure.AppContainers" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
app.MapGet("/api/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
Expand All @@ -45,6 +45,9 @@
.WithName("GetWeatherForecast")
.WithOpenApi();

app.UseDefaultFiles();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: UseFileServer combines these

app.UseStaticFiles();

app.Run();

sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export default defineConfig(({ mode }) => {
'/api': {
target: process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
}
}
Expand Down
181 changes: 181 additions & 0 deletions src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable ASPIREEXTENSION001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPUBLISHERS001
#pragma warning disable ASPIREDOCKERFILEBUILDER001
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting;

Expand Down Expand Up @@ -707,6 +715,28 @@ EndpointAnnotation GetOrCreateEndpointForScheme(string scheme)
httpEndpoint.TargetPort = httpsEndpoint.TargetPort = defaultEndpointTargetPort;
}

// Add pipeline step factory to handle ContainerFilesDestinationAnnotation
builder.WithPipelineStepFactory(factoryContext =>
{
List<PipelineStep> steps = [];
var buildStep = CreateProjectBuildImageStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource);
steps.Add(buildStep);

// Ensure any static file references' images are built first
if (factoryContext.Resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
foreach (var containerFile in containerFilesAnnotations)
{
var source = containerFile.Source;
var staticFileBuildStep = CreateBuildImageStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source);
buildStep.DependsOn(staticFileBuildStep);
steps.Add(staticFileBuildStep);
}
}

return steps;
});

return builder;
}

Expand Down Expand Up @@ -1042,4 +1072,155 @@ private sealed class ProjectContainerResource(ProjectResource pr) : ContainerRes
{
public override ResourceAnnotationCollection Annotations => pr.Annotations;
}

private static PipelineStep CreateBuildImageStep(string stepName, IResource resource) =>
new()
{
Name = stepName,
Action = async ctx =>
{
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
await containerImageBuilder.BuildImageAsync(
resource,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
ctx.CancellationToken).ConfigureAwait(false);
},
Tags = [WellKnownPipelineTags.BuildCompute]
};

private static PipelineStep CreateProjectBuildImageStep(string stepName, IResource resource) =>
new()
{
Name = stepName,
Action = async ctx =>
{
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
var logger = ctx.Services.GetRequiredService<ILoggerFactory>().CreateLogger(typeof(ProjectResourceBuilderExtensions));

// Check if we need to copy container files
var hasContainerFiles = resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations);

if (!hasContainerFiles)
{
// No container files to copy, just build the image normally
await containerImageBuilder.BuildImageAsync(
resource,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
ctx.CancellationToken).ConfigureAwait(false);
return;
}

// Build the container image for the project first
await containerImageBuilder.BuildImageAsync(
resource,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
ctx.CancellationToken).ConfigureAwait(false);

// Get the built image name
var originalImageName = resource.Name.ToLowerInvariant();

// Tag the built image with a temporary tag
var tempTag = $"temp-{Guid.NewGuid():N}";
var tempImageName = $"{originalImageName}:{tempTag}";

var dcpOptions = ctx.Services.GetRequiredService<IOptions<DcpOptions>>();
var containerRuntime = dcpOptions.Value.ContainerRuntime switch
{
string rt => ctx.Services.GetRequiredKeyedService<IContainerRuntime>(rt),
null => ctx.Services.GetRequiredKeyedService<IContainerRuntime>("docker")
};

logger.LogInformation("Tagging image {OriginalImageName} as {TempImageName}", originalImageName, tempImageName);
await containerRuntime.TagImageAsync(originalImageName, tempImageName, ctx.CancellationToken).ConfigureAwait(false);

// Generate a Dockerfile that layers the container files on top
var dockerfileBuilder = new DockerfileBuilder();
var stage = dockerfileBuilder.From(tempImageName);

// Add COPY --from: statements for each source
foreach (var containerFileDestination in containerFilesAnnotations!)
{
var source = containerFileDestination.Source;

if (!source.TryGetContainerImageName(out var sourceImageName))
{
logger.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name);
continue;
}

var destinationPath = containerFileDestination.DestinationPath;
if (!destinationPath.StartsWith('/'))
{
// Make it an absolute path relative to /app (typical .NET container working directory)
destinationPath = $"/app/{destinationPath}";
}

foreach (var containerFilesSource in source.Annotations.OfType<ContainerFilesSourceAnnotation>())
{
logger.LogInformation("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}",
sourceImageName, containerFilesSource.SourcePath, destinationPath);
stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath);
}
}

// Write the Dockerfile to a temporary location
var projectResource = (ProjectResource)resource;
var projectMetadata = projectResource.GetProjectMetadata();
var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!;
var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}");

try
{
using (var writer = new StreamWriter(tempDockerfilePath))
{
await dockerfileBuilder.WriteAsync(writer, ctx.CancellationToken).ConfigureAwait(false);
}

logger.LogDebug("Generated temporary Dockerfile at {DockerfilePath}", tempDockerfilePath);

// Build the final image from the generated Dockerfile
await containerRuntime.BuildImageAsync(
projectDir,
tempDockerfilePath,
originalImageName,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
new Dictionary<string, string?>(),
new Dictionary<string, string?>(),
null,
ctx.CancellationToken).ConfigureAwait(false);

logger.LogInformation("Successfully built final image {ImageName} with container files", originalImageName);
}
finally
{
// Clean up the temporary Dockerfile
if (File.Exists(tempDockerfilePath))
{
try
{
File.Delete(tempDockerfilePath);
logger.LogDebug("Deleted temporary Dockerfile {DockerfilePath}", tempDockerfilePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete temporary Dockerfile {DockerfilePath}", tempDockerfilePath);
}
}
}
},
Tags = [WellKnownPipelineTags.BuildCompute]
};

}
38 changes: 38 additions & 0 deletions tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable CS0618 // Type or member is obsolete
#pragma warning disable ASPIREPIPELINES001

using System.Text;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Tests.Utils;
Expand Down Expand Up @@ -895,4 +897,40 @@ public TestProjectWithExecutableProfile()
};
}
}

[Fact]
public void ProjectResourceWithContainerFilesDestinationAnnotationCreatesPipelineSteps()
{
var appBuilder = CreateBuilder();

// Create a test container resource that implements IResourceWithContainerFiles
var sourceContainerResource = new TestContainerFilesResource("source");
var sourceContainer = appBuilder.AddResource(sourceContainerResource)
.WithImage("myimage")
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" });

// Add a project and annotate it with ContainerFilesDestinationAnnotation
appBuilder.AddProject<TestProject>("projectName", launchProfileName: null)
.PublishWithContainerFiles(sourceContainer, "./static");

using var app = appBuilder.Build();

var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var projectResources = appModel.GetProjectResources();

var resource = Assert.Single(projectResources);

// Verify the ContainerFilesDestinationAnnotation was added
var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType<ContainerFilesDestinationAnnotation>());
Assert.Equal(sourceContainer.Resource, containerFilesAnnotation.Source);
Assert.Equal("./static", containerFilesAnnotation.DestinationPath);

// Verify the PipelineStepAnnotation was added by WithProjectDefaults
var pipelineStepAnnotations = resource.Annotations.OfType<PipelineStepAnnotation>().ToList();
Assert.NotEmpty(pipelineStepAnnotations);
}

private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles
{
}
}