diff --git a/src/Bicep.Cli.IntegrationTests/Commands/LocalDeployCommandTests.cs b/src/Bicep.Cli.IntegrationTests/Commands/LocalDeployCommandTests.cs index e15edec1cd6..cbb32009a4d 100644 --- a/src/Bicep.Cli.IntegrationTests/Commands/LocalDeployCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/Commands/LocalDeployCommandTests.cs @@ -117,6 +117,18 @@ private ILocalExtension GetExtensionMock() return extensionMock.Object; } + private ILocalExtension GetFailingExtensionMock() + { + var extensionMock = StrictMock.Of(); + extensionMock.Setup(x => x.CreateOrUpdate(It.IsAny(), It.IsAny())) + .Returns((req, _) => + { + return Task.FromResult(new LocalExtensionOperationResponse(null, new(new("MyErrorCode", "Dummy error message")))); + }); + + return extensionMock.Object; + } + [TestMethod] public async Task Local_deploy_should_succeed() { @@ -268,7 +280,7 @@ public async Task Local_deploy_with_azure_should_succeed(bool async) } }, }, - []); + [], []); }); var services = await ExtensionTestHelper.GetServiceBuilderWithPublishedExtension(GetMockLocalDeployPackage(), new(LocalDeployEnabled: true)); @@ -299,4 +311,70 @@ public async Task Local_deploy_with_azure_should_succeed(bool async) """); } + + [TestMethod] + public async Task Local_deploy_should_report_nested_operations() + { + var paramFile = new EmbeddedFile(typeof(LocalDeployCommandTests).Assembly, "Files/LocalDeployCommandTests/weather/nested.bicepparam"); + var baselineFolder = BaselineFolder.BuildOutputFolder(TestContext, paramFile); + + var services = await ExtensionTestHelper.GetServiceBuilderWithPublishedExtension(GetMockLocalDeployPackage(), new(LocalDeployEnabled: true)); + var clientFactory = services.Build().Construct(); + + var cacheDirectory = FileHelper.GetCacheRootDirectory(TestContext).EnsureExists(); + + var result = await Bicep( + new InvocationSettings(ClientFactory: clientFactory, FeatureOverrides: new(CacheRootDirectory: cacheDirectory)), + services => RegisterExtensionMocks(services, GetExtensionMock()), + TestContext.CancellationTokenSource.Token, + ["local-deploy", baselineFolder.EntryFile.OutputFilePath]); + + result.Should().NotHaveStderr().And.Succeed(); + + result.WithoutAnsi().WithoutDurations().Stdout.Should().BeEquivalentToIgnoringNewlines(""" + ╭───────────────────────┬──────────┬───────────╮ + │ Resource │ Duration │ Status │ + ├───────────────────────┼──────────┼───────────┤ + │ main │ │ Succeeded │ + │ main -> gridpointsReq │ │ Succeeded │ + │ main -> forecastReq │ │ Succeeded │ + ╰───────────────────────┴──────────┴───────────╯ + + """); + } + + [TestMethod] + public async Task Local_deploy_should_report_nested_operation_failures() + { + var paramFile = new EmbeddedFile(typeof(LocalDeployCommandTests).Assembly, "Files/LocalDeployCommandTests/weather/nested.bicepparam"); + var baselineFolder = BaselineFolder.BuildOutputFolder(TestContext, paramFile); + + var services = await ExtensionTestHelper.GetServiceBuilderWithPublishedExtension(GetMockLocalDeployPackage(), new(LocalDeployEnabled: true)); + var clientFactory = services.Build().Construct(); + + var cacheDirectory = FileHelper.GetCacheRootDirectory(TestContext).EnsureExists(); + + var result = await Bicep( + new InvocationSettings(ClientFactory: clientFactory, FeatureOverrides: new(CacheRootDirectory: cacheDirectory)), + services => RegisterExtensionMocks(services, GetFailingExtensionMock()), + TestContext.CancellationTokenSource.Token, + ["local-deploy", baselineFolder.EntryFile.OutputFilePath]); + + result.Should().NotHaveStderr().And.Fail(); + + result.WithoutAnsi().WithoutDurations().Stdout.Should().BeEquivalentToIgnoringNewlines(""" + ╭───────────────────────┬──────────┬───────────────────────────────────────────╮ + │ Resource │ Duration │ Status │ + ├───────────────────────┼──────────┼───────────────────────────────────────────┤ + │ main │ │ DeploymentFailed: At least one resource │ + │ │ │ deployment operation failed. Please list │ + │ │ │ deployment operations for details. Please │ + │ │ │ see │ + │ │ │ https://aka.ms/arm-deployment-operations │ + │ │ │ for usage details. │ + │ main -> gridpointsReq │ │ MyErrorCode: Dummy error message │ + ╰───────────────────────┴──────────┴───────────────────────────────────────────╯ + + """); + } } diff --git a/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicep b/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicep new file mode 100644 index 00000000000..62c46a2440b --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicep @@ -0,0 +1,12 @@ +targetScope = 'local' + +param coords { + latitude: string + longitude: string +} + +module main 'main.bicep' = { + params: { + coords: coords + } +} diff --git a/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicepparam b/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicepparam new file mode 100644 index 00000000000..dba07b7d3b8 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/LocalDeployCommandTests/weather/nested.bicepparam @@ -0,0 +1,6 @@ +using 'nested.bicep' + +param coords = { + latitude: '47.6363726' + longitude: '-122.1357068' +} diff --git a/src/Bicep.Cli/Commands/LocalDeployCommand.cs b/src/Bicep.Cli/Commands/LocalDeployCommand.cs index c163eb122aa..1b6311bf105 100644 --- a/src/Bicep.Cli/Commands/LocalDeployCommand.cs +++ b/src/Bicep.Cli/Commands/LocalDeployCommand.cs @@ -71,7 +71,7 @@ private async Task ProcessDeployment(Compilation compilation, string templateStr await using var dispatcher = dispatcherFactory.Create(); await dispatcher.InitializeExtensions(compilation); - await dispatcher.Deploy(templateString, parametersString, result => onUpdate(new(DeploymentProcessor.GetDeploymentView(result.Deployment, result.Operations), null)), cancellationToken); + await dispatcher.Deploy(templateString, parametersString, result => onUpdate(new(DeploymentProcessor.GetDeploymentView(result), null)), cancellationToken); } catch (Exception exception) { diff --git a/src/Bicep.Cli/Helpers/Deploy/DeploymentProcessor.cs b/src/Bicep.Cli/Helpers/Deploy/DeploymentProcessor.cs index e7302ae3107..ed8a29644d6 100644 --- a/src/Bicep.Cli/Helpers/Deploy/DeploymentProcessor.cs +++ b/src/Bicep.Cli/Helpers/Deploy/DeploymentProcessor.cs @@ -22,6 +22,7 @@ using Bicep.Core.TypeSystem; using Bicep.Core.Utils; using Bicep.Core.Utils.Deployments; +using Bicep.Local.Deploy; using Microsoft.WindowsAzure.ResourceStack.Common.Json; using Newtonsoft.Json.Linq; @@ -480,10 +481,12 @@ private static DeploymentView GetDeploymentView(ArmDeploymentResource deployment Outputs: GetOutputs(deployment.Data)); } - public static DeploymentView GetDeploymentView(DeploymentContent deployment, IEnumerable operations) + public static DeploymentView GetDeploymentView(LocalDeploymentResult result) { + var deployment = result.Deployment; + List operationViews = []; - foreach (var operation in operations) + foreach (var (prefix, operation) in FlattenOperations(result, string.Empty)) { if (operation.Properties.TargetResource is null) { @@ -501,7 +504,7 @@ public static DeploymentView GetDeploymentView(DeploymentContent deployment, IEn operationViews.Add(new( Id: operation.Properties.TargetResource.Id, Name: operation.Properties.TargetResource.ResourceName, - SymbolicName: operation.Properties.TargetResource.SymbolicName, + SymbolicName: $"{prefix}{operation.Properties.TargetResource.SymbolicName}", Type: operation.Properties.TargetResource.ResourceType!, State: operationState, StartTime: operation.Properties.Timestamp!.Value, @@ -520,4 +523,20 @@ public static DeploymentView GetDeploymentView(DeploymentContent deployment, IEn Error: GetError(deployment), Outputs: GetOutputs(deployment)); } + + private static IEnumerable<(string prefix, DeploymentOperationDefinition operation)> FlattenOperations(LocalDeploymentResult result, string prefix) + { + foreach (var operation in result.Operations) + { + yield return (prefix, operation); + } + + foreach (var (name, childDeployment) in result.ChildDeployments) + { + foreach (var item in FlattenOperations(childDeployment, $"{prefix}{name} -> ")) + { + yield return item; + } + } + } } diff --git a/src/Bicep.Cli/Helpers/Deploy/DeploymentRenderer.cs b/src/Bicep.Cli/Helpers/Deploy/DeploymentRenderer.cs index 5cedf757f01..9ad5cd24535 100644 --- a/src/Bicep.Cli/Helpers/Deploy/DeploymentRenderer.cs +++ b/src/Bicep.Cli/Helpers/Deploy/DeploymentRenderer.cs @@ -24,8 +24,8 @@ public bool RenderDeployment(Table table, DeploymentWrapperView? view, ref bool } var orderedOperations = deployment.Operations - .OrderBy(x => x.EndTime is { } ? x.EndTime.Value : DateTime.MaxValue) - .ThenBy(x => x.StartTime) + .OrderBy(x => x.StartTime) + .ThenBy(x => x.EndTime is { } ? x.EndTime.Value : DateTime.MaxValue) .ToList(); table.Rows.Clear(); diff --git a/src/Bicep.Local.Deploy/Azure/ArmDeploymentProvider.cs b/src/Bicep.Local.Deploy/Azure/ArmDeploymentProvider.cs index 47cfde26f05..f479ee88265 100644 --- a/src/Bicep.Local.Deploy/Azure/ArmDeploymentProvider.cs +++ b/src/Bicep.Local.Deploy/Azure/ArmDeploymentProvider.cs @@ -71,6 +71,6 @@ public async Task CheckDeployment(RootConfiguration confi var response = await deploymentsClient.GetAsync(deploymentLocator.DeploymentName, cancellationToken); var content = response.GetRawResponse().Content.ToString().FromDeploymentsJson(); - return new(content, []); + return new(content, [], []); } } diff --git a/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngine.cs b/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngine.cs index 27d834868f7..163e7843908 100644 --- a/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngine.cs +++ b/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngine.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.Net; using Azure.Deployments.Core.Constants; using Azure.Deployments.Core.Definitions; @@ -19,36 +20,19 @@ namespace Bicep.Local.Deploy.Engine; -public class LocalDeploymentEngine +public class LocalDeploymentEngine( + LocalRequestContext requestContext, + IAzureDeploymentSettings settings, + AzureDeploymentEngine deploymentEngine, + IDataProviderHolder dataProviderHolder, + IDeploymentJobsDataProvider jobProvider, + IDeploymentEntityFactory deploymentEntityFactory) { static LocalDeploymentEngine() { CoreConstants.ResourcesLimitOverrideForLocalDeploy = int.MaxValue; } - public LocalDeploymentEngine( - LocalRequestContext requestContext, - IAzureDeploymentSettings settings, - AzureDeploymentEngine deploymentEngine, - IDataProviderHolder dataProviderHolder, - IDeploymentJobsDataProvider jobProvider, - IDeploymentEntityFactory deploymentEntityFactory) - { - this.requestContext = requestContext; - this.settings = settings; - this.deploymentEngine = deploymentEngine; - this.dataProviderHolder = dataProviderHolder; - this.jobProvider = jobProvider; - this.deploymentEntityFactory = deploymentEntityFactory; - } - - private readonly LocalRequestContext requestContext; - private readonly IAzureDeploymentSettings settings; - private readonly AzureDeploymentEngine deploymentEngine; - private readonly IDataProviderHolder dataProviderHolder; - private readonly IDeploymentJobsDataProvider jobProvider; - private readonly IDeploymentEntityFactory deploymentEntityFactory; - private static (Template template, Dictionary parameters, OrdinalDictionary>? extensionConfigs) ParseTemplateAndParameters(string templateString, string parametersString) { var template = TemplateParsingEngine.ParseTemplate(templateString); @@ -95,7 +79,7 @@ public async Task StartDeployment(string name, string templateString, string par var oboToken = "dummyToken"; var oboCorrelationId = RequestCorrelationContext.Current.CorrelationId; - var deploymentPlan = await this.deploymentEngine.ProcessDeployment( + var deploymentPlan = await deploymentEngine.ProcessDeployment( preflightSettings: new(settings, syncMode: true), deploymentContext: context, definition: definition, @@ -130,13 +114,70 @@ public async Task CheckDeployment(string name) var deploymentDataProvider = await dataProviderHolder.GetDeploymentDataProviderAsync(requestContext.Location); var entity = await deploymentDataProvider.FindDeployment(context.SubscriptionId, context.ResourceGroupName, context.DeploymentName); - var operationEntities = await deploymentDataProvider.FindDeploymentOperations(context.SubscriptionId, context.ResourceGroupName, context.DeploymentName, entity.SequenceId, -1); + var operationEntities = await deploymentDataProvider.FindDeploymentOperations( + context.SubscriptionId, + context.ResourceGroupName, + context.DeploymentName, + entity.SequenceId, + int.MaxValue, + includeOrphanSequences: true); + + var deploymentEntityBySequence = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [entity.SequenceId] = entity, + }; + + async Task GetDeploymentEntityForSequence(string deploymentSequenceId) + { + if (deploymentEntityBySequence.TryGetValue(deploymentSequenceId, out var cachedEntity)) + { + return cachedEntity; + } + + var deploymentEntity = await deploymentDataProvider.FindDeployment( + context.SubscriptionId, + context.ResourceGroupName, + context.DeploymentName, + deploymentSequenceId); + + deploymentEntityBySequence[deploymentSequenceId] = deploymentEntity; + + return deploymentEntity; + } + + List operationDefinitions = []; + foreach (var operation in operationEntities.Where(operation => operation.TargetResource is not null)) + { + var deploymentEntity = await GetDeploymentEntityForSequence(operation.DeploymentSequenceId); + operationDefinitions.Add(deploymentEngine.CreateDeploymentOperationDefinition(deploymentEntity, operation, requestContext.Location)); + } + + ImmutableArray operations = [.. operationDefinitions]; + + var childDeployments = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var operation in operations) + { + if (operation.Properties.TargetResource is not { } targetResource || + targetResource.Extension?.Alias is not "az0synthesized" || + targetResource.Identifiers?["name"]?.ToString() is not { } childDeploymentName) + { + continue; + } + + // Only recurse into deployments that exist locally. Modules deployed to other scopes + // (e.g. Azure) are not tracked here, so their wrapper operation is kept as a single row. + if (await deploymentDataProvider.FindDeployment(context.SubscriptionId, context.ResourceGroupName, childDeploymentName) is null) + { + continue; + } + + childDeployments[targetResource.SymbolicName] = await CheckDeployment(childDeploymentName); + } return new( deploymentEngine.CreateDeploymentDefinition(entity, requestContext.ApiVersion), - [.. operationEntities - .Where(operation => operation.TargetResource is not null) - .Select(operation => deploymentEngine.CreateDeploymentOperationDefinition(entity, operation, requestContext.Location))]); + operations, + childDeployments.ToImmutable()); } } diff --git a/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngineHost.cs b/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngineHost.cs index b37f8a903de..72ac312dec2 100644 --- a/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngineHost.cs +++ b/src/Bicep.Local.Deploy/Engine/LocalDeploymentEngineHost.cs @@ -119,10 +119,7 @@ public override void AddAsyncNotificationUri(HttpRequestHeaders httpHeaders, Bac => throw new NotImplementedException(); public override EnablementConfig GetEnablementConfig(PreviewDeploymentFunction feature) - => new() - { - FeatureName = feature.ToString() - }; + => enablementConfigProvider.GetEnablementConfig(feature); public override IResourceTypeMetadataProvider GetResourceTypeMetadataProvider() => throw new NotImplementedException(); diff --git a/src/Bicep.Local.Deploy/Engine/LocalDeploymentSettings.cs b/src/Bicep.Local.Deploy/Engine/LocalDeploymentSettings.cs index fe2fd6b7be3..4467fa85107 100644 --- a/src/Bicep.Local.Deploy/Engine/LocalDeploymentSettings.cs +++ b/src/Bicep.Local.Deploy/Engine/LocalDeploymentSettings.cs @@ -116,9 +116,9 @@ public class LocalDeploymentSettings : IAzureDeploymentSettings public string[] AsyncOperationCallbackAllowedProviders { get; set; } = []; - public TimeSpan ResourceMaximumRetryInterval { get; set; } = TimeSpan.FromMinutes(10); + public TimeSpan ResourceMaximumRetryInterval { get; set; } = TimeSpan.FromSeconds(10); - public TimeSpan ResourceMinimumRetryInterval { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan ResourceMinimumRetryInterval { get; set; } = TimeSpan.FromSeconds(1); public TimeSpan ResourceNotificationBasedDefaultRetryInterval { get; set; } = TimeSpan.FromMinutes(2); diff --git a/src/Bicep.Local.Deploy/LocalDeploymentResult.cs b/src/Bicep.Local.Deploy/LocalDeploymentResult.cs index 552600960be..38f25f9e86a 100644 --- a/src/Bicep.Local.Deploy/LocalDeploymentResult.cs +++ b/src/Bicep.Local.Deploy/LocalDeploymentResult.cs @@ -2,14 +2,11 @@ // Licensed under the MIT License. using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; using Azure.Deployments.Core.Definitions; -using Bicep.Local.Deploy.Extensibility; -using Microsoft.Extensions.DependencyInjection; namespace Bicep.Local.Deploy; public record LocalDeploymentResult( DeploymentContent Deployment, - ImmutableArray Operations); + ImmutableArray Operations, + ImmutableDictionary ChildDeployments);