Skip to content
26 changes: 20 additions & 6 deletions src/Aspire.Hosting.Azure/AzureBicepResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,17 +271,31 @@ public virtual void WriteToManifest(ManifestPublishingContext context)
if (Scope is not null)
{
context.Writer.WriteStartObject("scope");
var resourceGroup = Scope.ResourceGroup switch
WriteScopeValue(context, "resourceGroup", Scope.ResourceGroup);
WriteScopeValue(context, "subscription", Scope.Subscription);
if (Scope.IsTenantScope)
{
IManifestExpressionProvider output => output.ValueExpression,
object obj => obj.ToString(),
null => ""
};
context.Writer.WriteString("resourceGroup", resourceGroup);
context.Writer.WriteString("tenant", "current");
}
context.Writer.WriteEndObject();
}
}

private static void WriteScopeValue(ManifestPublishingContext context, string propertyName, object? scopeValue)
{
if (scopeValue is null)
{
return;
}

var value = scopeValue switch
{
IManifestExpressionProvider output => output.ValueExpression,
object obj => obj.ToString(),
};
context.Writer.WriteString(propertyName, value);
}

/// <summary>
/// Provisions this Azure Bicep resource using the bicep provisioner.
/// </summary>
Expand Down
71 changes: 67 additions & 4 deletions src/Aspire.Hosting.Azure/AzureBicepResourceScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,89 @@ namespace Aspire.Hosting.Azure;
/// <summary>
/// Represents the scope associated with the resource.
/// </summary>
/// <param name="resourceGroup">The name of the existing resource group.</param>
public sealed class AzureBicepResourceScope(object resourceGroup)
public sealed class AzureBicepResourceScope
{
/// <summary>
/// Initializes a new instance of the <see cref="AzureBicepResourceScope"/> class.
/// Initializes a new instance of the <see cref="AzureBicepResourceScope"/> class with a resource group scope.
/// </summary>
/// <param name="resourceGroup">The name of the existing resource group.</param>
public AzureBicepResourceScope(object resourceGroup)
{
ArgumentNullException.ThrowIfNull(resourceGroup);

ResourceGroup = resourceGroup;
}

/// <summary>
/// Initializes a new instance of the <see cref="AzureBicepResourceScope"/> class with a resource group scope in a specific subscription.
/// </summary>
/// <param name="resourceGroup">The name of the existing resource group.</param>
/// <param name="subscription">The subscription identifier associated with the resource group.</param>
public AzureBicepResourceScope(object resourceGroup, object subscription) : this(resourceGroup)
{
ArgumentNullException.ThrowIfNull(subscription);

Subscription = subscription;
}

private AzureBicepResourceScope(object? resourceGroup, object? subscription, bool isTenantScope)
{
ResourceGroup = resourceGroup;
Subscription = subscription;
IsTenantScope = isTenantScope;
}

/// <summary>
/// Creates a scope for subscription-level resources.
/// </summary>
/// <param name="subscription">The subscription identifier for subscription-level resources.</param>
/// <returns>A new <see cref="AzureBicepResourceScope"/> scoped to the subscription.</returns>
public static AzureBicepResourceScope ForSubscription(object subscription)
{
ArgumentNullException.ThrowIfNull(subscription);

return new AzureBicepResourceScope(resourceGroup: null, subscription, isTenantScope: false);
}

/// <summary>
/// Creates a scope for tenant-level resources in the current tenant.
/// </summary>
/// <returns>A new <see cref="AzureBicepResourceScope"/> scoped to the current tenant.</returns>
public static AzureBicepResourceScope ForTenant()
{
return new AzureBicepResourceScope(resourceGroup: null, subscription: null, isTenantScope: true);
}
Comment thread
davidfowl marked this conversation as resolved.
Comment thread
davidfowl marked this conversation as resolved.

/// <summary>
/// Represents the resource group to encode in the scope.
/// </summary>
public object ResourceGroup { get; } = resourceGroup;
public object? ResourceGroup { get; }

/// <summary>
/// Represents the subscription to encode in the scope.
/// </summary>
public object? Subscription { get; }

/// <summary>
/// Gets a value indicating whether the scope targets the current tenant.
/// </summary>
public bool IsTenantScope { get; }

internal static AzureBicepResourceScope? FromExistingResourceAnnotation(ExistingAzureResourceAnnotation annotation)
{
ArgumentNullException.ThrowIfNull(annotation);

if (annotation.IsTenantScope)
{
return ForTenant();
}

return (annotation.ResourceGroup, annotation.Subscription) switch
{
({ } resourceGroup, { } subscription) => new AzureBicepResourceScope(resourceGroup, subscription),
({ } resourceGroup, null) => new AzureBicepResourceScope(resourceGroup),
(null, { } subscription) => ForSubscription(subscription),
_ => null
};
}
}
96 changes: 68 additions & 28 deletions src/Aspire.Hosting.Azure/AzureProvisioningResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ public static T CreateExistingOrNewProvisionableResource<T>(AzureResourceInfrast
? nameOutputReference.AsProvisioningParameter(infrastructure)
: new BicepValue<string>((string)existingAnnotation.Name);
provisionedResource = createExisting(infrastructure.AspireResource.GetBicepIdentifier(), existingResourceName);
if (existingAnnotation.ResourceGroup is not null)
if (AzureBicepResourceScope.FromExistingResourceAnnotation(existingAnnotation) is { } scope)
{
infrastructure.AspireResource.Scope = new(existingAnnotation.ResourceGroup);
infrastructure.AspireResource.Scope = scope;
}
}
else
Expand Down Expand Up @@ -186,43 +186,83 @@ public static bool TryApplyExistingResourceAnnotation(IAzureResource aspireResou
((IBicepValue)existingResourceName).Self = new BicepValueReference(provisionableResource, "Name", ["name"]);
provisionableResource.ProvisionableProperties["name"] = existingResourceName;

static bool ResourceGroupEquals(object existingResourceGroup, object? infraResourceGroup)
if (AzureBicepResourceScope.FromExistingResourceAnnotation(existingAnnotation) is { } scope &&
!ScopeEquals(scope, infra.AspireResource.Scope))
{
// We're in the resource group being created
if (infraResourceGroup is null)
{
return false;
}
SetScopeProperty(provisionableResource, CreateScopeExpression(scope, infra));
}

// Compare the resource groups only if they are the same type (string or ParameterResource)
if (infraResourceGroup.GetType() == existingResourceGroup.GetType())
{
return infraResourceGroup.Equals(existingResourceGroup);
}
return true;
}

