From 532b11f4428340aba4951d7b1ebfac83ca421f5e Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 5 Nov 2025 04:58:15 +0000 Subject: [PATCH 1/5] Checkpoint from VS Code for coding agent session --- .../Publishing/ManifestPublishingContext.cs | 45 +++++++ src/Schema/aspire-8.0.json | 113 ++++++++++++++++++ .../AddViteAppTests.cs | 3 +- .../PublishAsDockerfileTests.cs | 6 +- 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 1b53f4c7183..a5de44cc7e4 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -196,6 +196,8 @@ private async Task WriteProjectAsync(ProjectResource project) await WriteDeploymentTarget(deploymentTarget).ConfigureAwait(false); } + WriteContainerFilesDestination(project); + await WriteCommandLineArgumentsAsync(project).ConfigureAwait(false); await WriteEnvironmentVariablesAsync(project).ConfigureAwait(false); @@ -214,6 +216,39 @@ private async Task WriteDeploymentTarget(DeploymentTargetAnnotation deploymentTa } } + private void WriteContainerFilesDestination(IResource resource) + { + if (!resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + return; + } + + Writer.WriteStartObject("containerFiles"); + + foreach (var containerFileDestination in containerFilesAnnotations) + { + var source = containerFileDestination.Source; + + Writer.WriteStartObject(source.Name); + Writer.WriteString("destination", containerFileDestination.DestinationPath); + + // Get source paths from the source resource + if (source.TryGetAnnotationsOfType(out var sourceAnnotations)) + { + Writer.WriteStartArray("sources"); + foreach (var sourceAnnotation in sourceAnnotations) + { + Writer.WriteStringValue(sourceAnnotation.SourcePath); + } + Writer.WriteEndArray(); + } + + Writer.WriteEndObject(); + } + + Writer.WriteEndObject(); + } + private async Task WriteExecutableAsync(ExecutableResource executable) { Writer.WriteString("type", "executable.v0"); @@ -227,6 +262,8 @@ private async Task WriteExecutableAsync(ExecutableResource executable) Writer.WriteString("command", executable.Command); + WriteContainerFilesDestination(executable); + await WriteCommandLineArgumentsAsync(executable).ConfigureAwait(false); await WriteEnvironmentVariablesAsync(executable).ConfigureAwait(false); @@ -316,6 +353,9 @@ public async Task WriteContainerAsync(ContainerResource container) // Write args if they are present await WriteCommandLineArgumentsAsync(container).ConfigureAwait(false); + // Write container files destination if present + WriteContainerFilesDestination(container); + // Write volume & bind mount details WriteContainerMounts(container); @@ -353,6 +393,11 @@ private async Task WriteBuildContextAsync(ContainerResource container) Writer.WriteString("stage", stage); } + if (!annotation.HasEntrypoint) + { + Writer.WriteBoolean("buildOnly", true); + } + if (annotation.BuildArguments.Count > 0) { Writer.WriteStartObject("args"); diff --git a/src/Schema/aspire-8.0.json b/src/Schema/aspire-8.0.json index 5a29741afb2..7004fdfe5c5 100644 --- a/src/Schema/aspire-8.0.json +++ b/src/Schema/aspire-8.0.json @@ -67,6 +67,27 @@ "connectionString": { "$ref": "#/definitions/connectionString" }, + "containerFiles": { + "type": "object", + "description": "Container files to be copied from other resources into this container's image.", + "additionalProperties": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "The destination path within this container where files will be copied." + }, + "sources": { + "type": "array", + "description": "The source paths within the source container to copy from.", + "items": { + "type": "string" + } + } + }, + "required": [ "destination" ] + } + }, "env": { "$ref": "#/definitions/env" }, @@ -125,6 +146,27 @@ "connectionString": { "$ref": "#/definitions/connectionString" }, + "containerFiles": { + "type": "object", + "description": "Container files to be copied from other resources into this container's image.", + "additionalProperties": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "The destination path within this container where files will be copied." + }, + "sources": { + "type": "array", + "description": "The source paths within the source container to copy from.", + "items": { + "type": "string" + } + } + }, + "required": [ "destination" ] + } + }, "env": { "$ref": "#/definitions/env" }, @@ -152,6 +194,27 @@ "type": "string", "description": "The path to the project file. Relative paths are interpreted as being relative to the location of the manifest file." }, + "containerFiles": { + "type": "object", + "description": "Container files to be copied from other resources into this project's container image.", + "additionalProperties": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "The destination path within this container where files will be copied." + }, + "sources": { + "type": "array", + "description": "The source paths within the source container to copy from.", + "items": { + "type": "string" + } + } + }, + "required": [ "destination" ] + } + }, "args": { "$ref": "#/definitions/args" }, @@ -186,6 +249,27 @@ } ] }, + "containerFiles": { + "type": "object", + "description": "Container files to be copied from other resources into this project's container image.", + "additionalProperties": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "The destination path within this container where files will be copied." + }, + "sources": { + "type": "array", + "description": "The source paths within the source container to copy from.", + "items": { + "type": "string" + } + } + }, + "required": [ "destination" ] + } + }, "args": { "$ref": "#/definitions/args" }, @@ -214,6 +298,27 @@ "type": "string", "description": "The path to the command. Should be interpreted as being relative to the AppHost directory." }, + "containerFiles": { + "type": "object", + "description": "Container files to be copied from other resources into this executable's container image.", + "additionalProperties": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "The destination path within this container where files will be copied." + }, + "sources": { + "type": "array", + "description": "The source paths within the source container to copy from.", + "items": { + "type": "string" + } + } + }, + "required": [ "destination" ] + } + }, "args": { "$ref": "#/definitions/args" }, @@ -592,6 +697,14 @@ "type": "string", "description": "The path to the Dockerfile. Can be relative or absolute. If relative it is relative to the manifest file." }, + "stage": { + "type": "string", + "description": "The name of the build stage to use for multi-stage Dockerfiles." + }, + "buildOnly": { + "type": "boolean", + "description": "Indicates whether this container is built only to provide files for other containers and should not be deployed as a running service." + }, "args": { "type": "object", "description": "A list of build arguments which are used during container build.", diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs index 90dafcb5567..312bb9785de 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddViteAppTests.cs @@ -33,7 +33,8 @@ public async Task VerifyDefaultDockerfile() "type": "container.v1", "build": { "context": "vite", - "dockerfile": "vite.Dockerfile" + "dockerfile": "vite.Dockerfile", + "buildOnly": true }, "env": { "NODE_ENV": "production", diff --git a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs index fe97b38e8d2..b9ae7663636 100644 --- a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs @@ -33,7 +33,8 @@ public async Task PublishAsDockerFileConfiguresManifestWithoutBuildArgs() "type": "container.v1", "build": { "context": ".", - "dockerfile": "Dockerfile" + "dockerfile": "Dockerfile", + "buildOnly": true }, "env": { "NODE_ENV": "{{builder.Environment.EnvironmentName.ToLowerInvariant()}}" @@ -80,6 +81,7 @@ public async Task PublishAsDockerFileConfiguresManifestWithBuildArgs() "build": { "context": ".", "dockerfile": "Dockerfile", + "buildOnly": true, "args": { "SOME_STRING": "Test", "SOME_BOOL": "true", @@ -129,6 +131,7 @@ public async Task PublishAsDockerFileConfiguresManifestWithBuildArgsThatHaveNoVa "build": { "context": ".", "dockerfile": "Dockerfile", + "buildOnly": true, "args": { "SOME_ARG": null } @@ -178,6 +181,7 @@ public async Task PublishAsDockerFileConfigureContainer() "build": { "context": ".", "dockerfile": "Dockerfile", + "buildOnly": true, "secrets": { "buildSecret": { "type": "env", From 15034704f849c6e439c68c2b1ee09e517bbd8229 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 05:26:04 +0000 Subject: [PATCH 2/5] Fix buildOnly field not being written for JavaScript apps The issue was that JavaScript apps using PublishAsDockerFile() were not setting HasEntrypoint=false on the correct annotation. The fix required setting it after WithDockerfileBuilder() since that method creates a new DockerfileBuildAnnotation that replaces the original one. Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../JavaScriptHostingExtensions.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 319d1c255fe..e1cc54e3ab0 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -247,6 +247,11 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var dockerFileAnnotation)) + { + dockerFileAnnotation.HasEntrypoint = false; + } return; } @@ -284,13 +289,14 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var dockerFileAnnotation)) + // This must be set AFTER WithDockerfileBuilder because WithDockerfileBuilder creates a new annotation + if (c.Resource.TryGetLastAnnotation(out var dockerFileAnnotation2)) { - dockerFileAnnotation.HasEntrypoint = false; + dockerFileAnnotation2.HasEntrypoint = false; } else { - throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile."); + throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling WithDockerfileBuilder."); } }) .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }) From b5c2bcef48c13157037a795e05a942c12974a6a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 06:32:22 +0000 Subject: [PATCH 3/5] Revert changes to JavaScriptHostingExtensions.cs As requested in code review, reverting changes to JavaScriptHostingExtensions.cs. The manifest publisher should describe current API behavior without modifying the APIs. Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../JavaScriptHostingExtensions.cs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index e1cc54e3ab0..319d1c255fe 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -247,11 +247,6 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var dockerFileAnnotation)) - { - dockerFileAnnotation.HasEntrypoint = false; - } return; } @@ -289,14 +284,13 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var dockerFileAnnotation2)) + if (resource.TryGetLastAnnotation(out var dockerFileAnnotation)) { - dockerFileAnnotation2.HasEntrypoint = false; + dockerFileAnnotation.HasEntrypoint = false; } else { - throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling WithDockerfileBuilder."); + throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile."); } }) .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }) From 71eba94188ca10d1d082375b7c20faef2805a6e1 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 5 Nov 2025 07:49:09 +0000 Subject: [PATCH 4/5] Remove buildOnly expectations from tests - field only appears when true --- tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs index b9ae7663636..fe97b38e8d2 100644 --- a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs @@ -33,8 +33,7 @@ public async Task PublishAsDockerFileConfiguresManifestWithoutBuildArgs() "type": "container.v1", "build": { "context": ".", - "dockerfile": "Dockerfile", - "buildOnly": true + "dockerfile": "Dockerfile" }, "env": { "NODE_ENV": "{{builder.Environment.EnvironmentName.ToLowerInvariant()}}" @@ -81,7 +80,6 @@ public async Task PublishAsDockerFileConfiguresManifestWithBuildArgs() "build": { "context": ".", "dockerfile": "Dockerfile", - "buildOnly": true, "args": { "SOME_STRING": "Test", "SOME_BOOL": "true", @@ -131,7 +129,6 @@ public async Task PublishAsDockerFileConfiguresManifestWithBuildArgsThatHaveNoVa "build": { "context": ".", "dockerfile": "Dockerfile", - "buildOnly": true, "args": { "SOME_ARG": null } @@ -181,7 +178,6 @@ public async Task PublishAsDockerFileConfigureContainer() "build": { "context": ".", "dockerfile": "Dockerfile", - "buildOnly": true, "secrets": { "buildSecret": { "type": "env", From 7fd4cdab8e909928c94ad116b3425ab572d91766 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:11:27 +0000 Subject: [PATCH 5/5] Add tests for containerFiles manifest serialization Added three comprehensive tests for the containerFiles feature: - Single source with single destination - Single source with multiple source paths - Multiple sources with multiple destinations All tests verify that containerFiles are correctly serialized to the manifest JSON. Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- .../ManifestGenerationTests.cs | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 05d2ac54984..dc2a47e95af 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -553,6 +553,148 @@ public async Task ParameterInputDefaultValuesGenerateCorrectly() Assert.Equal(expectedManifest, manifest.ToString()); } + [Fact] + public async Task ContainerFilesAreWrittenToManifest() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + Args = GetManifestArgs() + }); + + // Create a source container with ContainerFilesSourceAnnotation + var sourceContainer = builder.AddContainer("source", "node:22") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); + + // Create a destination container with ContainerFilesDestinationAnnotation + var destContainer = builder.AddContainer("dest", "nginx:alpine") + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = sourceContainer.Resource, + DestinationPath = "/usr/share/nginx/html" + }); + + builder.Build().Run(); + + var destManifest = await ManifestUtils.GetManifest(destContainer.Resource).DefaultTimeout(); + + var expectedManifest = """ + { + "type": "container.v0", + "image": "nginx:alpine", + "containerFiles": { + "source": { + "destination": "/usr/share/nginx/html", + "sources": [ + "/app/dist" + ] + } + } + } + """; + + Assert.Equal(expectedManifest, destManifest.ToString()); + } + + [Fact] + public async Task ContainerFilesWithMultipleSourcesAreWrittenToManifest() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + Args = GetManifestArgs() + }); + + // Create a source container with multiple ContainerFilesSourceAnnotations + var sourceContainer = builder.AddContainer("source", "node:22") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }) + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/assets" }); + + // Create a destination container with ContainerFilesDestinationAnnotation + var destContainer = builder.AddContainer("dest", "nginx:alpine") + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = sourceContainer.Resource, + DestinationPath = "/usr/share/nginx/html" + }); + + builder.Build().Run(); + + var destManifest = await ManifestUtils.GetManifest(destContainer.Resource).DefaultTimeout(); + + var expectedManifest = """ + { + "type": "container.v0", + "image": "nginx:alpine", + "containerFiles": { + "source": { + "destination": "/usr/share/nginx/html", + "sources": [ + "/app/dist", + "/app/assets" + ] + } + } + } + """; + + Assert.Equal(expectedManifest, destManifest.ToString()); + } + + [Fact] + public async Task ContainerFilesWithMultipleDestinationsAreWrittenToManifest() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + Args = GetManifestArgs() + }); + + // Create two source containers + var source1 = builder.AddContainer("source1", "node:22") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); + + var source2 = builder.AddContainer("source2", "node:22") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/assets" }); + + // Create a destination container with multiple ContainerFilesDestinationAnnotations + var destContainer = builder.AddContainer("dest", "nginx:alpine") + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = source1.Resource, + DestinationPath = "/usr/share/nginx/html" + }) + .WithAnnotation(new ContainerFilesDestinationAnnotation + { + Source = source2.Resource, + DestinationPath = "/usr/share/nginx/assets" + }); + + builder.Build().Run(); + + var destManifest = await ManifestUtils.GetManifest(destContainer.Resource).DefaultTimeout(); + + var expectedManifest = """ + { + "type": "container.v0", + "image": "nginx:alpine", + "containerFiles": { + "source1": { + "destination": "/usr/share/nginx/html", + "sources": [ + "/app/dist" + ] + }, + "source2": { + "destination": "/usr/share/nginx/assets", + "sources": [ + "/app/assets" + ] + } + } + } + """; + + Assert.Equal(expectedManifest, destManifest.ToString()); + } + private static TestProgram CreateTestProgramJsonDocumentManifestPublisher(bool includeIntegrationServices = false, bool includeNodeApp = false) { var program = TestProgram.Create(GetJsonManifestArgs(), includeIntegrationServices, includeNodeApp);