Skip to content

Commit 0ca3075

Browse files
authored
Set up Azure deployer and provisioning context prompting (#10792)
* Set up Azure deployer and provisioning context prompting * Set provisioning config for publish assets test
1 parent 876c75f commit 0ca3075

File tree

9 files changed

+396
-118
lines changed

9 files changed

+396
-118
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
5+
#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.
6+
7+
using Aspire.Hosting.Azure.Provisioning.Internal;
8+
9+
namespace Aspire.Hosting.Azure;
10+
11+
internal sealed class AzureDeployingContext(
12+
IProvisioningContextProvider provisioningContextProvider,
13+
IUserSecretsManager userSecretsManager)
14+
{
15+
public async Task DeployModelAsync(CancellationToken cancellationToken = default)
16+
{
17+
var userSecrets = await userSecretsManager.LoadUserSecretsAsync(cancellationToken).ConfigureAwait(false);
18+
await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
19+
}
20+
}

src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
using System.Diagnostics.CodeAnalysis;
88
using Aspire.Hosting.ApplicationModel;
9+
using Aspire.Hosting.Azure.Provisioning.Internal;
910
using Microsoft.Extensions.DependencyInjection;
1011
using Microsoft.Extensions.Options;
1112

@@ -45,6 +46,7 @@ public sealed class AzureEnvironmentResource : Resource
4546
public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name)
4647
{
4748
Annotations.Add(new PublishingCallbackAnnotation(PublishAsync));
49+
Annotations.Add(new DeployingCallbackAnnotation(DeployAsync));
4850

4951
Location = location;
5052
ResourceGroupName = resourceGroupName;
@@ -63,4 +65,16 @@ private Task PublishAsync(PublishingContext context)
6365

6466
return azureCtx.WriteModelAsync(context.Model, this);
6567
}
68+
69+
private Task DeployAsync(DeployingContext context)
70+
{
71+
var provisioningContextProvider = context.Services.GetRequiredService<IProvisioningContextProvider>();
72+
var userSecretsManager = context.Services.GetRequiredService<IUserSecretsManager>();
73+
74+
var azureCtx = new AzureDeployingContext(
75+
provisioningContextProvider,
76+
userSecretsManager);
77+
78+
return azureCtx.DeployModelAsync(context.CancellationToken);
79+
}
6680
}

src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ internal sealed partial class DefaultProvisioningContextProvider(
2727
ILogger<DefaultProvisioningContextProvider> logger,
2828
IArmClientProvider armClientProvider,
2929
IUserPrincipalProvider userPrincipalProvider,
30-
ITokenCredentialProvider tokenCredentialProvider) : IProvisioningContextProvider
30+
ITokenCredentialProvider tokenCredentialProvider,
31+
DistributedApplicationExecutionContext distributedApplicationExecutionContext) : IProvisioningContextProvider
3132
{
3233
private readonly AzureProvisionerOptions _options = options.Value;
3334

@@ -38,7 +39,7 @@ private void EnsureProvisioningOptions(JsonObject userSecrets)
3839
if (!interactionService.IsAvailable ||
3940
(!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)))
4041
{
41-
// If the interaction service is not available, or
42+
// If the interaction service is not available, or
4243
// if both options are already set, we can skip the prompt
4344
_provisioningOptionsAvailable.TrySetResult();
4445
return;
@@ -95,7 +96,7 @@ private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, Canc
9596
var result = await interactionService.PromptInputsAsync(
9697
"Azure provisioning",
9798
"""
98-
The model contains Azure resources that require an Azure Subscription.
99+
The model contains Azure resources that require an Azure Subscription.
99100
100101
To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning).
101102
""",
@@ -289,6 +290,8 @@ private string GetDefaultResourceGroupName()
289290
normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
290291
}
291292

292-
return $"{prefix}-{normalizedApplicationName}-{suffix}";
293+
return distributedApplicationExecutionContext.IsPublishMode
294+
? $"{prefix}-{normalizedApplicationName}"
295+
: $"{prefix}-{normalizedApplicationName}-{suffix}";
293296
}
294297
}

src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,6 @@ private bool HasStepsInProgress()
263263