return false;
private static bool ScopeEquals(AzureBicepResourceScope expected, AzureBicepResourceScope? actual)
{
return actual is not null &&
ScopeValueEquals(expected.ResourceGroup, actual.ResourceGroup) &&
ScopeValueEquals(expected.Subscription, actual.Subscription) &&
expected.IsTenantScope == actual.IsTenantScope;
}

private static bool ScopeValueEquals(object? left, object? right)
{
if (left is null || right is null)
{
return left is null && right is null;
}

return left.GetType() == right.GetType() && left.Equals(right);
}

private static BicepValue<string> CreateScopeExpression(AzureBicepResourceScope scope, AzureResourceInfrastructure infra)
{
if (scope.IsTenantScope)
{
return new FunctionCallExpression(new IdentifierExpression("tenant"));
}

// Apply resource group scope if the target infrastructure's resource group is different from the existing annotation's resource group
if (existingAnnotation.ResourceGroup is not null &&
!ResourceGroupEquals(existingAnnotation.ResourceGroup, infra.AspireResource.Scope?.ResourceGroup))
if (scope.ResourceGroup is not null && scope.Subscription is not null)
{
BicepValue<string> scope = existingAnnotation.ResourceGroup switch
return (scope.Subscription, scope.ResourceGroup) switch
{
string rgName => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(rgName)),
ParameterResource p => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), p.AsProvisioningParameter(infra).Value.Compile()),
_ => throw new NotSupportedException($"Resource group type '{existingAnnotation.ResourceGroup.GetType()}' is not supported.")
(string subscription, string resourceGroup) => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(subscription), new StringLiteralExpression(resourceGroup)),
(string subscription, ParameterResource resourceGroup) => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(subscription), resourceGroup.AsProvisioningParameter(infra).Value.Compile()),
(ParameterResource subscription, string resourceGroup) => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), subscription.AsProvisioningParameter(infra).Value.Compile(), new StringLiteralExpression(resourceGroup)),
(ParameterResource subscription, ParameterResource resourceGroup) => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), subscription.AsProvisioningParameter(infra).Value.Compile(), resourceGroup.AsProvisioningParameter(infra).Value.Compile()),
_ => throw new NotSupportedException($"Scope value types '{scope.Subscription.GetType()}' and '{scope.ResourceGroup.GetType()}' are not supported.")
};
}

