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()
{