diff --git a/source/Calamari.AzureResourceGroup.Tests/Bicep/BicepToArmParameterMapperFixture.cs b/source/Calamari.AzureResourceGroup.Tests/Bicep/BicepToArmParameterMapperFixture.cs new file mode 100644 index 0000000000..a7f79f9e4e --- /dev/null +++ b/source/Calamari.AzureResourceGroup.Tests/Bicep/BicepToArmParameterMapperFixture.cs @@ -0,0 +1,177 @@ +using Calamari.AzureResourceGroup.Bicep; +using Calamari.Common.Plumbing.Variables; +using NuGet.Protocol; +using NUnit.Framework; + +namespace Calamari.AzureResourceGroup.Tests.Bicep; + +[TestFixture] +public class BicepToArmParameterMapperFixture +{ + const string SimpleBicepParametersString = """ + [{"Key":"storageAccountName","Value":"teststorageaccount"},{"Key":"location","Value":"Australia South East"},{"Key":"myStuff","Value":"[PLACEHOLDER]"}] + """; + + const string SimpleArmTemplate = """ + { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "14097892204907684939" + } + }, + "parameters": { + "storageAccountName": { + "type": "string" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "myStuff": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-06-01", + "name": "[parameters('storageAccountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "tags": { + "tagValue": "[parameters('myStuff')]" + }, + "properties": { + "accessTier": "Hot" + } + } + ], + "outputs": { + "storageAccountId": { + "type": "string", + "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" + } + } + } + """; + + CalamariVariables variables; + [SetUp] + public void SetUp() + { + variables = new CalamariVariables(); + + } + + + [Test] + public void Map_WithEmptyBicepParameters_ReturnsEmptyString() + { + var bicepParametersString = string.Empty; + + var result = BicepToArmParameterMapper.Map(bicepParametersString, SimpleArmTemplate, variables); + + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test] + public void Map_EmptyArmParameters_ReturnsEmptyString() + { + var result = BicepToArmParameterMapper.Map(SimpleBicepParametersString, "{\"noParametersHere\":{\"value\":\"told ya\"}}", variables); + + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test] + public void Map_WithMatchingParameters_ReturnsParameterString() + { + var expectedParameterString = """ + { + "storageAccountName": { + "value": "teststorageaccount" + }, + "location": { + "value": "Australia South East" + }, + "myStuff": { + "value": "[PLACEHOLDER]" + } + } + """.ReplaceLineEndings(); + + + + var result = BicepToArmParameterMapper.Map(SimpleBicepParametersString, SimpleArmTemplate, variables).ReplaceLineEndings(); + + + // Convert so we can ignore Platform specific line endings. + Assert.That(result.ToJson(), Is.EqualTo(expectedParameterString.ToJson())); + + } + + [Test] + public void Map_WithOctopusVariableValueDefined_IsResolvedInParametersString() + { + variables.Add("Octopus.TestValue", "banana"); + var parameterStringInput = SimpleBicepParametersString.Replace("[PLACEHOLDER]", "#{ Octopus.TestValue }"); + var expectedParameterString = """ + { + "storageAccountName": { + "value": "teststorageaccount" + }, + "location": { + "value": "Australia South East" + }, + "myStuff": { + "value": "banana" + } + } + """.ReplaceLineEndings(); + + var result = BicepToArmParameterMapper.Map(parameterStringInput, SimpleArmTemplate, variables).ReplaceLineEndings(); + + // Convert so we can ignore Platform specific line endings. + Assert.That(result, Is.EqualTo(expectedParameterString)); + } + + [Test] + public void Map_WithOctopusVariableDefinedWithNonDelimitedJsonObject_IsResolvedInParametersStringWithProperDelimiting() + { + variables.Add("Octopus.TestValue", + """ + { + "SomeKey": "WithAValue", + "AnotherKey": "WithADifferentValue", + "NestedObject": { + "NestedObjectKey": "YetAnotherValue" + } + } + """); + var parameterStringInput = SimpleBicepParametersString.Replace("[PLACEHOLDER]", "#{ Octopus.TestValue }"); + var expectedParameterString = """ + { + "storageAccountName": { + "value": "teststorageaccount" + }, + "location": { + "value": "Australia South East" + }, + "myStuff": { + "value": "{\"SomeKey\":\"WithAValue\",\"AnotherKey\":\"WithADifferentValue\",\"NestedObject\":{\"NestedObjectKey\":\"YetAnotherValue\"}}" + } + } + """.ReplaceLineEndings(); + + var result = BicepToArmParameterMapper.Map(parameterStringInput, SimpleArmTemplate, variables).ReplaceLineEndings(); + + // Convert so we can ignore Platform specific line endings. + Assert.That(result, Is.EqualTo(expectedParameterString)); + } +} diff --git a/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj b/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj index ce4b9d11ea..55a49cc53c 100644 --- a/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj +++ b/source/Calamari.AzureResourceGroup.Tests/Calamari.AzureResourceGroup.Tests.csproj @@ -27,9 +27,6 @@ PreserveNewest - - PreserveNewest - PreserveNewest diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs index 04d4630225..6ce8922365 100644 --- a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs +++ b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs @@ -7,6 +7,7 @@ using Azure.ResourceManager; using Azure.ResourceManager.Resources; using Calamari.Azure; +using Calamari.AzureResourceGroup.Bicep; using Calamari.CloudAccounts; using Calamari.Testing; using Calamari.Testing.Azure; @@ -17,7 +18,7 @@ namespace Calamari.AzureResourceGroup.Tests { [TestFixture] - [Category(TestCategory.CompatibleOS.OnlyWindows)] + [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; @@ -32,6 +33,8 @@ class DeployAzureBicepTemplateCommandFixture 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() { @@ -128,7 +131,6 @@ await CommandTestBuilder.CreateAsync() public async Task DeployAzureBicepTemplate_InlineSource() { var templateFileContent = File.ReadAllText(Path.Combine(packagePath, "azure_website_template.bicep")); - var paramsFileContent = File.ReadAllText(Path.Combine(packagePath, "parameters.json")); await CommandTestBuilder.CreateAsync() .WithArrange(context => @@ -136,7 +138,7 @@ await CommandTestBuilder.CreateAsync() AddDefaults(context); context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupDeploymentMode, "Complete"); context.Variables.Add(SpecialVariables.Action.Azure.TemplateSource, "Inline"); - AddTemplateFiles(context, templateFileContent, paramsFileContent); + context.WithDataFile(templateFileContent, "template.bicep"); }) .Execute(); } @@ -151,18 +153,12 @@ void AddDefaults(CommandTestBuilderContext context) 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.TemplateParameters, "parameters.json"); + 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)); } - - static void AddTemplateFiles(CommandTestBuilderContext context, string template, string parameters) - { - context.WithDataFile(template, "template.bicep"); - context.WithDataFile(parameters, "parameters.json"); - } } } diff --git a/source/Calamari.AzureResourceGroup.Tests/Packages/Bicep/parameters.json b/source/Calamari.AzureResourceGroup.Tests/Packages/Bicep/parameters.json deleted file mode 100644 index edbd857422..0000000000 --- a/source/Calamari.AzureResourceGroup.Tests/Packages/Bicep/parameters.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "storageAccountName": { - "value": "#{StorageAccountName}" - }, - "location": { - "value": "#{Location}" - }, - "sku": { - "value": "#{SKU}" - } -} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/Bicep/BicepCli.cs b/source/Calamari.AzureResourceGroup/Bicep/BicepCli.cs new file mode 100644 index 0000000000..dc5ea17491 --- /dev/null +++ b/source/Calamari.AzureResourceGroup/Bicep/BicepCli.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Linq; +using Calamari.Common.Commands; +using Calamari.Common.Features.Processes; +using Calamari.Common.Features.Scripting; +using Calamari.Common.Plumbing; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AzureResourceGroup.Bicep; + +public class BicepCli +{ + const string ArmTemplateFileName = "ARMTemplate.json"; + readonly ILog log; + readonly ICommandLineRunner commandLineRunner; + readonly string workingDirectory; + string azCliLocation = null!; + + public BicepCli(ILog log, ICommandLineRunner commandLineRunner, string workingDirectory) + { + this.log = log; + this.commandLineRunner = commandLineRunner; + this.workingDirectory = workingDirectory; + + SetAz(); + } + + public string BuildArmTemplate(string bicepFilePath) + { + var invocation = new CommandLineInvocation(azCliLocation, + "bicep", + "build", + "--file", + bicepFilePath, + "--outfile", + ArmTemplateFileName) + { + WorkingDirectory = workingDirectory + }; + + ExecuteCommandLineInvocationAndLogOutput(invocation); + + return Path.Combine(workingDirectory, ArmTemplateFileName); + } + + void SetAz() + { + var result = CalamariEnvironment.IsRunningOnWindows + ? ExecuteRawCommandAndReturnOutput("where", "az.cmd") + : ExecuteRawCommandAndReturnOutput("which", "az"); + + var infoMessages = result.Output.Messages.Where(m => m.Level == Level.Verbose).Select(m => m.Text).ToArray(); + var foundExecutable = infoMessages.FirstOrDefault(); + if (string.IsNullOrEmpty(foundExecutable)) + throw new CommandException("Could not find az. Make sure az is on the PATH."); + + azCliLocation = foundExecutable.Trim(); + } + + CommandResultWithOutput ExecuteRawCommandAndReturnOutput(string exe, params string[] arguments) + { + var captureCommandOutput = new CaptureCommandOutput(); + var invocation = new CommandLineInvocation(exe, arguments) + { + WorkingDirectory = workingDirectory, + OutputAsVerbose = false, + OutputToLog = false, + AdditionalInvocationOutputSink = captureCommandOutput + }; + + var result = commandLineRunner.Execute(invocation); + + return new CommandResultWithOutput(result, captureCommandOutput); + } + + CommandResult ExecuteCommandLineInvocationAndLogOutput(CommandLineInvocation invocation) + { + invocation.WorkingDirectory = workingDirectory; + invocation.OutputAsVerbose = false; + invocation.OutputToLog = false; + + var captureCommandOutput = new CaptureCommandOutput(); + invocation.AdditionalInvocationOutputSink = captureCommandOutput; + + LogCommandText(invocation); + + var result = commandLineRunner.Execute(invocation); + + LogCapturedOutput(result, captureCommandOutput); + + return result; + } + + void LogCommandText(CommandLineInvocation invocation) + { + log.Verbose(invocation.ToString()); + } + + void LogCapturedOutput(CommandResult result, CaptureCommandOutput captureCommandOutput) + { + foreach (var message in captureCommandOutput.Messages) + { + if (result.ExitCode == 0) + { + log.Verbose(message.Text); + continue; + } + + switch (message.Level) + { + case Level.Verbose: + log.Verbose(message.Text); + break; + case Level.Error: + log.Error(message.Text); + break; + } + } + } +} + +class CommandResultWithOutput(CommandResult result, CaptureCommandOutput output) +{ + public CommandResult Result { get; } = result; + + public CaptureCommandOutput Output { get; } = output; +} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/Bicep/BicepToArmParameterMapper.cs b/source/Calamari.AzureResourceGroup/Bicep/BicepToArmParameterMapper.cs new file mode 100644 index 0000000000..44ea4566ce --- /dev/null +++ b/source/Calamari.AzureResourceGroup/Bicep/BicepToArmParameterMapper.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Calamari.Common.Plumbing.Variables; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Calamari.AzureResourceGroup.Bicep; + +public static class BicepToArmParameterMapper +{ + record ArmParameter(string Name, string Type); + + public static string Map(string bicepParametersString, string armTemplateJson, IVariables variables) + { + var armParameters = JObject.Parse(armTemplateJson)["parameters"]?.Children() + .Select(p => new ArmParameter( + Name: p.Name, + Type: p.Value["type"]?.Value() ?? "string" + )) + .ToList() + ?? []; + + if (armParameters.Count == 0 || string.IsNullOrEmpty(bicepParametersString)) + { + return string.Empty; + } + + var parameterKeyValuePairs = JArray.Parse(bicepParametersString) + .Select(item => new KeyValuePair( + item["Key"]!.Value()!, + item["Value"]!.Value()! + )) + .ToList(); + + var result = new JObject(); + + var matched = parameterKeyValuePairs.Join( + armParameters, + kvp => kvp.Key, + p => p.Name, + (kvp, armParameter) => new { kvp, armParameter } + ); + + foreach (var match in matched) + { + var specifiedValue = GenerateValue(match.kvp, match.armParameter, variables); + if (specifiedValue != null) + { + result[match.kvp.Key] = new JObject { ["value"] = specifiedValue }; + } + } + + + return result.ToString(); + } + + static JToken? GenerateValue(KeyValuePair property, ArmParameter parameter, IVariables variables) + { + var evaluatedValue = variables.Evaluate(property.Value); + + var valueToken = parameter.Type switch + { + // NOTE: Array and Object are not defined here, but Server ignores them too + "int" => int.TryParse(evaluatedValue, out var intResult) ? new JValue(intResult) : null, + "bool" => bool.TryParse(evaluatedValue, out var boolResult) ? new JValue(boolResult) : null, + "secureString" or "secureObject" => property.Value, + _ => !string.IsNullOrEmpty(evaluatedValue) ? ParseStringOrObject(evaluatedValue) : null + }; + + return valueToken; + } + + // Used for handling Objects passed around as strings which + // is part of what triggered https://slipway.octopushq.com/software-products/OctopusServer/problems/SoftwareReleaseProblems-9261 + static JToken ParseStringOrObject(string value) + { + var trimmed = value.Trim(); + if (trimmed.StartsWith("{") && trimmed.EndsWith("}")) + { + try + { + var obj = JObject.Parse(trimmed); + return new JValue(obj.ToString(Formatting.None)); + } + catch (JsonException) + { + // not valid JSON, fall through to string + } + } + + return new JValue(value); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/Bicep/DeployAzureBicepTemplateCommand.cs b/source/Calamari.AzureResourceGroup/Bicep/DeployAzureBicepTemplateCommand.cs new file mode 100644 index 0000000000..68ff5c6fd9 --- /dev/null +++ b/source/Calamari.AzureResourceGroup/Bicep/DeployAzureBicepTemplateCommand.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Pipeline; + +namespace Calamari.AzureResourceGroup.Bicep; + +[Command("deploy-azure-bicep-template", Description = "Deploy a Bicep template to Azure")] +// ReSharper disable once ClassNeverInstantiated.Global +public class DeployAzureBicepTemplateCommand : PipelineCommand +{ + protected override IEnumerable Deploy(DeployResolver resolver) + { + yield return resolver.Create(); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs b/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs new file mode 100644 index 0000000000..9b5226f88e --- /dev/null +++ b/source/Calamari.AzureResourceGroup/Bicep/DeployBicepTemplateBehaviour.cs @@ -0,0 +1,101 @@ +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; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AzureResourceGroup.Bicep; + +// ReSharper disable once ClassNeverInstantiated.Global +class DeployBicepTemplateBehaviour(ICommandLineRunner commandLineRunner, TemplateService templateService, AzureResourceGroupOperator resourceGroupOperator, IResourceGroupTemplateNormalizer parameterNormalizer, ILog log) + : IDeployBehaviour +{ + public bool IsEnabled(RunningDeployment context) + { + return true; + } + + 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, context.Variables); + 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; + } + + (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); + + var filesInPackageOrRepository = templateSource is "Package" or "GitRepository"; + if (filesInPackageOrRepository) + { + bicepTemplateFile = context.Variables.Get(SpecialVariables.Action.Azure.BicepTemplate); + } + + log.Info($"Processing Bicep file: {bicepTemplateFile}"); + var armTemplateFile = bicepCli.BuildArmTemplate(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); + + return (template, parameters); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/BicepCli.cs b/source/Calamari.AzureResourceGroup/BicepCli.cs deleted file mode 100644 index 172fa4b73c..0000000000 --- a/source/Calamari.AzureResourceGroup/BicepCli.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Calamari.Common.Commands; -using Calamari.Common.Features.Processes; -using Calamari.Common.Features.Scripting; -using Calamari.Common.Plumbing; -using Calamari.Common.Plumbing.Logging; - -namespace Calamari.AzureResourceGroup -{ - public class BicepCli - { - public const string ArmTemplateFileName = "ARMTemplate.json"; - readonly ILog log; - readonly ICommandLineRunner commandLineRunner; - readonly string workingDirectory; - string azCliLocation = null!; - - public BicepCli(ILog log, ICommandLineRunner commandLineRunner, string workingDirectory) - { - this.log = log; - this.commandLineRunner = commandLineRunner; - this.workingDirectory = workingDirectory; - - SetAz(); - } - - public string BuildArmTemplate(string bicepFilePath) - { - var invocation = new CommandLineInvocation(azCliLocation, - "bicep", - "build", - "--file", - bicepFilePath, - "--outfile", - ArmTemplateFileName) - { - WorkingDirectory = workingDirectory - }; - - ExecuteCommandLineInvocationAndLogOutput(invocation); - - return Path.Combine(workingDirectory, ArmTemplateFileName); - } - - void SetAz() - { - var result = CalamariEnvironment.IsRunningOnWindows - ? ExecuteRawCommandAndReturnOutput("where", "az.cmd") - : ExecuteRawCommandAndReturnOutput("which", "az"); - - var infoMessages = result.Output.Messages.Where(m => m.Level == Level.Verbose).Select(m => m.Text).ToArray(); - var foundExecutable = infoMessages.FirstOrDefault(); - if (string.IsNullOrEmpty(foundExecutable)) - throw new CommandException("Could not find az. Make sure az is on the PATH."); - - azCliLocation = foundExecutable.Trim(); - } - - CommandResultWithOutput ExecuteRawCommandAndReturnOutput(string exe, params string[] arguments) - { - var captureCommandOutput = new CaptureCommandOutput(); - var invocation = new CommandLineInvocation(exe, arguments) - { - WorkingDirectory = workingDirectory, - OutputAsVerbose = false, - OutputToLog = false, - AdditionalInvocationOutputSink = captureCommandOutput - }; - - var result = commandLineRunner.Execute(invocation); - - return new CommandResultWithOutput(result, captureCommandOutput); - } - - CommandResult ExecuteCommandLineInvocationAndLogOutput(CommandLineInvocation invocation) - { - invocation.WorkingDirectory = workingDirectory; - invocation.OutputAsVerbose = false; - invocation.OutputToLog = false; - - var captureCommandOutput = new CaptureCommandOutput(); - invocation.AdditionalInvocationOutputSink = captureCommandOutput; - - LogCommandText(invocation); - - var result = commandLineRunner.Execute(invocation); - - LogCapturedOutput(result, captureCommandOutput); - - return result; - } - - void LogCommandText(CommandLineInvocation invocation) - { - log.Verbose(invocation.ToString()); - } - - void LogCapturedOutput(CommandResult result, CaptureCommandOutput captureCommandOutput) - { - foreach (var message in captureCommandOutput.Messages) - { - if (result.ExitCode == 0) - { - log.Verbose(message.Text); - continue; - } - - switch (message.Level) - { - case Level.Verbose: - log.Verbose(message.Text); - break; - case Level.Error: - log.Error(message.Text); - break; - } - } - } - } - - class CommandResultWithOutput - { - public CommandResultWithOutput(CommandResult result, CaptureCommandOutput output) - { - Result = result; - Output = output; - } - - public CommandResult Result { get; } - - public CaptureCommandOutput Output { get; set; } - } -} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/Calamari.AzureResourceGroup.csproj b/source/Calamari.AzureResourceGroup/Calamari.AzureResourceGroup.csproj index 28948f698e..a53fd67917 100644 --- a/source/Calamari.AzureResourceGroup/Calamari.AzureResourceGroup.csproj +++ b/source/Calamari.AzureResourceGroup/Calamari.AzureResourceGroup.csproj @@ -15,6 +15,7 @@ + diff --git a/source/Calamari.AzureResourceGroup/DeployAzureBicepTemplateCommand.cs b/source/Calamari.AzureResourceGroup/DeployAzureBicepTemplateCommand.cs deleted file mode 100644 index 3de6909bcb..0000000000 --- a/source/Calamari.AzureResourceGroup/DeployAzureBicepTemplateCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Pipeline; - -namespace Calamari.AzureResourceGroup -{ - [Command("deploy-azure-bicep-template", Description = "Deploy a Bicep template to Azure")] - public class DeployAzureBicepTemplateCommand : PipelineCommand - { - protected override IEnumerable Deploy(DeployResolver resolver) - { - yield return resolver.Create(); - } - } -} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs index 4ce32d8008..1a1513fb62 100644 --- a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs +++ b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupBehaviour.cs @@ -5,7 +5,6 @@ using Calamari.Azure; using Calamari.CloudAccounts; using Calamari.Common.Commands; -using Calamari.Common.FeatureToggles; using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Pipeline; diff --git a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupCommand.cs b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupCommand.cs index 9715009243..6993c555a1 100644 --- a/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupCommand.cs +++ b/source/Calamari.AzureResourceGroup/DeployAzureResourceGroupCommand.cs @@ -6,6 +6,7 @@ namespace Calamari.AzureResourceGroup { [Command("deploy-azure-resource-group", Description = "Creates a new Azure Resource Group deployment")] + // ReSharper disable once ClassNeverInstantiated.Global public class DeployAzureResourceGroupCommand : PipelineCommand { protected override IEnumerable Deploy(DeployResolver resolver) diff --git a/source/Calamari.AzureResourceGroup/DeployBicepTemplateBehaviour.cs b/source/Calamari.AzureResourceGroup/DeployBicepTemplateBehaviour.cs deleted file mode 100644 index 83400607ac..0000000000 --- a/source/Calamari.AzureResourceGroup/DeployBicepTemplateBehaviour.cs +++ /dev/null @@ -1,110 +0,0 @@ -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; -using Calamari.Common.Plumbing.Variables; - -namespace Calamari.AzureResourceGroup -{ - class DeployBicepTemplateBehaviour : IDeployBehaviour - { - readonly ICommandLineRunner commandLineRunner; - readonly TemplateService templateService; - readonly AzureResourceGroupOperator resourceGroupOperator; - readonly ILog log; - - public DeployBicepTemplateBehaviour(ICommandLineRunner commandLineRunner, TemplateService templateService, AzureResourceGroupOperator resourceGroupOperator, ILog log) - { - this.commandLineRunner = commandLineRunner; - this.templateService = templateService; - this.resourceGroupOperator = resourceGroupOperator; - this.log = log; - } - - public bool IsEnabled(RunningDeployment context) - { - return true; - } - - public async Task Execute(RunningDeployment context) - { - var accountType = context.Variables.GetRequiredVariable(AzureScripting.SpecialVariables.Account.AccountType); - var account = accountType == AccountType.AzureOidc.ToString() ? (IAzureAccount)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, context.Variables); - 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; - } - - (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); - - var filesInPackageOrRepository = templateSource is "Package" or "GitRepository"; - if (filesInPackageOrRepository) - { - bicepTemplateFile = context.Variables.Get(SpecialVariables.Action.Azure.BicepTemplate); - } - - log.Info($"Processing Bicep file: {bicepTemplateFile}"); - var armTemplateFile = bicepCli.BuildArmTemplate(bicepTemplateFile!); - log.Info("Bicep file processed"); - - var template = templateService.GetSubstitutedTemplateContent(armTemplateFile, filesInPackageOrRepository, context.Variables); - - var parameters = templateService.GetSubstitutedTemplateContent("parameters.json", inPackage: false, context.Variables); - - return (template, parameters); - } - } -} \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/ResourceGroupTemplateNormalizer.cs b/source/Calamari.AzureResourceGroup/ResourceGroupTemplateNormalizer.cs index b8a4f4aab9..5931c14ec1 100644 --- a/source/Calamari.AzureResourceGroup/ResourceGroupTemplateNormalizer.cs +++ b/source/Calamari.AzureResourceGroup/ResourceGroupTemplateNormalizer.cs @@ -14,7 +14,7 @@ public string Normalize(string json) try { var envelope = JsonConvert.DeserializeObject(json); - return JsonConvert.SerializeObject(envelope.Parameters); + return JsonConvert.SerializeObject(envelope?.Parameters); } catch (JsonSerializationException) { @@ -25,7 +25,7 @@ public string Normalize(string json) class ParameterEnvelope { [JsonProperty("parameters", Required = Required.Always)] - public JObject Parameters { get; set; } + public required JObject Parameters { get; set; } } } } \ No newline at end of file diff --git a/source/Calamari.AzureResourceGroup/SpecialVariables.cs b/source/Calamari.AzureResourceGroup/SpecialVariables.cs index 21fa54b272..00ad95b050 100644 --- a/source/Calamari.AzureResourceGroup/SpecialVariables.cs +++ b/source/Calamari.AzureResourceGroup/SpecialVariables.cs @@ -18,6 +18,7 @@ public static class Azure public static readonly string ArmDeploymentTimeout = "Octopus.Action.Azure.ArmDeploymentTimeout"; public static readonly string BicepTemplate = "Octopus.Action.Azure.BicepTemplate"; public static readonly string BicepTemplateFile = "Octopus.Action.Azure.BicepTemplateFile"; + public static readonly string BicepTemplateParameters = "Octopus.Action.Azure.BicepTemplateParameters"; } } }