// HACK: This is a dance we do to set extra properties using Azure.Provisioning
// will be resolved if we ever get https://github.com/Azure/azure-sdk-for-net/issues/47980
var expression = scope.Compile();
var value = new BicepValue<string>(expression);
((IBicepValue)value).Self = new BicepValueReference(provisionableResource, "Scope", ["scope"]);
provisionableResource.ProvisionableProperties["scope"] = value;
if (scope.ResourceGroup is not null)
{
return scope.ResourceGroup switch
{
string resourceGroup => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(resourceGroup)),
ParameterResource resourceGroup => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), resourceGroup.AsProvisioningParameter(infra).Value.Compile()),
_ => throw new NotSupportedException($"Resource group type '{scope.ResourceGroup.GetType()}' is not supported.")
};
}

return true;
if (scope.Subscription is not null)
{
return scope.Subscription switch
{
string subscription => new FunctionCallExpression(new IdentifierExpression("subscription"), new StringLiteralExpression(subscription)),
ParameterResource subscription => new FunctionCallExpression(new IdentifierExpression("subscription"), subscription.AsProvisioningParameter(infra).Value.Compile()),
_ => throw new NotSupportedException($"Subscription type '{scope.Subscription.GetType()}' is not supported.")
};
}

throw new InvalidOperationException("The Azure Bicep resource scope must specify a resource group, subscription, or tenant scope.");
}

private static void SetScopeProperty(ProvisionableResource provisionableResource, BicepValue<string> scope)
{
// HACK: This is a dance we do to set extra properties using Azure.Provisioning
// will be resolved if we ever get https://github.com/Azure/azure-sdk-for-net/issues/47980
var expression = scope.Compile();
var value = new BicepValue<string>(expression);
((IBicepValue)value).Self = new BicepValueReference(provisionableResource, "Scope", ["scope"]);
provisionableResource.ProvisionableProperties["scope"] = value;
}

private void EnsureParametersAlign(AzureResourceInfrastructure infrastructure)
Expand Down
49 changes: 41 additions & 8 deletions src/Aspire.Hosting.Azure/AzurePublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,16 @@ private async Task WriteAzureArtifactsOutputAsync(IReportingStep step, Distribut
if (resource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation))
{
await VisitAsync(existingAnnotation.ResourceGroup, MapParameterAsync, cancellationToken).ConfigureAwait(false);
await VisitAsync(existingAnnotation.Subscription, MapParameterAsync, cancellationToken).ConfigureAwait(false);
await VisitAsync(existingAnnotation.Name, MapParameterAsync, cancellationToken).ConfigureAwait(false);
}

