Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Manifest Output & Custom Identities for Projects #3339

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Azure.ApplicationModel;

/// <summary>
/// Represents a user assigned identity that should be assigned to a project.
/// </summary>
public class UserAssignedIdentityAnnotation : CustomManifestOutputAnnotation
{
/// <summary>
/// The Environment Variable Prefix.
/// </summary>
public string EnvironmentVariablePrefix => ((UserAssignedIdentityDescriptor)Value).EnvironmentVariablePrefix;

/// <summary>
/// Initializes a new instance of <see cref="UserAssignedIdentityAnnotation"/>.
/// </summary>
/// <param name="envPrefix">Environment Variable prefix for the Client ID.</param>
/// <param name="clientId">The identity's Client ID for usage within the app.</param>
/// <param name="identityResourceId">The identity Resource ID for assignment to the container app.</param>
public UserAssignedIdentityAnnotation(string envPrefix, string clientId, string identityResourceId) : base("userAssignedIdentities", new UserAssignedIdentityDescriptor(envPrefix, clientId, identityResourceId))
{
}
}

/// <summary>
/// User Assigned Identity Descriptor for output in to the manifest.
/// </summary>
public sealed record UserAssignedIdentityDescriptor
{
/// <summary>
/// Identity Client ID.
/// </summary>
[JsonPropertyName("clientId")]
public string ClientId { get; init; }
/// <summary>
/// Identity Resource ID.
/// </summary>
[JsonPropertyName("resourceId")]
public string IdentityResourceId { get; init; }
/// <summary>
/// Environment Variable Prefix.
/// </summary>
[JsonPropertyName("env")]
public string EnvironmentVariablePrefix { get; init; }

/// <summary>
/// Creates a new <see cref="UserAssignedIdentityDescriptor"/>.
/// </summary>
/// <param name="envPrefix">Environment Variable prefix for the Client ID.</param>
/// <param name="clientId">The identity's Client ID for usage within the app.</param>
/// <param name="identityResourceId">The identity Resource ID for assignment to the container app.</param>
public UserAssignedIdentityDescriptor(string envPrefix, string clientId, string identityResourceId) => (ClientId, IdentityResourceId, EnvironmentVariablePrefix) = (clientId, identityResourceId, envPrefix);
}
46 changes: 46 additions & 0 deletions src/Aspire.Hosting.Azure/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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.Azure.ApplicationModel;

namespace Aspire.Hosting.Azure;

/// <summary>
/// Provides extension methods for building a project resource.
/// </summary>
public static class ProjectResourceBuilderExtensions
{
/// <summary>
/// Adds a User Assigned Identity to the project.
/// </summary>
/// <param name="builder">The project resource builder.</param>
/// <param name="envPrefix">Environment Variable prefix for the Client ID (e.g. {envPrefix}_CLIENT_ID).</param>
/// <param name="clientId">The identity's Client ID for usage within the app.</param>
/// <param name="identityId">The identity Resource ID for assignment to the container app.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<ProjectResource> WithUserAssignedIdentity(this IResourceBuilder<ProjectResource> builder, string envPrefix, string clientId, string identityId)
{
// Check that we don't already have an annotation with this prefix
if (builder.Resource.Annotations.OfType<UserAssignedIdentityAnnotation>().Any(m => m.EnvironmentVariablePrefix == envPrefix))
{
throw new DistributedApplicationException($"A User Assigned Identity with the env prefix '{envPrefix}' has already been added to the project.");
}

builder.WithAnnotation(new UserAssignedIdentityAnnotation(envPrefix, clientId, identityId));
return builder;
}

/// <summary>
/// Adds a User Assigned Identity to the project using a Bicep output reference.
/// </summary>
/// <param name="builder">The project resource builder.</param>
/// <param name="envPrefix">The Environment Variable prefix for the Client ID (e.g. {envPrefix}_CLIENT_ID).</param>
/// <param name="clientIdOutputReference">The bicep output reference for the Client ID.</param>
/// <param name="identityIdOutputReference">The bicep output reference for the Resource ID.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<ProjectResource> WithUserAssignedIdentity(this IResourceBuilder<ProjectResource> builder, string envPrefix, BicepOutputReference clientIdOutputReference, BicepOutputReference identityIdOutputReference)
{
return builder.WithUserAssignedIdentity(envPrefix, clientIdOutputReference.ValueExpression, identityIdOutputReference.ValueExpression);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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;

/// <summary>
/// Represents a custom annotation that can be applied to a resource with varying methods of implementation depending on the consumer of the manifest.
///
/// Produces output such as `"name": [
/// "value"
/// ]`.
/// </summary>
public class CustomManifestOutputAnnotation : IResourceAnnotation
{
/// <summary>
/// Name of the custom manifest annotation.
/// </summary>
public string Name { get; }

/// <summary>
/// Value of the custom manifest annotation.
/// </summary>
public object Value { get; }

/// <summary>
/// Create a custom annotation with the specified name and value.
/// </summary>
/// <param name="name">Name of the annotation.</param>
/// <param name="value">Value to be serialized and output.</param>
public CustomManifestOutputAnnotation(string name, object value)
{
Name = name;
Value = value;
}
}

25 changes: 25 additions & 0 deletions src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
Original file line number Diff line number Diff line change
@@ -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 System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -123,6 +124,7 @@ async Task WriteResourceObjectAsync<T>(T resource, Func<Task> action) where T :
{
Writer.WriteStartObject(resource.Name);
await action().ConfigureAwait(false);
await WriteCustomManifestAnnotations(resource).ConfigureAwait(false);
Writer.WriteEndObject();
}
}
Expand All @@ -142,6 +144,29 @@ private Task WriteConnectionStringAsync(IResourceWithConnectionString resource)
return Task.CompletedTask;
}

