From 1e0b079a92b6d47e63888cc4fc02f438cc78b380 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 22 Oct 2024 22:11:57 -0700 Subject: [PATCH] Fixed AddDockerFile to work with compute customization (#6442) - We were not detecting containers with the build annotation and reading the image name from a parameter. Instead, it was using the runtime image name which is incorrect. - Expose DockerfileBuildAnnotation to make this possible. - Added test and updated the playground with a docker file sample --- .../AppWithDocker/Dockerfile | 11 ++ .../AppWithDocker/app.py | 6 + .../AzureContainerApps.AppHost.csproj | 4 + .../AzureContainerApps.AppHost/Program.cs | 5 + .../aspire-manifest.json | 24 +++- .../pythonapp.module.bicep | 52 +++++++++ .../AzureContainerAppsInfrastructure.cs | 31 +++-- .../DockerfileBuildAnnotation.cs | 33 +++++- src/Aspire.Hosting/PublicAPI.Unshipped.txt | 9 +- .../AzureContainerAppsTests.cs | 108 ++++++++++++++++++ 10 files changed, 267 insertions(+), 16 deletions(-) create mode 100644 playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/Dockerfile create mode 100644 playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/app.py create mode 100644 playground/AzureContainerApps/AzureContainerApps.AppHost/pythonapp.module.bicep diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/Dockerfile b/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/Dockerfile new file mode 100644 index 00000000000..bc6a71ff816 --- /dev/null +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/Dockerfile @@ -0,0 +1,11 @@ +# Use an official Python runtime as a parent image +FROM python:3.8-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Run the command to execute app.py when the container starts +CMD ["python", "app.py"] diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/app.py b/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/app.py new file mode 100644 index 00000000000..3bd628a235c --- /dev/null +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/AppWithDocker/app.py @@ -0,0 +1,6 @@ +import time +from datetime import datetime + +while True: + print(datetime.now(), flush=True) + time.sleep(3) diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/AzureContainerApps.AppHost.csproj b/playground/AzureContainerApps/AzureContainerApps.AppHost/AzureContainerApps.AppHost.csproj index 50636e83208..7399cb1c0a7 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/AzureContainerApps.AppHost.csproj +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/AzureContainerApps.AppHost.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs index 0e64f11122f..fb69f3dcf21 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs @@ -20,6 +20,11 @@ .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)) .AddBlobs("blobs"); +// Testing docker files + +builder.AddDockerfile("pythonapp", "AppWithDocker"); + +// Testing projects builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs) diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json index 1ae54135e1e..12ba7a63554 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json @@ -66,6 +66,24 @@ "type": "value.v0", "connectionString": "{storage.outputs.blobEndpoint}" }, + "pythonapp": { + "type": "container.v1", + "build": { + "context": "AppWithDocker", + "dockerfile": "AppWithDocker/Dockerfile" + }, + "deployment": { + "type": "azure.bicep.v0", + "path": "pythonapp.module.bicep", + "params": { + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "outputs_azure_container_registry_endpoint": "{.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "pythonapp_containerimage": "{pythonapp.containerImage}" + } + } + }, "api": { "type": "project.v1", "path": "../AzureContainerApps.ApiService/AzureContainerApps.ApiService.csproj", @@ -81,7 +99,9 @@ "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", "outputs_azure_container_registry_endpoint": "{.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", - "api_containerimage": "{api.containerImage}" + "api_containerimage": "{api.containerImage}", + "certificateName": "{certificateName.value}", + "customDomain": "{customDomain.value}" } }, "env": { @@ -111,4 +131,4 @@ } } } -} +} \ No newline at end of file diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/pythonapp.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/pythonapp.module.bicep new file mode 100644 index 00000000000..1095ab56fb8 --- /dev/null +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/pythonapp.module.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param outputs_azure_container_registry_managed_identity_id string + +param outputs_managed_identity_client_id string + +param outputs_azure_container_apps_environment_id string + +param outputs_azure_container_registry_endpoint string + +param pythonapp_containerimage string + +resource pythonapp 'Microsoft.App/containerApps@2024-03-01' = { + name: 'pythonapp' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: outputs_azure_container_registry_endpoint + identity: outputs_azure_container_registry_managed_identity_id + } + ] + } + environmentId: outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: pythonapp_containerimage + name: 'pythonapp' + env: [ + { + name: 'AZURE_CLIENT_ID' + value: outputs_managed_identity_client_id + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs index 0b69c9fc65b..7d4426ada94 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs @@ -136,7 +136,7 @@ public void BuildContainerApp(AzureResourceInfrastructure c) ProvisioningParameter? containerImageParam = null; - if (!resource.TryGetContainerImageName(out var containerImageName)) + if (!TryGetContainerImageName(resource, out var containerImageName)) { AllocateContainerRegistryParameters(); @@ -224,6 +224,19 @@ public void BuildContainerApp(AzureResourceInfrastructure c) } } + private static bool TryGetContainerImageName(IResource resource, out string? containerImageName) + { + // If the resource has a Dockerfile build annotation, we don't have the image name + // it will come as a parameter + if (resource.TryGetLastAnnotation(out _)) + { + containerImageName = null; + return false; + } + + return resource.TryGetContainerImageName(out containerImageName); + } + public async Task ProcessResourceAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) { ProcessEndpoints(); @@ -726,10 +739,10 @@ private BicepValue AllocateKeyVaultSecretUriReference(BicepSecretOutputR } private ProvisioningParameter AllocateContainerImageParameter() - => AllocateParameter(ProjectResourceExpression.GetContainerImageExpression((ProjectResource)resource)); + => AllocateParameter(ResourceExpression.GetContainerImageExpression(resource)); private BicepValue AllocateContainerPortParameter() - => AllocateParameter(ProjectResourceExpression.GetContainerPortExpression((ProjectResource)resource)); + => AllocateParameter(ResourceExpression.GetContainerPortExpression(resource)); private ProvisioningParameter AllocateManagedIdentityIdParameter() => _managedIdentityIdParameter ??= AllocateParameter(_containerAppEnvironmentContext.ManagedIdentityId); @@ -986,15 +999,15 @@ public static IManifestExpressionProvider GetSecretOutputKeyVault(AzureBicepReso new SecretOutputExpression(resource); } - private sealed class ProjectResourceExpression(ProjectResource projectResource, string propertyExpression) : IManifestExpressionProvider + private sealed class ResourceExpression(IResource resource, string propertyExpression) : IManifestExpressionProvider { - public string ValueExpression => $"{{{projectResource.Name}.{propertyExpression}}}"; + public string ValueExpression => $"{{{resource.Name}.{propertyExpression}}}"; - public static IManifestExpressionProvider GetContainerImageExpression(ProjectResource p) => - new ProjectResourceExpression(p, "containerImage"); + public static IManifestExpressionProvider GetContainerImageExpression(IResource p) => + new ResourceExpression(p, "containerImage"); - public static IManifestExpressionProvider GetContainerPortExpression(ProjectResource p) => - new ProjectResourceExpression(p, "containerPort"); + public static IManifestExpressionProvider GetContainerPortExpression(IResource p) => + new ResourceExpression(p, "containerPort"); } /// diff --git a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs index 3d864211bb8..d9e1cca6219 100644 --- a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs @@ -3,11 +3,36 @@ namespace Aspire.Hosting.ApplicationModel; -internal class DockerfileBuildAnnotation(string contextPath, string dockerfilePath, string? stage) : IResourceAnnotation +/// +/// Represents an annotation for customizing a Dockerfile build. +/// +/// The path to the context directory for the build. +/// The path to the Dockerfile to use for the build. +/// The name of the build stage to use for the build. +public class DockerfileBuildAnnotation(string contextPath, string dockerfilePath, string? stage) : IResourceAnnotation { + /// + /// Gets the path to the context directory for the build. + /// public string ContextPath => contextPath; - public string DockerfilePath = dockerfilePath; + + /// + /// Gets the path to the Dockerfile to use for the build. + /// + public string DockerfilePath => dockerfilePath; + + /// + /// Gets the name of the build stage to use for the build. + /// public string? Stage => stage; - public Dictionary BuildArguments { get; } = new(); - public Dictionary BuildSecrets { get; } = new(); + + /// + /// Gets the arguments to pass to the build. + /// + public Dictionary BuildArguments { get; } = []; + + /// + /// Gets the secrets to pass to the build. + /// + public Dictionary BuildSecrets { get; } = []; } diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 5ba71003bd4..f5c9a35adc5 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -1,5 +1,13 @@ #nullable enable Aspire.Hosting.ApplicationModel.ContainerLifetime.Session = 0 -> Aspire.Hosting.ApplicationModel.ContainerLifetime +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.BuildArguments.get -> System.Collections.Generic.Dictionary! +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.BuildSecrets.get -> System.Collections.Generic.Dictionary! +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.ContextPath.get -> string! +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.DockerfileBuildAnnotation(string! contextPath, string! dockerfilePath, string? stage) -> void +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.DockerfilePath.get -> string! +Aspire.Hosting.ApplicationModel.DockerfileBuildAnnotation.Stage.get -> string? Aspire.Hosting.ApplicationModel.EndpointNameAttribute Aspire.Hosting.ApplicationModel.EndpointNameAttribute.EndpointNameAttribute() -> void Aspire.Hosting.ApplicationModel.HealthReportSnapshot @@ -46,7 +54,6 @@ Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.get -> string! Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.set -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.get -> System.Collections.Immutable.ImmutableArray Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.init -> void -Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthReports.get -> System.Collections.Immutable.ImmutableArray Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthReports.init -> void diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 70809140398..9deaf19cb64 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -104,6 +104,114 @@ param outputs_azure_container_apps_environment_id string Assert.Equal(expectedBicep, bicep); } + [Fact] + public async Task AddDockerfileWithAppsInfrastructureAddsDeploymentTargetWithContainerAppToContainerResources() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppsInfrastructure(); + + var directory = Directory.CreateTempSubdirectory(".aspire-test"); + + // Contents of the Dockerfile are not important for this test + File.WriteAllText(Path.Combine(directory.FullName, "Dockerfile"), ""); + + builder.AddDockerfile("api", directory.FullName); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = Assert.Single(model.GetContainerResources()); + + container.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "outputs_azure_container_registry_endpoint": "{.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "api_containerimage": "{api.containerImage}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param outputs_azure_container_registry_managed_identity_id string + + param outputs_managed_identity_client_id string + + param outputs_azure_container_apps_environment_id string + + param outputs_azure_container_registry_endpoint string + + param api_containerimage string + + resource api 'Microsoft.App/containerApps@2024-03-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: outputs_azure_container_registry_endpoint + identity: outputs_azure_container_registry_managed_identity_id + } + ] + } + environmentId: outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'AZURE_CLIENT_ID' + value: outputs_managed_identity_client_id + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + [Fact] public async Task AddContainerAppsInfrastructureAddsDeploymentTargetWithContainerAppToProjectResources() {