if (resource.Scope is { } scope)
{
await VisitAsync(scope.ResourceGroup, MapParameterAsync, cancellationToken).ConfigureAwait(false);
await VisitAsync(scope.Subscription, MapParameterAsync, cancellationToken).ConfigureAwait(false);
}

// Map parameters for the resource itself
foreach (var parameter in resource.Parameters)
{
Expand Down Expand Up @@ -249,6 +256,39 @@ static BicepValue<string> ResolveValue(object val)
};
}

BicepValue<string> GetScopeExpression(AzureBicepResource resource)
{
if (resource.Scope is null)
{
return new IdentifierExpression(rg.BicepIdentifier);
}

if (resource.Scope.IsTenantScope)
{
return new FunctionCallExpression(new IdentifierExpression("tenant"));
}

if (resource.Scope.ResourceGroup is not null && resource.Scope.Subscription is not null)
{
return new FunctionCallExpression(
new IdentifierExpression("resourceGroup"),
ResolveValue(Eval(resource.Scope.Subscription)).Compile(),
ResolveValue(Eval(resource.Scope.ResourceGroup)).Compile());
}

if (resource.Scope.ResourceGroup is not null)
{
return new FunctionCallExpression(new IdentifierExpression("resourceGroup"), ResolveValue(Eval(resource.Scope.ResourceGroup)).Compile());
}

if (resource.Scope.Subscription is not null)
{
return new FunctionCallExpression(new IdentifierExpression("subscription"), ResolveValue(Eval(resource.Scope.Subscription)).Compile());
}

throw new InvalidOperationException("The Azure Bicep resource scope must specify a resource group, subscription, or tenant scope.");
}

var computeEnvironments = new List<IAzureComputeEnvironmentResource>();

var computeEnvironmentTask = await step.CreateTaskAsync(
Expand All @@ -269,15 +309,8 @@ static BicepValue<string> ResolveValue(object val)
)
.ConfigureAwait(false);

BicepValue<string> scope = resource.Scope?.ResourceGroup switch
{
string rgName => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(rgName)),
ParameterResource p => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), ParameterLookup[p].Value.Compile()),
_ => new IdentifierExpression(rg.BicepIdentifier)
};

var module = moduleMap[resource];
module.Scope = scope;
module.Scope = GetScopeExpression(resource);
module.Parameters.Add("location", locationParam);

foreach (var parameter in resource.Parameters)
Expand Down
21 changes: 10 additions & 11 deletions src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,12 +422,7 @@ private List<AzureRoleAssignmentResource> CreateRoleAssignmentsResources(
ProvisioningBuildOptions = options.Value.ProvisioningBuildOptions,
};

// existing resource role assignments need to be scoped to the resource's resource group
if (targetResource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation) &&
existingAnnotation.ResourceGroup is not null)
{
roleAssignmentResource.Scope = new(existingAnnotation.ResourceGroup);
}
ApplyExistingResourceScope(roleAssignmentResource, targetResource);

roleAssignmentResources.Add(roleAssignmentResource);
}
Expand Down Expand Up @@ -560,14 +555,18 @@ private AzureRoleAssignmentResource CreateGlobalRoleAssignmentsResource(
ProvisioningBuildOptions = options.Value.ProvisioningBuildOptions,
};

// existing resource role assignments need to be scoped to the resource's resource group
ApplyExistingResourceScope(roleAssignmentResource, targetResource);

return roleAssignmentResource;
}

private static void ApplyExistingResourceScope(AzureBicepResource roleAssignmentResource, AzureProvisioningResource targetResource)
{
if (targetResource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation) &&
existingAnnotation.ResourceGroup is not null)
AzureBicepResourceScope.FromExistingResourceAnnotation(existingAnnotation) is { } scope)
{
roleAssignmentResource.Scope = new(existingAnnotation.ResourceGroup);
roleAssignmentResource.Scope = scope;
}

return roleAssignmentResource;
}

private void AddGlobalRoleAssignmentsInfrastructure(
Expand Down
Loading
Loading