264264
private async Task HandleInteractionUpdateAsync(Interaction interaction, CancellationToken cancellationToken)
265265
{
266-
// Only handle input interaction types
267-
if (interaction.InteractionInfo is not Interaction.InputsInteractionInfo inputsInfo || inputsInfo.Inputs.Count == 0)
268-
{
269-
return;
270-
}
271-
272266
if (interaction.State == Interaction.InteractionState.InProgress)
273267
{
274268
if (HasStepsInProgress())
@@ -286,29 +280,63 @@ await _interactionService.CompleteInteractionAsync(interaction.InteractionId, (i
286280
return;
287281
}
288282

289-
var promptInputs = inputsInfo.Inputs.Select(input => new PublishingPromptInput
283+
// Handle input interaction types
284+
if (interaction.InteractionInfo is Interaction.InputsInteractionInfo inputsInfo && inputsInfo.Inputs.Count > 0)
290285
{
291-
Label = input.Label,
292-
InputType = input.InputType.ToString(),
293-
Required = input.Required,
294-
Options = input.Options,
295-
Value = input.Value,
296-
ValidationErrors = input.ValidationErrors
297-
}).ToList();
298-
299-
var activity = new PublishingActivity
286+
var promptInputs = inputsInfo.Inputs.Select(input => new PublishingPromptInput
287+
{
288+
Label = input.Label,
289+
InputType = input.InputType.ToString(),
290+
Required = input.Required,
291+
Options = input.Options,
292+
Value = input.Value,
293+
ValidationErrors = input.ValidationErrors
294+
}).ToList();
295+
296+
var activity = new PublishingActivity
297+
{
298+
Type = PublishingActivityTypes.Prompt,
299+
Data = new PublishingActivityData
300+
{
301+
Id = interaction.InteractionId.ToString(CultureInfo.InvariantCulture),
302+
StatusText = interaction.Message ?? $"{interaction.Title}: ",
303+
CompletionState = ToBackchannelCompletionState(CompletionState.InProgress),
304+
Inputs = promptInputs
305+
}
306+
};
307+
308+
await ActivityItemUpdated.Writer.WriteAsync(activity, cancellationToken).ConfigureAwait(false);
309+
}
310+
// Handle notification interaction types (PromptNotificationAsync)
311+
else if (interaction.InteractionInfo is Interaction.NotificationInteractionInfo)
300312
{
301-
Type = PublishingActivityTypes.Prompt,
302-
Data = new PublishingActivityData
313+
var promptInputs = new List<PublishingPromptInput>
314+
{
315+
new PublishingPromptInput
316+
{
317+
Label = "Confirm",
318+
InputType = "Boolean",
319+
Required = true,
320+
Options = null,
321+
Value = null,
322+
ValidationErrors = null
323+
}
324+
};
325+
326+
var activity = new PublishingActivity
303327
{
304-
Id = interaction.InteractionId.ToString(CultureInfo.InvariantCulture),
305-
StatusText = interaction.Message ?? $"{interaction.Title}: ",
306-
CompletionState = ToBackchannelCompletionState(CompletionState.InProgress),
307-
Inputs = promptInputs
308-
}
309-
};
310-
311-
await ActivityItemUpdated.Writer.WriteAsync(activity, cancellationToken).ConfigureAwait(false);
328+
Type = PublishingActivityTypes.Prompt,
329+
Data = new PublishingActivityData
330+
{
331+
Id = interaction.InteractionId.ToString(CultureInfo.InvariantCulture),
332+
StatusText = interaction.Message ?? $"{interaction.Title}: ",
333+
CompletionState = ToBackchannelCompletionState(CompletionState.InProgress),
334+
Inputs = promptInputs
335+
}
336+
};
337+
338+
await ActivityItemUpdated.Writer.WriteAsync(activity, cancellationToken).ConfigureAwait(false);
339+
}
312340
}
313341
}
314342

@@ -336,6 +364,22 @@ await _interactionService.CompleteInteractionAsync(interactionId,
336364
State = inputsInfo.Inputs
337365
};
338366
}
367+
else if (interaction.InteractionInfo is Interaction.NotificationInteractionInfo)
368+
{
369+
// Handle notification interactions with boolean result
370+
bool result = false;
371+
if (responses is not null && responses.Length > 0)
372+
{
373+
// Parse the boolean value from the first response
374+
result = bool.TryParse(responses[0].Value, out var parsedValue) && parsedValue;
375+
}
376+
377+
return new InteractionCompletionState
378+
{
379+
Complete = true,
380+
State = result
381+
};
382+
}
339383

