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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ server {
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ module.exports = {
"/api": {
target:
process.env["WEATHERAPI_HTTPS"] || process.env["WEATHERAPI_HTTP"],
secure: process.env["NODE_ENV"] !== "development",
pathRewrite: {
"^/api": "",
},
secure: process.env["NODE_ENV"] !== "development"
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
.WithExternalHttpEndpoints()
.PublishAsDockerFile();

builder.AddViteApp("reactvite", "../AspireJavaScript.Vite")
var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite")
.WithNpm(install: true)
.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 @@ -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,8 @@
.WithName("GetWeatherForecast")
.WithOpenApi();

app.UseFileServer();

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 @@ -15,6 +15,5 @@ server {
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ module.exports = (env) => {
context: ["/api"],
target:
process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP,
pathRewrite: { "^/api": "" },
secure: false,
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ server {
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ server {
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export default defineConfig({
'/api': {
target: process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP,
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
secure: false
}
}
Expand Down
211 changes: 200 additions & 11 deletions src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.ApplicationModel;

Expand All @@ -34,24 +37,28 @@ public ProjectResource(string name) : base(name)
var buildStep = new PipelineStep
{
Name = $"build-{name}",
Action = async ctx =>
{
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
await containerImageBuilder.BuildImageAsync(
this,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
ctx.CancellationToken).ConfigureAwait(false);
},
Action = BuildProjectImage,
Tags = [WellKnownPipelineTags.BuildCompute],
RequiredBySteps = [WellKnownPipelineSteps.Build],
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq]
};

return [buildStep];
}));

Annotations.Add(new PipelineConfigurationAnnotation(context =>
{
// Ensure any static file references' images are built first
if (this.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(this, WellKnownPipelineTags.BuildCompute);

foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
}));
}
// Keep track of the config host for each Kestrel endpoint annotation
internal Dictionary<EndpointAnnotation, string> KestrelEndpointAnnotationHosts { get; } = new();
Expand All @@ -77,4 +84,186 @@ internal bool ShouldInjectEndpointEnvironment(EndpointReference e)
.Select(a => a.Filter)
.Any(f => !f(endpoint));
}

private async Task BuildProjectImage(PipelineStepContext ctx)
{
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
var logger = ctx.Logger;

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

// Check if we need to copy container files
if (!this.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
// No container files to copy, just build the image normally
return;
}

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

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

var containerRuntime = ctx.Services.GetRequiredService<IContainerRuntime>();

logger.LogDebug("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);

var projectMetadata = this.GetProjectMetadata();

// Get the container working directory for the project
var containerWorkingDir = await GetContainerWorkingDirectoryAsync(projectMetadata.ProjectPath, logger, ctx.CancellationToken).ConfigureAwait(false);

// 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 the container working directory
destinationPath = $"{containerWorkingDir}/{destinationPath}";
}

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

// Write the Dockerfile to a temporary location
var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!;
var tempDockerfilePath = Path.GetTempFileName();

var builtSuccessfully = false;
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
},
[],
[],
null,
ctx.CancellationToken).ConfigureAwait(false);

logger.LogDebug("Successfully built final image {ImageName} with container files", originalImageName);
builtSuccessfully = true;
}
finally
{
if (builtSuccessfully)
{
// 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);
}
}
}
else
{
// Keep the Dockerfile for debugging purposes
logger.LogDebug("Failed build - temporary Dockerfile left at {DockerfilePath} for debugging", tempDockerfilePath);
}

// Remove the temporary tagged image
logger.LogDebug("Removing temporary image {TempImageName}", tempImageName);
await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false);
}
}

private static async Task<string> GetContainerWorkingDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken)
{
try
{
var outputLines = new List<string>();
var spec = new Dcp.Process.ProcessSpec("dotnet")
{
Arguments = $"msbuild -getProperty:ContainerWorkingDirectory \"{projectPath}\"",
OnOutputData = output =>
{
if (!string.IsNullOrWhiteSpace(output))
{
outputLines.Add(output.Trim());
}
},
OnErrorData = error => logger.LogDebug("dotnet msbuild (stderr): {Error}", error),
ThrowOnNonZeroReturnCode = false
};

logger.LogDebug("Getting ContainerWorkingDirectory for project {ProjectPath}", projectPath);
var (pendingResult, processDisposable) = Dcp.Process.ProcessUtil.Run(spec);

await using (processDisposable.ConfigureAwait(false))
{
var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false);

if (result.ExitCode != 0)
{
logger.LogDebug("Failed to get ContainerWorkingDirectory from dotnet msbuild for project {ProjectPath}. Exit code: {ExitCode}. Using default /app",
projectPath, result.ExitCode);
return "/app";
}

// The last non-empty line should contain the ContainerWorkingDirectory value
var workingDir = outputLines.LastOrDefault();

if (string.IsNullOrWhiteSpace(workingDir))
{
logger.LogDebug("dotnet msbuild returned empty ContainerWorkingDirectory for project {ProjectPath}. Using default /app", projectPath);
return "/app";
}

logger.LogDebug("Resolved ContainerWorkingDirectory for project {ProjectPath}: {WorkingDir}", projectPath, workingDir);
return workingDir;
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error getting ContainerWorkingDirectory. Using default /app");
return "/app";
}
}
}
11 changes: 10 additions & 1 deletion src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
using Aspire.Hosting.Cli;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Devcontainers;
using Aspire.Hosting.Devcontainers.Codespaces;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Exec;
using Aspire.Hosting.Health;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Orchestrator;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.VersionChecking;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -450,6 +450,15 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
Eventing.Subscribe<BeforeStartEvent>(BuiltInDistributedApplicationEventSubscriptionHandlers.MutateHttp2TransportAsync);
_innerBuilder.Services.AddKeyedSingleton<IContainerRuntime, DockerContainerRuntime>("docker");
_innerBuilder.Services.AddKeyedSingleton<IContainerRuntime, PodmanContainerRuntime>("podman");
_innerBuilder.Services.AddSingleton(sp =>
{
var dcpOptions = sp.GetRequiredService<IOptions<DcpOptions>>();
return dcpOptions.Value.ContainerRuntime switch
{
string rt => sp.GetRequiredKeyedService<IContainerRuntime>(rt),
null => sp.GetRequiredKeyedService<IContainerRuntime>("docker")
};
});
_innerBuilder.Services.AddSingleton<IResourceContainerImageBuilder, ResourceContainerImageBuilder>();
_innerBuilder.Services.AddSingleton<PipelineActivityReporter>();
_innerBuilder.Services.AddSingleton<IPipelineActivityReporter, PipelineActivityReporter>(sp => sp.GetRequiredService<PipelineActivityReporter>());
Expand Down
Loading