diff --git a/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs b/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs deleted file mode 100644 index daa694ca76..0000000000 --- a/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Azure; -using Azure.Core; -using Azure.ResourceManager; -using Azure.ResourceManager.Resources; -using Calamari.Azure; -using Calamari.CloudAccounts; -using Calamari.Common.Features.Deployment; -using Calamari.Common.Features.Scripts; -using Calamari.Common.Plumbing.Variables; -using Calamari.Testing; -using Calamari.Testing.Azure; -using Calamari.Testing.Helpers; -using Calamari.Testing.Requirements; -using Newtonsoft.Json.Linq; -using NUnit.Framework; - -// ReSharper disable MethodHasAsyncOverload - File.ReadAllTextAsync does not exist for .net framework targets - -namespace Calamari.AzureResourceGroup.Tests -{ - [TestFixture] - [Category(TestCategory.ExternalCloudIntegration)] - class AzureResourceGroupActionHandlerFixture - { - string clientId; - string clientSecret; - string tenantId; - string subscriptionId; - static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); - readonly CancellationToken cancellationToken = CancellationTokenSource.Token; - - ArmClient armClient; - SubscriptionResource subscriptionResource; - ResourceGroupResource resourceGroupResource; - string resourceGroupName; - - [OneTimeSetUp] - public async Task Setup() - { - var resourceManagementEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? DefaultVariables.ResourceManagementEndpoint; - var activeDirectoryEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? DefaultVariables.ActiveDirectoryEndpoint; - - clientId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId, cancellationToken); - clientSecret = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword, cancellationToken); - tenantId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId, cancellationToken); - subscriptionId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionId, cancellationToken); - - var resourceGroupLocation = Environment.GetEnvironmentVariable("AZURE_NEW_RESOURCE_REGION") ?? RandomAzureRegion.GetRandomRegionWithExclusions(); - - resourceGroupName = AzureTestResourceHelpers.GetResourceGroupName(); - - var servicePrincipalAccount = new AzureServicePrincipalAccount(subscriptionId, - clientId, - tenantId, - clientSecret, - "AzureGlobalCloud", - resourceManagementEndpointBaseUri, - activeDirectoryEndpointBaseUri); - - armClient = servicePrincipalAccount.CreateArmClient(retryOptions => - { - retryOptions.MaxRetries = 5; - retryOptions.Mode = RetryMode.Exponential; - retryOptions.Delay = TimeSpan.FromSeconds(2); - retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); - }); - - //create the resource group - subscriptionResource = armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)); - - var response = await subscriptionResource - .GetResourceGroups() - .CreateOrUpdateAsync(WaitUntil.Completed, - resourceGroupName, - new ResourceGroupData(new AzureLocation(resourceGroupLocation)) - { - Tags = - { - [AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysKey] = AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysValue, - [AzureTestResourceHelpers.ResourceGroupTags.SourceKey] = AzureTestResourceHelpers.ResourceGroupTags.SourceValue - } - }); - - resourceGroupResource = response.Value; - } - - [OneTimeTearDown] - public async Task Cleanup() - { - await armClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(subscriptionId, resourceGroupName)) - .DeleteAsync(WaitUntil.Started); - } - - [Test] - public async Task Deploy_with_template_in_package() - { - var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add("Octopus.Action.Azure.TemplateSource", "Package"); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplate", "azure_website_template.json"); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplateParameters", "azure_website_params.json"); - context.WithFilesToCopy(packagePath); - }) - .Execute(); - } - - [Test] - public async Task Deploy_with_template_in_git_repository() - { - // For the purposes of ARM templates in Calamari, a template in a Git Repository - // is equivalent to a template in a package, so we can just re-use the same - // package in the test here, it's just the template source property that is - // different. - var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add("Octopus.Action.Azure.TemplateSource", "GitRepository"); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplate", "azure_website_template.json"); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplateParameters", "azure_website_params.json"); - context.WithFilesToCopy(packagePath); - }) - .Execute(); - } - - [Test] - public async Task Deploy_with_template_inline() - { - var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); - var templateFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json")); - var paramsFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_params.json")); - var parameters = JObject.Parse(paramsFileContent)["parameters"].ToString(); - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add("Octopus.Action.Azure.TemplateSource", "Inline"); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplate, File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json"))); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplateParameters, parameters); - - context.WithFilesToCopy(packagePath); - - AddTemplateFiles(context, templateFileContent, paramsFileContent); - }) - .Execute(); - } - - [Test] - [WindowsTest] - [RequiresPowerShell5OrAbove] - public async Task Deploy_Ensure_Tools_Are_Configured() - { - var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); - var templateFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json")); - var paramsFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_params.json")); - var parameters = JObject.Parse(paramsFileContent)["parameters"].ToString(); - const string psScript = @" -$ErrorActionPreference = 'Continue' -az --version -Get-AzureEnvironment -az group list"; - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add("Octopus.Action.Azure.TemplateSource", "Inline"); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplate, File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json"))); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplateParameters, parameters); - context.Variables.Add(KnownVariables.Package.EnabledFeatures, KnownVariables.Features.CustomScripts); - context.Variables.Add(KnownVariables.Action.CustomScripts.GetCustomScriptStage(DeploymentStages.Deploy, ScriptSyntax.PowerShell), psScript); - context.Variables.Add(KnownVariables.Action.CustomScripts.GetCustomScriptStage(DeploymentStages.PreDeploy, ScriptSyntax.CSharp), "Console.WriteLine(\"Hello from C#\");"); - - context.WithFilesToCopy(packagePath); - - AddTemplateFiles(context, templateFileContent, paramsFileContent); - }) - .Execute(); - } - - private void AddDefaults(CommandTestBuilderContext context) - { - context.Variables.Add("Octopus.Account.AccountType", "AzureServicePrincipal"); - context.Variables.Add(AzureAccountVariables.SubscriptionId, subscriptionId); - context.Variables.Add(AzureAccountVariables.TenantId, tenantId); - context.Variables.Add(AzureAccountVariables.ClientId, clientId); - context.Variables.Add(AzureAccountVariables.Password, clientSecret); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, resourceGroupName); - context.Variables.Add("ResourceGroup", resourceGroupName); - context.Variables.Add("SKU", "Shared"); - //as we have a single resource group, we need to have unique web app name per test - context.Variables.Add("WebSite", $"Calamari-{Guid.NewGuid():N}"); - context.Variables.Add("Location", resourceGroupResource.Data.Location); - //this is a storage account prefix, so just make it as random as possible - //The names of the storage accounts are a max of 7 chars, so we generate a prefix of 17 chars (storage accounts have a max of 24) - context.Variables.Add("AccountPrefix", AzureTestResourceHelpers.RandomName(length: 17)); - } - - private static void AddTemplateFiles(CommandTestBuilderContext context, string template, string parameters) - { - context.WithDataFile(template, "template.json"); - context.WithDataFile(parameters, "parameters.json"); - } - } -} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj b/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj index 55a49cc53c..f0e58dc3d8 100644 --- a/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj +++ b/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs deleted file mode 100644 index 6ce8922365..0000000000 --- a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Azure; -using Azure.Core; -using Azure.ResourceManager; -using Azure.ResourceManager.Resources; -using Calamari.Azure; -using Calamari.AzureResourceGroup.Bicep; -using Calamari.CloudAccounts; -using Calamari.Testing; -using Calamari.Testing.Azure; -using Calamari.Testing.Helpers; -using Calamari.Testing.Requirements; -using NUnit.Framework; - -namespace Calamari.AzureResourceGroup.Tests -{ - [TestFixture] - [WindowsTest] // NOTE: We should look at having the Azure CLI installed on Linux boxes so that these steps can be tested there, particularly if we're moving cloud to a Ubuntu Default Worker. - class DeployAzureBicepTemplateCommandFixture - { - string clientId; - string clientSecret; - string tenantId; - string subscriptionId; - string resourceGroupName; - string resourceGroupLocation; - ArmClient armClient; - static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - readonly CancellationToken cancellationToken = CancellationTokenSource.Token; - readonly string packagePath = TestEnvironment.GetTestPath("Packages", "Bicep"); - SubscriptionResource subscriptionResource; - - const string ParameterContent = """[{"Key":"storageAccountName","Value":"#{StorageAccountName}"},{"Key":"location","Value":"#{Location}"},{"Key":"sku","Value":"#{SKU}"}]"""; - - [OneTimeSetUp] - public async Task Setup() - { - var resourceManagementEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? DefaultVariables.ResourceManagementEndpoint; - var activeDirectoryEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? DefaultVariables.ActiveDirectoryEndpoint; - - clientId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId, cancellationToken); - clientSecret = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword, cancellationToken); - tenantId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId, cancellationToken); - subscriptionId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionId, cancellationToken); - - resourceGroupName = AzureTestResourceHelpers.GetResourceGroupName(); - - resourceGroupLocation = Environment.GetEnvironmentVariable("AZURE_NEW_RESOURCE_REGION") ?? RandomAzureRegion.GetRandomRegionWithExclusions(); - - var servicePrincipalAccount = new AzureServicePrincipalAccount(subscriptionId, - clientId, - tenantId, - clientSecret, - "AzureGlobalCloud", - resourceManagementEndpointBaseUri, - activeDirectoryEndpointBaseUri); - - armClient = servicePrincipalAccount.CreateArmClient(retryOptions => - { - retryOptions.MaxRetries = 5; - retryOptions.Mode = RetryMode.Exponential; - retryOptions.Delay = TimeSpan.FromSeconds(2); - retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); - }); - - //create the resource group - subscriptionResource = armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)); - - await subscriptionResource - .GetResourceGroups() - .CreateOrUpdateAsync(WaitUntil.Completed, - resourceGroupName, - new ResourceGroupData(new AzureLocation(resourceGroupLocation)) - { - Tags = - { - [AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysKey] = AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysValue, - [AzureTestResourceHelpers.ResourceGroupTags.SourceKey] = AzureTestResourceHelpers.ResourceGroupTags.SourceValue - } - }); - } - - [OneTimeTearDown] - public async Task Cleanup() - { - await armClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(subscriptionId, resourceGroupName)) - .DeleteAsync(WaitUntil.Started); - } - - [Test] - [RequiresWindowsServer2016OrAbove("This test requires the az cli, which relies on python 3.10, which doesn't run on windows 2012/2012R2")] - public async Task DeployAzureBicepTemplate_PackageSource() - { - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.TemplateSource, "Package"); - context.Variables.Add(SpecialVariables.Action.Azure.BicepTemplate, "azure_website_template.bicep"); - context.WithFilesToCopy(packagePath); - }) - .Execute(); - } - - [Test] - [RequiresWindowsServer2016OrAbove("This test requires the az cli, which relies on python 3.10, which doesn't run on windows 2012/2012R2")] - public async Task DeployAzureBicepTemplate_GitSource() - { - // For the purposes of Bicep templates in Calamari, a template in a Git Repository - // is equivalent to a template in a package, so we can just re-use the same - // package in the test here, it's just the template source property that is - // different. - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.TemplateSource, "GitRepository"); - context.Variables.Add(SpecialVariables.Action.Azure.BicepTemplate, "azure_website_template.bicep"); - context.WithFilesToCopy(packagePath); - }) - .Execute(); - } - - [Test] - [RequiresWindowsServer2016OrAbove("This test requires the az cli, which relies on python 3.10, which doesn't run on windows 2012/2012R2")] - public async Task DeployAzureBicepTemplate_InlineSource() - { - var templateFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_template.bicep")); - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - AddDefaults(context); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add(SpecialVariables.Action.Azure.TemplateSource, "Inline"); - context.WithDataFile(templateFileContent, "template.bicep"); - }) - .Execute(); - } - - void AddDefaults(CommandTestBuilderContext context) - { - context.Variables.Add(AzureScripting.SpecialVariables.Account.AccountType, "AzureServicePrincipal"); - context.Variables.Add(AzureAccountVariables.SubscriptionId, subscriptionId); - context.Variables.Add(AzureAccountVariables.TenantId, tenantId); - context.Variables.Add(AzureAccountVariables.ClientId, clientId); - context.Variables.Add(AzureAccountVariables.Password, clientSecret); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, resourceGroupName); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupLocation, resourceGroupLocation); - context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); - context.Variables.Add(SpecialVariables.Action.Azure.BicepTemplateParameters, ParameterContent); - - context.Variables.Add("SKU", "Standard_LRS"); - context.Variables.Add("Location", resourceGroupLocation); - //storage accounts can be 24 chars long - context.Variables.Add("StorageAccountName", AzureTestResourceHelpers.RandomName(length: 24)); - } - } -} diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployAzureResourceGroupBehaviourUnitTestFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployAzureResourceGroupBehaviourUnitTestFixture.cs new file mode 100644 index 0000000000..731af57b92 --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/DeployAzureResourceGroupBehaviourUnitTestFixture.cs @@ -0,0 +1,151 @@ +using System; +using System.Threading.Tasks; +using Azure.ResourceManager.Resources.Models; +using Calamari.CloudAccounts; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing.Helpers; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.AzureResourceGroup.Tests +{ + // Covers DeployAzureResourceGroupBehaviour's template-source resolution, parameter normalisation and + // deployment mode/name logic with a mocked IAzureResourceGroupOperator + ITemplateService. These + // scenarios previously only ran against real Azure in AzureResourceGroupActionHandlerFixture. + [TestFixture] + public class DeployAzureResourceGroupBehaviourUnitTestFixture + { + const string SubscriptionId = "sub-id"; + const string ResourceGroupName = "my-rg"; + + ITemplateService templateService; + IResourceGroupTemplateNormalizer normalizer; + IAzureResourceGroupOperator resourceGroupOperator; + DeployAzureResourceGroupBehaviour sut; + + [SetUp] + public void SetUp() + { + templateService = Substitute.For(); + normalizer = Substitute.For(); + resourceGroupOperator = Substitute.For(); + normalizer.Normalize(Arg.Any()).Returns(ci => $"normalized:{ci.Arg()}"); + sut = new DeployAzureResourceGroupBehaviour(templateService, normalizer, new InMemoryLog(), resourceGroupOperator); + } + + [Test] + public async Task PackageSource_DeploysSubstitutedTemplateAndNormalisedParameters() + { + templateService.GetSubstitutedTemplateContent("template.json", true, Arg.Any()).Returns("TEMPLATE"); + templateService.GetSubstitutedTemplateContent("params.json", true, Arg.Any()).Returns("PARAMS"); + + var context = ContextFor("Package", deploymentMode: "Complete", extra: v => + { + v.Add(SpecialVariables.Action.Azure.ResourceGroupTemplate, "template.json"); + v.Add(SpecialVariables.Action.Azure.ResourceGroupTemplateParameters, "params.json"); + }); + + await sut.Execute(context); + + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + Arg.Any(), ArmDeploymentMode.Complete, "TEMPLATE", "normalized:PARAMS", Arg.Any()); + } + + [Test] + public async Task GitRepositorySource_ResolvesTemplateTheSameWayAsPackage() + { + // GitRepository and Package take the identical "files in package or repository" branch, so the + // cloud fixture needs no separate Git deploy test. + templateService.GetSubstitutedTemplateContent("template.json", true, Arg.Any()).Returns("TEMPLATE"); + templateService.GetSubstitutedTemplateContent("params.json", true, Arg.Any()).Returns("PARAMS"); + + var context = ContextFor("GitRepository", deploymentMode: "Complete", extra: v => + { + v.Add(SpecialVariables.Action.Azure.ResourceGroupTemplate, "template.json"); + v.Add(SpecialVariables.Action.Azure.ResourceGroupTemplateParameters, "params.json"); + }); + + await sut.Execute(context); + + templateService.Received().GetSubstitutedTemplateContent("template.json", true, Arg.Any()); + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + Arg.Any(), ArmDeploymentMode.Complete, "TEMPLATE", "normalized:PARAMS", Arg.Any()); + } + + [Test] + public async Task InlineSource_ResolvesTemplateFromInlineFilesNotPackage() + { + templateService.GetSubstitutedTemplateContent("template.json", false, Arg.Any()).Returns("INLINE_TEMPLATE"); + templateService.GetSubstitutedTemplateContent("parameters.json", false, Arg.Any()).Returns("INLINE_PARAMS"); + + var context = ContextFor("Inline", deploymentMode: "Complete"); + + await sut.Execute(context); + + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + Arg.Any(), ArmDeploymentMode.Complete, "INLINE_TEMPLATE", "normalized:INLINE_PARAMS", Arg.Any()); + } + + [Test] + public async Task DeploymentName_UsesExplicitVariableWhenProvided() + { + templateService.GetSubstitutedTemplateContent(Arg.Any(), Arg.Any(), Arg.Any()).Returns("T"); + + var context = ContextFor("Inline", deploymentMode: "Complete", extra: v => + v.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentName, "explicit-deployment")); + + await sut.Execute(context); + + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + "explicit-deployment", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task DeploymentName_DerivedFromStepNameWhenNotProvided() + { + templateService.GetSubstitutedTemplateContent(Arg.Any(), Arg.Any(), Arg.Any()).Returns("T"); + + var context = ContextFor("Inline", deploymentMode: "Complete", extra: v => + v.Add(ActionVariables.Name, "My Deploy Step")); + + await sut.Execute(context); + + // FromStepName sanitises the step name and appends a random suffix, so assert the prefix. + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + Arg.Is(name => name.StartsWith("my-deploy-step-")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestCase("Complete", ArmDeploymentMode.Complete)] + [TestCase("Incremental", ArmDeploymentMode.Incremental)] + public async Task DeploymentMode_IsParsedFromVariable(string modeVariable, ArmDeploymentMode expectedMode) + { + templateService.GetSubstitutedTemplateContent(Arg.Any(), Arg.Any(), Arg.Any()).Returns("T"); + + var context = ContextFor("Inline", deploymentMode: modeVariable); + + await sut.Execute(context); + + await resourceGroupOperator.Received(1).Deploy(Arg.Any(), SubscriptionId, ResourceGroupName, + Arg.Any(), expectedMode, Arg.Any(), Arg.Any(), Arg.Any()); + } + + static RunningDeployment ContextFor(string templateSource, string deploymentMode, Action extra = null) + { + var variables = new CalamariVariables(); + + // Dummy credentials - the operator that would use them to reach Azure is mocked in these tests. + variables.Add(AzureAccountVariables.SubscriptionId, SubscriptionId); + variables.Add(AzureAccountVariables.ClientId, "client-id"); + variables.Add(AzureAccountVariables.TenantId, "tenant-id"); + variables.Add(AzureAccountVariables.Password, "client-secret"); + + variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, deploymentMode); + variables.Add(SpecialVariables.Action.Azure.TemplateSource, templateSource); + + extra?.Invoke(variables); + return new RunningDeployment("", variables); + } + } +} diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployBicepTemplateBehaviourUnitTestFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployBicepTemplateBehaviourUnitTestFixture.cs new file mode 100644 index 0000000000..48055d4d73 --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/DeployBicepTemplateBehaviourUnitTestFixture.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading.Tasks; +using Azure.ResourceManager.Resources.Models; +using Calamari.AzureResourceGroup.Bicep; +using Calamari.CloudAccounts; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing.Helpers; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.AzureResourceGroup.Tests +{ + // Covers Bicep template-source resolution and the compile -> substitute -> deploy wiring of + // DeployBicepTemplateBehaviour with a mocked IBicepTemplateBuilder (the az-cli step) + IAzureResourceGroupOperator. + // These previously only ran against real Azure (and needed the az cli) in DeployAzureBicepTemplateCommandFixture. + [TestFixture] + public class DeployBicepTemplateBehaviourUnitTestFixture + { + const string SubscriptionId = "sub-id"; + const string ResourceGroupName = "my-rg"; + const string ResourceGroupLocation = "australiaeast"; + const string CompiledArmTemplatePath = "/working/ARMTemplate.json"; + + IBicepTemplateBuilder bicepCompiler; + ITemplateService templateService; + IAzureResourceGroupOperator resourceGroupOperator; + DeployBicepTemplateBehaviour sut; + + [SetUp] + public void SetUp() + { + bicepCompiler = Substitute.For(); + templateService = Substitute.For(); + resourceGroupOperator = Substitute.For(); + + bicepCompiler.BuildArmTemplate(Arg.Any(), Arg.Any()).Returns(CompiledArmTemplatePath); + // Must be valid JSON - BicepToArmParameterMapper.Map parses it. + templateService.GetSubstitutedTemplateContent(CompiledArmTemplatePath, Arg.Any(), Arg.Any()).Returns("{}"); + + sut = new DeployBicepTemplateBehaviour(bicepCompiler, templateService, resourceGroupOperator, new InMemoryLog()); + } + + [Test] + public async Task PackageSource_CompilesBicepTemplateFromPackageAndDeploys() + { + var context = ContextFor("Package", v => v.Add(SpecialVariables.Action.Azure.BicepTemplate, "my-template.bicep")); + + await sut.Execute(context); + + bicepCompiler.Received(1).BuildArmTemplate(Arg.Any(), "my-template.bicep"); + templateService.Received(1).GetSubstitutedTemplateContent(CompiledArmTemplatePath, true, Arg.Any()); + await resourceGroupOperator.Received(1).DeployCreatingResourceGroup(Arg.Any(), SubscriptionId, ResourceGroupName, + ResourceGroupLocation, Arg.Any(), ArmDeploymentMode.Complete, "{}", Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GitRepositorySource_ResolvesBicepTemplateTheSameWayAsPackage() + { + // GitRepository and Package take the identical branch - the cloud fixture needs no separate Git test. + var context = ContextFor("GitRepository", v => v.Add(SpecialVariables.Action.Azure.BicepTemplate, "my-template.bicep")); + + await sut.Execute(context); + + bicepCompiler.Received(1).BuildArmTemplate(Arg.Any(), "my-template.bicep"); + templateService.Received(1).GetSubstitutedTemplateContent(CompiledArmTemplatePath, true, Arg.Any()); + } + + [Test] + public async Task InlineSource_CompilesDefaultBicepFileNotFromPackage() + { + var context = ContextFor("Inline"); + + await sut.Execute(context); + + // Inline falls back to the default "template.bicep" file written to the working directory. + bicepCompiler.Received(1).BuildArmTemplate(Arg.Any(), "template.bicep"); + templateService.Received(1).GetSubstitutedTemplateContent(CompiledArmTemplatePath, false, Arg.Any()); + } + + [TestCase("Complete", ArmDeploymentMode.Complete)] + [TestCase("Incremental", ArmDeploymentMode.Incremental)] + public async Task DeploymentMode_IsParsedFromVariable(string modeVariable, ArmDeploymentMode expectedMode) + { + var context = ContextFor("Inline", deploymentMode: modeVariable); + + await sut.Execute(context); + + await resourceGroupOperator.Received(1).DeployCreatingResourceGroup(Arg.Any(), SubscriptionId, ResourceGroupName, + ResourceGroupLocation, Arg.Any(), expectedMode, Arg.Any(), Arg.Any(), Arg.Any()); + } + + static RunningDeployment ContextFor(string templateSource, Action extra = null, string deploymentMode = "Complete") + { + var variables = new CalamariVariables(); + + variables.Add("Octopus.Account.AccountType", "AzureServicePrincipal"); + // Dummy credentials - the operator and compiler that would use them are mocked in these tests. + variables.Add(AzureAccountVariables.SubscriptionId, SubscriptionId); + variables.Add(AzureAccountVariables.ClientId, "client-id"); + variables.Add(AzureAccountVariables.TenantId, "tenant-id"); + variables.Add(AzureAccountVariables.Password, "client-secret"); + + variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + variables.Add(SpecialVariables.Action.Azure.ResourceGroupLocation, ResourceGroupLocation); + variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, deploymentMode); + variables.Add(SpecialVariables.Action.Azure.TemplateSource, templateSource); + + extra?.Invoke(variables); + return new RunningDeployment("", variables); + } + } +} diff --git a/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupActionHandlerFixture.cs b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupActionHandlerFixture.cs new file mode 100644 index 0000000000..d005497f1b --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupActionHandlerFixture.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Calamari.Common.Features.Deployment; +using Calamari.Common.Features.Scripts; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing; +using Calamari.Testing.Azure; +using Calamari.Testing.Helpers; +using Calamari.Testing.Requirements; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +// ReSharper disable MethodHasAsyncOverload - File.ReadAllTextAsync does not exist for .net framework targets + +namespace Calamari.AzureResourceGroup.Tests.ExternalCloudIntegration +{ + [TestFixture] + class AzureResourceGroupActionHandlerFixture : AzureResourceGroupCloudTestBase + { + [Test] + public async Task Deploy_with_template_in_package() + { + var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); + await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + AddDefaults(context); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); + context.Variables.Add("Octopus.Action.Azure.TemplateSource", "Package"); + context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplate", "azure_website_template.json"); + context.Variables.Add("Octopus.Action.Azure.ResourceGroupTemplateParameters", "azure_website_params.json"); + context.WithFilesToCopy(packagePath); + }) + .Execute(); + } + + [Test] + [WindowsTest] + [RequiresPowerShell5OrAbove] + public async Task Deploy_Ensure_Tools_Are_Configured() + { + var packagePath = TestEnvironment.GetTestPath("Packages", "AzureResourceGroup"); + var templateFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json")); + var paramsFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_params.json")); + var parameters = JObject.Parse(paramsFileContent)["parameters"].ToString(); + const string psScript = @" +$ErrorActionPreference = 'Continue' +az --version +Get-AzureEnvironment +az group list"; + + await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + AddDefaults(context); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); + context.Variables.Add("Octopus.Action.Azure.TemplateSource", "Inline"); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplate, File.ReadAllText(Path.Combine(packagePath, "azure_website_template.json"))); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupTemplateParameters, parameters); + context.Variables.Add(KnownVariables.Package.EnabledFeatures, KnownVariables.Features.CustomScripts); + context.Variables.Add(KnownVariables.Action.CustomScripts.GetCustomScriptStage(DeploymentStages.Deploy, ScriptSyntax.PowerShell), psScript); + context.Variables.Add(KnownVariables.Action.CustomScripts.GetCustomScriptStage(DeploymentStages.PreDeploy, ScriptSyntax.CSharp), "Console.WriteLine(\"Hello from C#\");"); + + context.WithFilesToCopy(packagePath); + + AddTemplateFiles(context, templateFileContent, paramsFileContent); + }) + .Execute(); + } + + void AddDefaults(CommandTestBuilderContext context) + { + context.Variables.Add("Octopus.Account.AccountType", "AzureServicePrincipal"); + context.Variables.Add(AzureAccountVariables.SubscriptionId, SubscriptionId); + context.Variables.Add(AzureAccountVariables.TenantId, TenantId); + context.Variables.Add(AzureAccountVariables.ClientId, ClientId); + context.Variables.Add(AzureAccountVariables.Password, ClientSecret); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + context.Variables.Add("ResourceGroup", ResourceGroupName); + context.Variables.Add("SKU", "Shared"); + //as we have a single resource group, we need to have unique web app name per test + context.Variables.Add("WebSite", $"Calamari-{Guid.NewGuid():N}"); + context.Variables.Add("Location", ResourceGroupResource.Data.Location); + //this is a storage account prefix, so just make it as random as possible + //The names of the storage accounts are a max of 7 chars, so we generate a prefix of 17 chars (storage accounts have a max of 24) + context.Variables.Add("AccountPrefix", AzureTestResourceHelpers.RandomName(length: 17)); + } + + static void AddTemplateFiles(CommandTestBuilderContext context, string template, string parameters) + { + context.WithDataFile(template, "template.json"); + context.WithDataFile(parameters, "parameters.json"); + } + } +} diff --git a/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupCloudTestBase.cs b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupCloudTestBase.cs new file mode 100644 index 0000000000..dceb188cd7 --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/AzureResourceGroupCloudTestBase.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Calamari.Azure; +using Calamari.CloudAccounts; +using Calamari.Testing; +using Calamari.Testing.Azure; +using Calamari.Testing.Helpers; +using NUnit.Framework; + +namespace Calamari.AzureResourceGroup.Tests.ExternalCloudIntegration +{ + // Shared base for the real-cloud resource-group smoke tests: authenticates with the test service principal, + // provisions a fresh resource group, and deletes it on teardown. Derived fixtures inherit the + // ExternalCloudIntegration category. + [Category(TestCategory.ExternalCloudIntegration)] + abstract class AzureResourceGroupCloudTestBase + { + protected string ClientId { get; private set; } + protected string ClientSecret { get; private set; } + protected string TenantId { get; private set; } + protected string SubscriptionId { get; private set; } + protected string ResourceGroupName { get; private set; } + protected string ResourceGroupLocation { get; private set; } + protected ArmClient ArmClient { get; private set; } + protected ResourceGroupResource ResourceGroupResource { get; private set; } + + static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + readonly CancellationToken cancellationToken = CancellationTokenSource.Token; + + [OneTimeSetUp] + public async Task CreateResourceGroup() + { + var resourceManagementEndpointBaseUri = + Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? DefaultVariables.ResourceManagementEndpoint; + var activeDirectoryEndpointBaseUri = + Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? DefaultVariables.ActiveDirectoryEndpoint; + + ClientId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId, cancellationToken); + ClientSecret = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword, cancellationToken); + TenantId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId, cancellationToken); + SubscriptionId = await ExternalVariables.Get(ExternalVariable.AzureSubscriptionId, cancellationToken); + + ResourceGroupLocation = Environment.GetEnvironmentVariable("AZURE_NEW_RESOURCE_REGION") ?? RandomAzureRegion.GetRandomRegionWithExclusions(); + ResourceGroupName = AzureTestResourceHelpers.GetResourceGroupName(); + + var servicePrincipalAccount = new AzureServicePrincipalAccount(SubscriptionId, + ClientId, + TenantId, + ClientSecret, + "AzureGlobalCloud", + resourceManagementEndpointBaseUri, + activeDirectoryEndpointBaseUri); + + ArmClient = servicePrincipalAccount.CreateArmClient(retryOptions => + { + retryOptions.MaxRetries = 5; + retryOptions.Mode = RetryMode.Exponential; + retryOptions.Delay = TimeSpan.FromSeconds(2); + retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); + }); + + var subscriptionResource = ArmClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(SubscriptionId)); + + var response = await subscriptionResource + .GetResourceGroups() + .CreateOrUpdateAsync(WaitUntil.Completed, + ResourceGroupName, + new ResourceGroupData(new AzureLocation(ResourceGroupLocation)) + { + Tags = + { + [AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysKey] = AzureTestResourceHelpers.ResourceGroupTags.LifetimeInDaysValue, + [AzureTestResourceHelpers.ResourceGroupTags.SourceKey] = AzureTestResourceHelpers.ResourceGroupTags.SourceValue + } + }); + + ResourceGroupResource = response.Value; + } + + [OneTimeTearDown] + public async Task DeleteResourceGroup() + { + await ArmClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(SubscriptionId, ResourceGroupName)) + .DeleteAsync(WaitUntil.Started); + } + } +} diff --git a/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/DeployAzureBicepTemplateCommandFixture.cs b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/DeployAzureBicepTemplateCommandFixture.cs new file mode 100644 index 0000000000..a61c085b16 --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/ExternalCloudIntegration/DeployAzureBicepTemplateCommandFixture.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using Calamari.AzureResourceGroup.Bicep; +using Calamari.Testing; +using Calamari.Testing.Azure; +using Calamari.Testing.Helpers; +using Calamari.Testing.Requirements; +using NUnit.Framework; + +namespace Calamari.AzureResourceGroup.Tests.ExternalCloudIntegration +{ + // Single real-cloud smoke test: a Bicep deploy round-trips against real Azure, and it is the only test that + // exercises the real `az bicep build` compile (hence the Windows/az-cli requirements). Template-source + // resolution and the compile -> substitute -> deploy wiring is covered without Azure or the az cli in + // DeployBicepTemplateBehaviourUnitTestFixture. + [TestFixture] + [WindowsTest] // NOTE: We should look at having the Azure CLI installed on Linux boxes so that these steps can be tested there, particularly if we're moving cloud to a Ubuntu Default Worker. + class DeployAzureBicepTemplateCommandFixture : AzureResourceGroupCloudTestBase + { + readonly string packagePath = TestEnvironment.GetTestPath("Packages", "Bicep"); + + const string ParameterContent = """[{"Key":"storageAccountName","Value":"#{StorageAccountName}"},{"Key":"location","Value":"#{Location}"},{"Key":"sku","Value":"#{SKU}"}]"""; + + [Test] + [RequiresWindowsServer2016OrAbove("This test requires the az cli, which relies on python 3.10, which doesn't run on windows 2012/2012R2")] + public async Task DeployAzureBicepTemplate_PackageSource() + { + await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + AddDefaults(context); + context.Variables.Add(SpecialVariables.Action.Azure.TemplateSource, "Package"); + context.Variables.Add(SpecialVariables.Action.Azure.BicepTemplate, "azure_website_template.bicep"); + context.WithFilesToCopy(packagePath); + }) + .Execute(); + } + + void AddDefaults(CommandTestBuilderContext context) + { + context.Variables.Add(AzureScripting.SpecialVariables.Account.AccountType, "AzureServicePrincipal"); + context.Variables.Add(AzureAccountVariables.SubscriptionId, SubscriptionId); + context.Variables.Add(AzureAccountVariables.TenantId, TenantId); + context.Variables.Add(AzureAccountVariables.ClientId, ClientId); + context.Variables.Add(AzureAccountVariables.Password, ClientSecret); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupLocation, ResourceGroupLocation); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); + context.Variables.Add(SpecialVariables.Action.Azure.BicepTemplateParameters, ParameterContent); + + context.Variables.Add("SKU", "Standard_LRS"); + context.Variables.Add("Location", ResourceGroupLocation); + //storage accounts can be 24 chars long + context.Variables.Add("StorageAccountName", AzureTestResourceHelpers.RandomName(length: 24)); + } + } +} diff --git a/source/Calamari.AzureResourceGroup/AzureResourceGroupOperator.cs b/source/Calamari.AzureResourceGroup/AzureResourceGroupOperator.cs index 5504cd8685..1930fef7a0 100644 --- a/source/Calamari.AzureResourceGroup/AzureResourceGroupOperator.cs +++ b/source/Calamari.AzureResourceGroup/AzureResourceGroupOperator.cs @@ -7,6 +7,8 @@ using Azure.ResourceManager; using Azure.ResourceManager.Resources; using Azure.ResourceManager.Resources.Models; +using Calamari.Azure; +using Calamari.CloudAccounts; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; using Newtonsoft.Json; @@ -16,9 +18,66 @@ namespace Calamari.AzureResourceGroup; -class AzureResourceGroupOperator(ILog log) +class AzureResourceGroupOperator(ILog log) : IAzureResourceGroupOperator { - public async Task> CreateDeployment(ResourceGroupResource resourceGroupResource, + // Used by the ARM-template deploy behaviour: creates the ArmClient and runs the full submit/poll/finalise flow. + public async Task Deploy(IAzureAccount account, + string subscriptionId, + string resourceGroupName, + string deploymentName, + ArmDeploymentMode deploymentMode, + string template, + string? parameters, + IVariables variables) + { + var armClient = account.CreateArmClient(); + var resourceGroupResource = armClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(subscriptionId, resourceGroupName)); + + log.Info($"Deploying Resource Group {resourceGroupName} in subscription {subscriptionId}.\nDeployment name: {deploymentName}\nDeployment mode: {deploymentMode}"); + + var deploymentOperation = await CreateDeployment(resourceGroupResource, deploymentName, deploymentMode, template, parameters); + await PollForCompletionWithTimeout(deploymentOperation, variables); + await FinalizeDeployment(deploymentOperation, variables); + } + + // Used by the Bicep deploy behaviour: creates the resource group first if it does not already exist. + public async Task DeployCreatingResourceGroup(IAzureAccount account, + string subscriptionId, + string resourceGroupName, + string resourceGroupLocation, + string deploymentName, + ArmDeploymentMode deploymentMode, + string template, + string? parameters, + IVariables variables) + { + var armClient = account.CreateArmClient(); + var resourceGroupResource = await GetOrCreateResourceGroup(armClient, subscriptionId, resourceGroupName, resourceGroupLocation); + + var deploymentOperation = await CreateDeployment(resourceGroupResource, deploymentName, deploymentMode, template, parameters); + await PollForCompletion(deploymentOperation); + await FinalizeDeployment(deploymentOperation, variables); + } + + async Task GetOrCreateResourceGroup(ArmClient armClient, string subscriptionId, string resourceGroupName, string location) + { + var subscription = armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)); + + var resourceGroups = subscription.GetResourceGroups(); + var existing = await resourceGroups.GetIfExistsAsync(resourceGroupName); + + if (existing.HasValue && existing.Value != null) + return existing.Value; + + log.Info($"The resource group with the name {resourceGroupName} does not exist"); + log.Info($"Creating resource group {resourceGroupName} in location {location}"); + + var resourceGroupData = new ResourceGroupData(location); + var armOperation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, resourceGroupData); + return armOperation.Value; + } + + async Task> CreateDeployment(ResourceGroupResource resourceGroupResource, string deploymentName, ArmDeploymentMode deploymentMode, string template, @@ -48,14 +107,14 @@ public async Task> CreateDeployment(Resource } } - public async Task PollForCompletionWithTimeout(ArmOperation deploymentOperation, IVariables variables) + async Task PollForCompletionWithTimeout(ArmOperation deploymentOperation, IVariables variables) { var pollingTimeout = GetPollingTimeout(variables); var asyncResourceGroupPollingTimeoutPolicy = Policy.TimeoutAsync(pollingTimeout, TimeoutStrategy.Optimistic); await asyncResourceGroupPollingTimeoutPolicy.ExecuteAsync(ct => Poll(deploymentOperation, ct), CancellationToken.None); } - public async Task PollForCompletion(ArmOperation deploymentOperation) + async Task PollForCompletion(ArmOperation deploymentOperation) { await Poll(deploymentOperation, CancellationToken.None); } @@ -76,7 +135,7 @@ async Task Poll(ArmOperation deploymentOperation, Cancell } } - public async Task FinalizeDeployment(ArmOperation operation, IVariables variables) + async Task FinalizeDeployment(ArmOperation operation, IVariables variables) { await LogOperationResults(operation); CaptureOutputs(operation.Value.Data.Properties.Outputs?.ToString(), variables); diff --git a/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs b/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs index e5c4da7f7e..d2f8a0a91d 100644 --- a/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs +++ b/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs @@ -1,13 +1,8 @@ using System; using System.Threading.Tasks; -using Azure; -using Azure.ResourceManager; -using Azure.ResourceManager.Resources; using Azure.ResourceManager.Resources.Models; -using Calamari.Azure; using Calamari.CloudAccounts; using Calamari.Common.Commands; -using Calamari.Common.Features.Processes; using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Pipeline; @@ -16,7 +11,7 @@ namespace Calamari.AzureResourceGroup.Bicep; // ReSharper disable once ClassNeverInstantiated.Global -class DeployBicepTemplateBehaviour(ICommandLineRunner commandLineRunner, TemplateService templateService, AzureResourceGroupOperator resourceGroupOperator, ILog log) +class DeployBicepTemplateBehaviour(IBicepTemplateBuilder bicepBuilder, ITemplateService templateService, IAzureResourceGroupOperator resourceGroupOperator, ILog log) : IDeployBehaviour { public bool IsEnabled(RunningDeployment context) @@ -29,53 +24,31 @@ public async Task Execute(RunningDeployment context) var accountType = context.Variables.GetRequiredVariable(AzureScripting.SpecialVariables.Account.AccountType); IAzureAccount account = accountType == nameof(AccountType.AzureOidc) ? new AzureOidcAccount(context.Variables) : new AzureServicePrincipalAccount(context.Variables); - var armClient = account.CreateArmClient(); - var resourceGroupName = context.Variables.GetRequiredVariable(SpecialVariables.Action.Azure.ResourceGroupName); var resourceGroupLocation = context.Variables.GetRequiredVariable(SpecialVariables.Action.Azure.ResourceGroupLocation); var subscriptionId = context.Variables.GetRequiredVariable(AzureAccountVariables.SubscriptionId); var deploymentModeVariable = context.Variables.GetRequiredVariable(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode); var deploymentMode = (ArmDeploymentMode)Enum.Parse(typeof(ArmDeploymentMode), deploymentModeVariable); - var resourceGroup = await GetOrCreateResourceGroup(armClient, subscriptionId, resourceGroupName, resourceGroupLocation); - var (template, parameters) = GetArmTemplateAndParameters(context); var armDeploymentName = DeploymentName.FromStepName(context.Variables[ActionVariables.Name]); log.Verbose($"Deployment Name: {armDeploymentName}, set to variable \"AzureRmOutputs[DeploymentName]\""); log.SetOutputVariable("AzureRmOutputs[DeploymentName]", armDeploymentName, context.Variables); - var deploymentOperation = await resourceGroupOperator.CreateDeployment(resourceGroup, - armDeploymentName, - deploymentMode, - template, - parameters); - await resourceGroupOperator.PollForCompletion(deploymentOperation); - await resourceGroupOperator.FinalizeDeployment(deploymentOperation, context.Variables); - } - - async Task GetOrCreateResourceGroup(ArmClient armClient, string subscriptionId, string resourceGroupName, string location) - { - var subscription = armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)); - - var resourceGroups = subscription.GetResourceGroups(); - var existing = await resourceGroups.GetIfExistsAsync(resourceGroupName); - - if (existing.HasValue && existing.Value != null) - return existing.Value; - - log.Info($"The resource group with the name {resourceGroupName} does not exist"); - log.Info($"Creating resource group {resourceGroupName} in location {location}"); - - var resourceGroupData = new ResourceGroupData(location); - var armOperation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, resourceGroupData); - return armOperation.Value; + await resourceGroupOperator.DeployCreatingResourceGroup(account, + subscriptionId, + resourceGroupName, + resourceGroupLocation, + armDeploymentName, + deploymentMode, + template, + parameters, + context.Variables); } (string template, string? parameters) GetArmTemplateAndParameters(RunningDeployment context) { - var bicepCli = new BicepCli(log, commandLineRunner, context.CurrentDirectory); - var bicepTemplateFile = context.Variables.Get(SpecialVariables.Action.Azure.BicepTemplateFile, "template.bicep"); var templateSource = context.Variables.Get(SpecialVariables.Action.Azure.TemplateSource, string.Empty); @@ -86,12 +59,11 @@ async Task GetOrCreateResourceGroup(ArmClient armClient, } log.Info($"Processing Bicep file: {bicepTemplateFile}"); - var armTemplateFile = bicepCli.BuildArmTemplate(bicepTemplateFile!); + var armTemplateFile = bicepBuilder.BuildArmTemplate(context.CurrentDirectory, bicepTemplateFile!); log.Info("Bicep file processed"); var template = templateService.GetSubstitutedTemplateContent(armTemplateFile, filesInPackageOrRepository, context.Variables); - var parametersValue = context.Variables.GetRaw(SpecialVariables.Action.Azure.BicepTemplateParameters) ?? string.Empty; var parameters = BicepToArmParameterMapper.Map(parametersValue, template, context.Variables); diff --git a/source/Calamari.AzureResourceGroup/Bicep/IBicepTemplateBuilder.cs b/source/Calamari.AzureResourceGroup/Bicep/IBicepTemplateBuilder.cs new file mode 100644 index 0000000000..19a1d6d1e2 --- /dev/null +++ b/source/Calamari.AzureResourceGroup/Bicep/IBicepTemplateBuilder.cs @@ -0,0 +1,17 @@ +using Calamari.Common.Features.Processes; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AzureResourceGroup.Bicep +{ + public interface IBicepTemplateBuilder + { + // Returns the path to the generated ARM template. + string BuildArmTemplate(string workingDirectory, string bicepFilePath); + } + + class BicepBuilder(ILog log, ICommandLineRunner commandLineRunner) : IBicepTemplateBuilder + { + public string BuildArmTemplate(string workingDirectory, string bicepFilePath) + => new BicepCli(log, commandLineRunner, workingDirectory).BuildArmTemplate(bicepFilePath); + } +} diff --git a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs index 21a2423b9f..1cbd1a3a6a 100644 --- a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs +++ b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs @@ -1,9 +1,7 @@ // ReSharper disable ClassNeverInstantiated.Global using System; using System.Threading.Tasks; -using Azure.ResourceManager.Resources; using Azure.ResourceManager.Resources.Models; -using Calamari.Azure; using Calamari.CloudAccounts; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Extensions; @@ -15,10 +13,10 @@ namespace Calamari.AzureResourceGroup; class DeployAzureResourceGroupBehaviour( - TemplateService templateService, + ITemplateService templateService, IResourceGroupTemplateNormalizer parameterNormalizer, ILog log, - AzureResourceGroupOperator azureResourceGroupOperator) + IAzureResourceGroupOperator azureResourceGroupOperator) : IDeployBehaviour { public bool IsEnabled(RunningDeployment context) => true; @@ -31,8 +29,6 @@ public async Task Execute(RunningDeployment context) var hasAccessToken = !variables.Get(AccountVariables.Jwt).IsNullOrEmpty(); IAzureAccount account = hasAccessToken ? new AzureOidcAccount(variables) : new AzureServicePrincipalAccount(variables); - var armClient = account.CreateArmClient(); - var resourceGroupName = variables.GetRequiredVariable(SpecialVariables.Action.Azure.ResourceGroupName); var subscriptionId = variables.GetRequiredVariable(AzureAccountVariables.SubscriptionId); @@ -62,16 +58,13 @@ public async Task Execute(RunningDeployment context) ? parameterNormalizer.Normalize(templateService.GetSubstitutedTemplateContent(templateParametersFile, filesInPackageOrRepository, variables)) : null; - var resourceGroupResource = armClient.GetResourceGroupResource(ResourceGroupResource.CreateResourceIdentifier(subscriptionId, resourceGroupName)); - - log.Info($"Deploying Resource Group {resourceGroupName} in subscription {subscriptionId}.\nDeployment name: {deploymentName}\nDeployment mode: {deploymentMode}"); - - var deploymentOperation = await azureResourceGroupOperator.CreateDeployment(resourceGroupResource, - deploymentName, - deploymentMode, - template, - parameters); - await azureResourceGroupOperator.PollForCompletionWithTimeout(deploymentOperation, variables); - await azureResourceGroupOperator.FinalizeDeployment(deploymentOperation, variables); + await azureResourceGroupOperator.Deploy(account, + subscriptionId, + resourceGroupName, + deploymentName, + deploymentMode, + template, + parameters, + variables); } -} \ No newline at end of file +} diff --git a/source/Calamari.AzureResourceGroup/IAzureResourceGroupOperator.cs b/source/Calamari.AzureResourceGroup/IAzureResourceGroupOperator.cs new file mode 100644 index 0000000000..aba18ea3c8 --- /dev/null +++ b/source/Calamari.AzureResourceGroup/IAzureResourceGroupOperator.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Azure.ResourceManager.Resources.Models; +using Calamari.CloudAccounts; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AzureResourceGroup +{ + // The single point at which resource-group deployment talks to Azure, so the behaviour's template, + // parameter, deployment-mode and name logic can be unit-tested with a mock. + public interface IAzureResourceGroupOperator + { + Task Deploy(IAzureAccount account, + string subscriptionId, + string resourceGroupName, + string deploymentName, + ArmDeploymentMode deploymentMode, + string template, + string? parameters, + IVariables variables); + + // As Deploy, but creates the resource group in the given location if it does not already exist (Bicep flow). + Task DeployCreatingResourceGroup(IAzureAccount account, + string subscriptionId, + string resourceGroupName, + string resourceGroupLocation, + string deploymentName, + ArmDeploymentMode deploymentMode, + string template, + string? parameters, + IVariables variables); + } +} diff --git a/source/Calamari.AzureResourceGroup/IResourceGroupTemplateNormalizer.cs b/source/Calamari.AzureResourceGroup/IResourceGroupTemplateNormalizer.cs index d89b7e98e5..6847aa14c1 100644 --- a/source/Calamari.AzureResourceGroup/IResourceGroupTemplateNormalizer.cs +++ b/source/Calamari.AzureResourceGroup/IResourceGroupTemplateNormalizer.cs @@ -2,7 +2,7 @@ namespace Calamari.AzureResourceGroup { - interface IResourceGroupTemplateNormalizer + public interface IResourceGroupTemplateNormalizer { string Normalize(string json); } diff --git a/source/Calamari.AzureResourceGroup/ITemplateService.cs b/source/Calamari.AzureResourceGroup/ITemplateService.cs new file mode 100644 index 0000000000..e9618f6932 --- /dev/null +++ b/source/Calamari.AzureResourceGroup/ITemplateService.cs @@ -0,0 +1,9 @@ +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AzureResourceGroup +{ + public interface ITemplateService + { + string GetSubstitutedTemplateContent(string relativePath, bool inPackage, IVariables variables); + } +} diff --git a/source/Calamari.AzureResourceGroup/Program.cs b/source/Calamari.AzureResourceGroup/Program.cs index 973f561d22..3a91abe389 100644 --- a/source/Calamari.AzureResourceGroup/Program.cs +++ b/source/Calamari.AzureResourceGroup/Program.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Threading.Tasks; using Autofac; +using Calamari.AzureResourceGroup.Bicep; using Calamari.AzureScripting; using Calamari.Common; using Calamari.Common.Plumbing.Commands; @@ -20,10 +21,11 @@ protected override void ConfigureContainer(ContainerBuilder builder, CommonOptio { base.ConfigureContainer(builder, options); - builder.RegisterType(); + builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As().SingleInstance(); - builder.RegisterType(); + builder.RegisterType().As(); + builder.RegisterType().As(); } protected override IEnumerable GetProgramAssembliesToRegister() diff --git a/source/Calamari.AzureResourceGroup/TemplateService.cs b/source/Calamari.AzureResourceGroup/TemplateService.cs index 7bb1b141fa..0187ef11cd 100644 --- a/source/Calamari.AzureResourceGroup/TemplateService.cs +++ b/source/Calamari.AzureResourceGroup/TemplateService.cs @@ -6,7 +6,7 @@ namespace Calamari.AzureResourceGroup; -class TemplateService(ICalamariFileSystem fileSystem, ITemplateResolver resolver) +class TemplateService(ICalamariFileSystem fileSystem, ITemplateResolver resolver) : ITemplateService { public string GetSubstitutedTemplateContent(string relativePath, bool inPackage, IVariables variables) {