340384
return new InteractionCompletionState
341385
{
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
5+
#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.
6+
#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.
7+
#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.
8+
9+
using Aspire.Hosting.Utils;
10+
using Aspire.Hosting.Tests;
11+
using Microsoft.Extensions.DependencyInjection;
12+
using Aspire.Hosting.Azure.Provisioning.Internal;
13+
using Aspire.Hosting.Testing;
14+
using System.Text.Json.Nodes;
15+
16+
namespace Aspire.Hosting.Azure.Tests;
17+
18+
public class AzureDeployerTests(ITestOutputHelper output)
19+
{
20+
[Fact]
21+
public void DeployAsync_EmitsPublishedResources()
22+
{
23+
// Arrange
24+
var tempDir = Directory.CreateTempSubdirectory(".azure-deployer-test");
25+
output.WriteLine($"Temp directory: {tempDir.FullName}");
26+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.FullName, isDeploy: true);
27+
// Configure Azure settings to avoid prompting during deployment for this test case
28+
builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012";
29+
builder.Configuration["Azure:ResourceGroup"] = "test-rg";
30+
builder.Configuration["Azure:Location"] = "westus2";
31+
32+
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
33+
34+
// Add a container that will use the container app environment
35+
builder.AddContainer("api", "my-api-image:latest")
36+
.WithHttpEndpoint();
37+
38+
// Act
39+
using var app = builder.Build();
40+
app.Run();
41+
42+
// Assert files exist but don't verify contents
43+
var mainBicepPath = Path.Combine(tempDir.FullName, "main.bicep");
44+
Assert.True(File.Exists(mainBicepPath));
45+
var envBicepPath = Path.Combine(tempDir.FullName, "env", "env.bicep");
46+
Assert.True(File.Exists(envBicepPath));
47+
48+
tempDir.Delete(recursive: true);
49+
}
50+
51+
[Fact]
52+
public async Task DeployAsync_PromptsViaInteractionService()
53+
{
54+
// Arrange
55+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
56+
var testInteractionService = new TestInteractionService();
57+
ConfigureTestServices(builder, testInteractionService);
58+
59+
// Add an Azure environment resource which will trigger the deployment prompting
60+
builder.AddAzureEnvironment();
61+
62+
// Act
63+
using var app = builder.Build();
64+
65+
var runTask = Task.Run(app.Run);
66+
67+
// Assert - Wait for the first interaction (message bar)
68+
var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync();
69+
Assert.Equal("Azure provisioning", messageBarInteraction.Title);
70+
Assert.Contains("Azure resources that require an Azure Subscription", messageBarInteraction.Message ?? "");
71+
72+
// Complete the message bar interaction to proceed to inputs dialog
73+
messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true)); // Data = true (user clicked Enter Values)
74+
75+
// Wait for the inputs interaction
76+
var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync();
77+
Assert.Equal("Azure provisioning", inputsInteraction.Title);
78+
Assert.True(inputsInteraction.Options!.EnableMessageMarkdown);
79+
80+
// Verify the expected inputs for Azure provisioning
81+
Assert.Collection(inputsInteraction.Inputs,
82+
input =>
83+
{
84+
Assert.Equal("Location", input.Label);
85+
Assert.Equal(InputType.Choice, input.InputType);
86+
Assert.True(input.Required);
87+
},
88+
input =>
89+
{
90+
Assert.Equal("Subscription ID", input.Label);
91+
Assert.Equal(InputType.SecretText, input.InputType);
92+
Assert.True(input.Required);
93+
},
94+
input =>
95+
{
96+
Assert.Equal("Resource group", input.Label);
97+
Assert.Equal(InputType.Text, input.InputType);
98+
Assert.False(input.Required);
99+
});
100+
101+
// Complete the inputs interaction with valid values
102+
inputsInteraction.Inputs[0].Value = inputsInteraction.Inputs[0].Options!.First(kvp => kvp.Key == "westus").Value;
103+
inputsInteraction.Inputs[1].Value = "12345678-1234-1234-1234-123456789012";
104+
inputsInteraction.Inputs[2].Value = "test-rg";
105+
106+
inputsInteraction.CompletionTcs.SetResult(InteractionResult.Ok(inputsInteraction.Inputs));
107+
108+
// Wait for the run task to complete (or timeout)
109+
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
110+
}
111+
112+
private static void ConfigureTestServices(IDistributedApplicationTestingBuilder builder, IInteractionService interactionService)
113+
{
114+
var options = ProvisioningTestHelpers.CreateOptions(null, null, null);
115+
var environment = ProvisioningTestHelpers.CreateEnvironment();
116+
var logger = ProvisioningTestHelpers.CreateLogger();
117+
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
118+
var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider();
119+
var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider();
120+
builder.Services.AddSingleton(armClientProvider);
121+
builder.Services.AddSingleton(userPrincipalProvider);
122+
builder.Services.AddSingleton(tokenCredentialProvider);
123+
builder.Services.AddSingleton(environment);
124+
builder.Services.AddSingleton(logger);
125+
builder.Services.AddSingleton(options);
126+
builder.Services.AddSingleton(interactionService);
127+
builder.Services.AddSingleton<IProvisioningContextProvider, DefaultProvisioningContextProvider>();
128+
builder.Services.AddSingleton<IUserSecretsManager, NoOpUserSecretsManager>();
129+
}
130+
131+
private sealed class NoOpUserSecretsManager : IUserSecretsManager
132+
{
133+
public Task<JsonObject> LoadUserSecretsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new JsonObject());
134+
135+
public Task SaveUserSecretsAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) => Task.CompletedTask;
136+
}
137+
}

0 commit comments

Comments
 (0)