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/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);