diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 0247ff6f77d..769e43a238e 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -139,8 +139,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var defaultImageTags = context.GetSteps(this, DefaultImageStepTag).Single(); var myBuildStep = context.GetSteps(this, WellKnownPipelineTags.BuildCompute).Single(); - var computeResources = context.Model.GetComputeResources() - .Where(r => r.RequiresImageBuildAndPush()) + var computeResources = context.Model.Resources + .Where(r => r.RequiresImageBuild()) .ToList(); foreach (var computeResource in computeResources) @@ -174,8 +174,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet private static Task DefaultImageTags(PipelineStepContext context) { - var computeResources = context.Model.GetComputeResources() - .Where(r => r.RequiresImageBuildAndPush()) + var computeResources = context.Model.Resources + .Where(r => r.RequiresImageBuild()) .ToList(); var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}"; diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index dc133ade377..af27e40d3b8 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -183,7 +183,18 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl .Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.BuildCommandLineArgs)}"); } }); - }); + + // since Vite apps are typically served via a separate web server, we don't have an entrypoint + if (resource.TryGetLastAnnotation(out var dockerFileAnnotation)) + { + dockerFileAnnotation.HasEntrypoint = false; + } + else + { + throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile."); + } + }) + .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }); } /// diff --git a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs index 6ca2b1690dc..c964cfa9b61 100644 --- a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs +++ b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs @@ -10,4 +10,4 @@ namespace Aspire.Hosting.NodeJs; /// The command to execute the Vite application, such as the script or entry point. /// The working directory from which the Vite application command is executed. public class ViteAppResource(string name, string command, string workingDirectory) - : NodeAppResource(name, command, workingDirectory); + : NodeAppResource(name, command, workingDirectory), IResourceWithContainerFiles; diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index ddfa95b6a5a..614b3ce7787 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -4,6 +4,9 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; using Aspire.Hosting.Python; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -233,6 +236,45 @@ public static IResourceBuilder AddPythonApp( .WithArgs(scriptArgs); } + /// + /// Adds a Uvicorn-based Python application to the distributed application builder with HTTP endpoint configuration. + /// + /// This method configures the application to use Uvicorn as the server and exposes an HTTP + /// endpoint. When publishing, it sets the entry point to use the Uvicorn executable with appropriate arguments for + /// host and port. + /// The distributed application builder to which the Uvicorn application resource will be added. + /// The unique name of the Uvicorn application resource. + /// The directory containing the Python application files. + /// The ASGI app import path which informs Uvicorn which module and variable to load as your web application. + /// For example, "main:app" means "main.py" file and variable named "app". + /// A resource builder for further configuration of the Uvicorn Python application resource. + public static IResourceBuilder AddUvicornApp( + this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string app) + { + var resourceBuilder = builder.AddPythonExecutable(name, appDirectory, "uvicorn") + .WithHttpEndpoint(env: "PORT") + .WithArgs(c => + { + c.Args.Add(app); + + c.Args.Add("--host"); + var endpoint = ((IResourceWithEndpoints)c.Resource).GetEndpoint("http"); + if (builder.ExecutionContext.IsPublishMode) + { + c.Args.Add("0.0.0.0"); + } + else + { + c.Args.Add(endpoint.EndpointAnnotation.TargetHost); + } + + c.Args.Add("--port"); + c.Args.Add(endpoint.Property(EndpointProperty.TargetPort)); + }); + + return resourceBuilder; + } + private static IResourceBuilder AddPythonAppCore( IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType, string entrypoint, string virtualEnvironmentPath) @@ -465,6 +507,7 @@ private static IResourceBuilder AddPythonAppCore( var runtimeBuilder = context.Builder .From($"python:{pythonVersion}-slim-bookworm", "app") .EmptyLine() + .AddContainerFiles(context.Resource, "/app") .Comment("------------------------------") .Comment("🚀 Runtime stage") .Comment("------------------------------") @@ -504,9 +547,77 @@ private static IResourceBuilder AddPythonAppCore( }); }); + resourceBuilder.WithPipelineStepFactory(factoryContext => + { + List steps = []; + var buildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource); + steps.Add(buildStep); + + // ensure any static file references' images are built first + if (factoryContext.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + foreach (var containerFile in containerFilesAnnotations) + { + var source = containerFile.Source; + var staticFileBuildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source); + buildStep.DependsOn(staticFileBuildStep); + steps.Add(staticFileBuildStep); + } + } + + return steps; + }); + return resourceBuilder; } + private static PipelineStep CreateBuildImageBuildStep(string stepName, IResource resource) => + new() + { + Name = stepName, + Action = async ctx => + { + var containerImageBuilder = ctx.Services.GetRequiredService(); + await containerImageBuilder.BuildImageAsync( + resource, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + ctx.CancellationToken).ConfigureAwait(false); + }, + Tags = [WellKnownPipelineTags.BuildCompute] + }; + + private static DockerfileStage AddContainerFiles(this DockerfileStage stage, IResource resource, string rootDestinationPath) + { + if (resource.TryGetAnnotationsOfType(out var containerFilesDestinationAnnotations)) + { + foreach (var containerFileDestination in containerFilesDestinationAnnotations) + { + // get image name + if (!containerFileDestination.Source.TryGetContainerImageName(out var imageName)) + { + throw new InvalidOperationException("Cannot add container files: Source resource does not have a container image name."); + } + + var destinationPath = containerFileDestination.DestinationPath; + if (!destinationPath.StartsWith('/')) + { + destinationPath = $"{rootDestinationPath}/{destinationPath}"; + } + + foreach (var containerFilesSource in containerFileDestination.Source.Annotations.OfType()) + { + stage.CopyFrom(imageName, containerFilesSource.SourcePath, destinationPath); + } + } + + stage.EmptyLine(); + } + return stage; + } + private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs) { ArgumentNullException.ThrowIfNull(scriptArgs); diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFilesDestinationAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFilesDestinationAnnotation.cs new file mode 100644 index 00000000000..8d0ebfcdf10 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFilesDestinationAnnotation.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation that specifies a source resource and destination path for copying container files. +/// +/// +/// This annotation is typically used in scenarios where assets, such as images or static files, +/// need to be copied from one container image to another during the build process. +/// +/// This annotation is applied to the destination resource where the source container's files will be copied to. +/// +public sealed class ContainerFilesDestinationAnnotation : IResourceAnnotation +{ + /// + /// Gets the resource that provides access to the container files to be copied. + /// + public required IResource Source { get; init; } + + /// + /// Gets or sets the file system path where the container files will be copied into the destination. + /// + public required string DestinationPath { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerFilesSourceAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ContainerFilesSourceAnnotation.cs new file mode 100644 index 00000000000..9a9847d0a1c --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ContainerFilesSourceAnnotation.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation that associates a container file/directory with a resource. +/// +/// +/// This annotation is typically used in scenarios where assets, such as images or static files, +/// need to be copied from one container image to another during the build process. +/// +/// This annotation is applied to the source resource that produces the files. +/// +public sealed class ContainerFilesSourceAnnotation : IResourceAnnotation +{ + /// + /// Gets the file system path to the source file or directory inside the container. + /// + public required string SourcePath { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs index 49eab79d372..fa57a0e11cd 100644 --- a/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs @@ -28,6 +28,11 @@ public static IEnumerable GetComputeResources(this DistributedApplica continue; } + if (r.IsBuildOnlyContainer()) + { + continue; + } + yield return r; } } diff --git a/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs b/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs index c802ab90de6..315610ed0b8 100644 --- a/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs +++ b/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs @@ -21,7 +21,7 @@ public class DockerfileStage : DockerfileStatement public DockerfileStage(string? stageName, string imageReference) { StageName = stageName; - + // Add the FROM statement as the first statement _statements.Add(new DockerfileFromStatement(imageReference, stageName)); } @@ -108,17 +108,17 @@ public DockerfileStage Copy(string source, string destination) /// /// Adds a COPY statement to copy files from another stage. /// - /// The source stage name. + /// The source stage or image name. /// The source path in the stage. /// The destination path. /// The current stage. [Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public DockerfileStage CopyFrom(string stage, string source, string destination) + public DockerfileStage CopyFrom(string from, string source, string destination) { - ArgumentException.ThrowIfNullOrEmpty(stage); + ArgumentException.ThrowIfNullOrEmpty(from); ArgumentException.ThrowIfNullOrEmpty(source); ArgumentException.ThrowIfNullOrEmpty(destination); - _statements.Add(new DockerfileCopyFromStatement(stage, source, destination)); + _statements.Add(new DockerfileCopyFromStatement(from, source, destination)); return this; } @@ -284,4 +284,4 @@ public override async Task WriteStatementAsync(StreamWriter writer, Cancellation await statement.WriteStatementAsync(writer, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs index 7dec7a40c55..9885a019781 100644 --- a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs @@ -54,4 +54,12 @@ public class DockerfileBuildAnnotation(string contextPath, string dockerfilePath /// When set, this will be used as the container image tag instead of the value from ContainerImageAnnotation. /// public string? ImageTag { get; set; } + + /// + /// Gets or sets a value indicating whether an entry point is defined in the Dockerfile. + /// + /// + /// Container images without an entry point are not considered compute resources. + /// + public bool HasEntrypoint { get; set; } = true; } diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceWithContainerFiles.cs b/src/Aspire.Hosting/ApplicationModel/IResourceWithContainerFiles.cs new file mode 100644 index 00000000000..d2b0ae6ee24 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceWithContainerFiles.cs @@ -0,0 +1,17 @@ +// 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; + +namespace Aspire.Hosting; + +/// +/// Represents a resource that contains files that can be copied to other resources. +/// +/// +/// Resources that implement this interface produce container images that include files +/// that can be copied into other resources. For example using Docker's COPY --from feature. +/// +public interface IResourceWithContainerFiles : IResource +{ +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 102bf9a0895..4d0763339b2 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -601,6 +601,19 @@ public static int GetReplicaCount(this IResource resource) } } + /// + /// Determines whether the specified resource requires image building. + /// + /// + /// Resources require an image build if they provide their own Dockerfile or are a project. + /// + /// The resource to evaluate for image build requirements. + /// True if the resource requires image building; otherwise, false. + public static bool RequiresImageBuild(this IResource resource) + { + return resource is ProjectResource || resource.TryGetLastAnnotation(out _); + } + /// /// Determines whether the specified resource requires image building and pushing. /// @@ -612,7 +625,13 @@ public static int GetReplicaCount(this IResource resource) /// True if the resource requires image building and pushing; otherwise, false. public static bool RequiresImageBuildAndPush(this IResource resource) { - return resource is ProjectResource || resource.TryGetLastAnnotation(out _); + return resource.RequiresImageBuild() && !resource.IsBuildOnlyContainer(); + } + + internal static bool IsBuildOnlyContainer(this IResource resource) + { + return resource.TryGetLastAnnotation(out var dockerfileBuild) && + !dockerfileBuild.HasEntrypoint; } /// diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 5b5dd63585b..5cd33648f0a 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1270,6 +1270,34 @@ public static IResourceBuilder WithUrlForEndpoint(this IResourceBuilder return builder; } + /// + /// Configures the resource to copy container files from the specified source resource during publishing. + /// + /// The type of resource being built. Must implement . + /// The resource builder to which container files will be copied to. + /// The resource which contains the container files to be copied. + /// The destination path within the resource's container where the files will be copied. + public static IResourceBuilder PublishWithContainerFiles( + this IResourceBuilder builder, + IResourceBuilder source, + string destinationPath) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(destinationPath); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + return builder.WithAnnotation(new ContainerFilesDestinationAnnotation() + { + Source = source.Resource, + DestinationPath = destinationPath + }); + } + /// /// Excludes a resource from being published to the manifest. /// diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py index 554594b504c..c89b916de8d 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py +++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py @@ -129,13 +129,3 @@ async def health_check(): # Serve static files directly from root, if the "static" directory exists if os.path.exists("static"): app.mount("/", fastapi.staticfiles.StaticFiles(directory="static", html=True), name="static") - - -if __name__ == "__main__": - import uvicorn - - port = int(os.environ.get("PORT", 8111)) - host = os.environ.get("HOST", "127.0.0.1") - reload = os.environ.get("DEBUG", "False").lower() == "true" - - uvicorn.run("app:app", host=host, port=port, reload=reload, log_level="info") diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs index 4824a97aa32..da5f8004060 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs +++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs @@ -13,23 +13,20 @@ var cache = builder.AddRedis("cache"); #endif -var apiService = builder.AddPythonScript("app", "./app", "app.py") +var app = builder.AddUvicornApp("app", "./app", "app:app") .WithUvEnvironment() - .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints() - .WithHttpHealthCheck("/health") #if UseRedisCache .WithReference(cache) .WaitFor(cache) #endif - .PublishAsDockerFile(c => - { - c.WithDockerfile("."); - }); + .WithHttpHealthCheck("/health"); -builder.AddViteApp("frontend", "./frontend") +var frontend = builder.AddViteApp("frontend", "./frontend") .WithNpmPackageManager() - .WithReference(apiService) - .WaitFor(apiService); + .WithReference(app) + .WaitFor(app); + +app.PublishWithContainerFiles(frontend, "./static"); builder.Build().Run(); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index fca29fa37cb..c693da939e0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREACADOMAINS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREAZURE002 // 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. using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; @@ -1954,6 +1955,44 @@ await Verify(containerBicep, "bicep") .AppendContentAsFile(projectBicep, "bicep"); } + [Fact] + public async Task BuildOnlyContainerResource_DoesNotGetDeployed() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + // Add a normal container resource + builder.AddContainer("api", "myimage"); + + // Add a build-only container resource + builder.AddExecutable("build-only", "exe", ".") + .PublishAsDockerFile(c => + { + c.WithDockerfileBuilder(".", dockerfileContext => + { + var dockerBuilder = dockerfileContext.Builder + .From("scratch"); + }); + + var dockerFileAnnotation = c.Resource.Annotations.OfType().Single(); + dockerFileAnnotation.HasEntrypoint = false; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = model.Resources.Single(r => r.Name == "api"); + var containerProvisioningResource = container.GetDeploymentTargetAnnotation()?.DeploymentTarget as AzureProvisioningResource; + Assert.NotNull(containerProvisioningResource); + + var buildOnly = model.Resources.Single(r => r.Name == "build-only"); + Assert.Null(buildOnly.GetDeploymentTargetAnnotation()); + } + [Fact] public async Task BindMountNamesWithHyphensAreNormalized() { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 563e23e370b..34423b2f392 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -6,22 +6,23 @@ #pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREINTERACTION001 // 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 ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -using Aspire.Hosting.Utils; -using Aspire.Hosting.Tests; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Aspire.Hosting.Azure.Provisioning.Internal; -using Aspire.Hosting.Publishing.Internal; -using Aspire.Hosting.Testing; using System.Text.Json.Nodes; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.Provisioning; +using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Publishing.Internal; +using Aspire.Hosting.Testing; +using Aspire.Hosting.Tests; +using Aspire.Hosting.Utils; +using Aspire.TestUtilities; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Aspire.TestUtilities; -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Azure.Tests; @@ -178,11 +179,11 @@ public async Task DeployAsync_WithResourcesWithBuildSteps() Assert.Contains("api-build", mainBuildStep.DependsOnSteps); - var apiBuildstep = configContext.GetSteps(WellKnownPipelineTags.BuildCompute) + var apiBuildStep = configContext.GetSteps(WellKnownPipelineTags.BuildCompute) .Where(s => s.Name == "api-build") .Single(); - Assert.Contains("default-image-tags", apiBuildstep.DependsOnSteps); + Assert.Contains("default-image-tags", apiBuildStep.DependsOnSteps); configCalled = true; }); @@ -201,6 +202,56 @@ public async Task DeployAsync_WithResourcesWithBuildSteps() Assert.Empty(mockImageBuilder.BuildImageResources); } + /// + /// Verifies that deploying an application with resources that are build-only containers only builds + /// the containers and does not attempt to push them. + /// + [Fact] + public async Task DeployAsync_WithBuildOnlyContainers() + { + // Arrange + var mockProcessRunner = new MockProcessRunner(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var armClientProvider = new TestArmClientProvider(new Dictionary + { + ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, + ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + }); + ConfigureTestServices(builder, armClientProvider: armClientProvider); + + var containerAppEnv = builder.AddAzureContainerAppEnvironment("env"); + + // Add a build-only container resource + builder.AddExecutable("exe", "exe", ".") + .PublishAsDockerFile(c => + { + c.WithDockerfileBuilder(".", dockerfileContext => + { + var dockerBuilder = dockerfileContext.Builder + .From("scratch"); + }); + + var dockerFileAnnotation = c.Resource.Annotations.OfType().Single(); + dockerFileAnnotation.HasEntrypoint = false; + }); + + using var app = builder.Build(); + await app.StartAsync(); + await app.WaitForShutdownAsync(); + + // Assert - Verify MockImageBuilder was only called to build an image and not push it + var mockImageBuilder = app.Services.GetRequiredService() as MockImageBuilder; + Assert.NotNull(mockImageBuilder); + Assert.False(mockImageBuilder.BuildImageCalled); + Assert.True(mockImageBuilder.BuildImagesCalled); + var builtImage = Assert.Single(mockImageBuilder.BuildImageResources); + Assert.Equal("exe", builtImage.Name); + Assert.False(mockImageBuilder.PushImageCalled); + } + [Fact] public async Task DeployAsync_WithAzureStorageResourcesWorks() { diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs index 155f723b657..0387d8f488a 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs @@ -1,6 +1,7 @@ // 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; using Aspire.Hosting.Utils; namespace Aspire.Hosting.NodeJs.Tests; @@ -51,5 +52,11 @@ RUN npm run build """.Replace("\r\n", "\n"); Assert.Equal(expectedDockerfile, dockerfileContents); + + var dockerBuildAnnotation = nodeApp.Resource.Annotations.OfType().Single(); + Assert.False(dockerBuildAnnotation.HasEntrypoint); + + var containerFilesSource = nodeApp.Resource.Annotations.OfType().Single(); + Assert.Equal("/app/dist", containerFilesSource.SourcePath); } } diff --git a/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs new file mode 100644 index 00000000000..058507ab7df --- /dev/null +++ b/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECOMPUTE001 // 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. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Python.Tests; + +public class AddUvicornAppTests +{ + [Fact] + public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode() + { + using var sourceDir = new TempDirectory(); + using var outputDir = new TempDirectory(); + var projectDirectory = sourceDir.Path; + + // Create a UV-based Python project with pyproject.toml and uv.lock + var pyprojectContent = """ + [project] + name = "test-app" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """; + + var uvLockContent = """ + version = 1 + requires-python = ">=3.12" + """; + + var scriptContent = """ + print("Hello from UV project!") + """; + + File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent); + File.WriteAllText(Path.Combine(projectDirectory, "uv.lock"), uvLockContent); + File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent); + + var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "manifest", outputPath: outputDir.Path); + + var main = builder.AddUvicornApp("main", projectDirectory, "main.py") + .WithUvEnvironment(); + + var sourceFiles = builder.AddResource(new MyFilesContainer("exe", "exe", ".")) + .PublishAsDockerFile(c => + { + c.WithDockerfileBuilder(".", dockerfileContext => + { + var dockerBuilder = dockerfileContext.Builder + .From("scratch"); + }) + .WithImageTag("deterministc-tag"); + }) + .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }); + + main.PublishWithContainerFiles(sourceFiles, "./static"); + + var app = builder.Build(); + + app.Run(); + + // Verify that Dockerfiles were generated for each entrypoint type + var appDockerfilePath = Path.Combine(outputDir.Path, "main.Dockerfile"); + Assert.True(File.Exists(appDockerfilePath), "Dockerfile should be generated for script entrypoint"); + + var scriptDockerfileContent = File.ReadAllText(appDockerfilePath); + + await Verify(scriptDockerfileContent); + } + + private sealed class MyFilesContainer(string name, string command, string workingDirectory) + : ExecutableResource(name, command, workingDirectory), IResourceWithContainerFiles; +} diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode.verified.txt similarity index 64% rename from src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode.verified.txt index ca86e795dcb..46f91fa3f97 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile +++ b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode.verified.txt @@ -1,15 +1,4 @@ -# Stage 1: Build the Vite app -FROM node:22-slim AS frontend-stage - -# Set the working directory inside the container -COPY frontend ./ - -WORKDIR /frontend -RUN npm install -RUN npm run build - -# Stage 2: Build the Python application with UV -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder # Enable bytecode compilation and copy mode for the virtual environment ENV UV_COMPILE_BYTECODE=1 @@ -19,18 +8,17 @@ WORKDIR /app # Install dependencies first for better layer caching # Uses BuildKit cache mounts to speed up repeated builds -RUN --mount=type=cache,target=/root/.cache/uv --mount=type=bind,source=./app/uv.lock,target=uv.lock --mount=type=bind,source=./app/pyproject.toml,target=pyproject.toml \ +RUN --mount=type=cache,target=/root/.cache/uv --mount=type=bind,source=uv.lock,target=uv.lock --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --locked --no-install-project --no-dev # Copy the rest of the application source and install the project -COPY ./app /app +COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev -# Stage 3: Create the final runtime image -FROM python:3.13-slim-bookworm AS app +FROM python:3.12-slim-bookworm AS app -COPY --from=frontend-stage /dist /app/static +COPY --from=exe:deterministc-tag /app/dist /app/./static # ------------------------------ # 🚀 Runtime stage @@ -54,4 +42,4 @@ USER appuser WORKDIR /app # Run the application -ENTRYPOINT ["fastapi", "run", "app.py", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["uvicorn"]