private Task WriteCustomManifestAnnotations(IResource resource)
{
if (resource.TryGetAnnotationsOfType<CustomManifestOutputAnnotation>(out var customAnnotations))
{
// Group by name
var groupedAnnotations = customAnnotations
.GroupBy(a => a.Name)
.ToFrozenDictionary(m => m.Key, m => m.ToArray());
foreach(var (name, annotations) in groupedAnnotations)
{
Writer.WritePropertyName(name);
Writer.WriteStartArray();
foreach (var annotation in annotations)
{
JsonSerializer.Serialize(Writer, annotation.Value);
}
Writer.WriteEndArray();
}
}

return Task.CompletedTask;
}

private async Task WriteProjectAsync(ProjectResource project)
{
Writer.WriteString("type", "project.v0");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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.Azure;
using Aspire.Hosting.Utils;
using Xunit;

namespace Aspire.Hosting.Tests.Azure;

public class AzureProjectProvisionerTests
{
[Fact]
public async Task EnsureUserAssignedIdentityAddedToManifest()
{
using var builder = TestDistributedApplicationBuilder.Create();

var projectResource = builder.AddProject<Projects.ServiceA>("servicea")
.WithUserAssignedIdentity("TEST", "00000000-0000-0000-0000-000000000000", "/subscriptions/<subscription_id>/resourcegroups/my-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-user");

var manifest = await ManifestUtils.GetManifest(projectResource.Resource);

var expectedManifest = """
{
"type": "project.v0",
"path": "../../../../../tests/testproject/TestProject.ServiceA/TestProject.ServiceA.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"port": 5156
}
},
"userAssignedIdentities": [
{
"clientId": "00000000-0000-0000-0000-000000000000",
"resourceId": "/subscriptions/\u003Csubscription_id\u003E/resourcegroups/my-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-user",
"env": "TEST"
}
]
}
""";

Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task EnsureUserAssignedIdentityFromBicepAddedToManifest()
{
using var builder = TestDistributedApplicationBuilder.Create();

var bicepResource = builder.AddBicepTemplateString("identities", "test");
var projectResource = builder.AddProject<Projects.ServiceA>("servicea")
.WithUserAssignedIdentity("TEST", bicepResource.GetOutput("clientId"), bicepResource.GetOutput("resourceId"));

var manifest = await ManifestUtils.GetManifest(projectResource.Resource);

var expectedManifest = """
{
"type": "project.v0",
"path": "../../../../../tests/testproject/TestProject.ServiceA/TestProject.ServiceA.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"port": 5156
}
},
"userAssignedIdentities": [
{
"clientId": "{identities.outputs.clientId}",
"resourceId": "{identities.outputs.resourceId}",
"env": "TEST"
}
]
}
""";

Assert.Equal(expectedManifest, manifest.ToString());
}
}
28 changes: 28 additions & 0 deletions tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,34 @@ public void MetadataPropertyNotEmittedWhenMetadataNotAdded()
Assert.False(container.TryGetProperty("metadata", out var _));
}

[Fact]
public void EnsureCustomAnnotationsAreWritten()
{
using var program = CreateTestProgramJsonDocumentManifestPublisher();

program.ServiceABuilder
.WithAnnotation(new CustomManifestOutputAnnotation("testStringAnnotation", "TestValue"))
.WithAnnotation(new CustomManifestOutputAnnotation("testTrueAnnotation", true))
.WithAnnotation(new CustomManifestOutputAnnotation("testFalseAnnotation", false))
.WithAnnotation(new CustomManifestOutputAnnotation("testNumberAnnotation", 42));

// Build AppHost so that publisher can be resolved.
program.Build();
var publisher = program.GetManifestPublisher();

program.Run();

var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");

var service = resources.GetProperty("servicea");
Assert.True(service.TryGetProperty("testStringAnnotation", out var testStringAnnotation));
Assert.Equal(JsonValueKind.Array, testStringAnnotation.ValueKind);
Assert.Equal("TestValue", testStringAnnotation[0].GetString());
Assert.True(service.GetProperty("testTrueAnnotation")[0].GetBoolean());
Assert.False(service.GetProperty("testFalseAnnotation")[0].GetBoolean());
Assert.Equal(42, service.GetProperty("testNumberAnnotation")[0].GetInt32());
}

[Fact]
public void VerifyTestProgramFullManifest()
{
Expand Down
Loading