diff --git a/source/Calamari/AzureContextScriptWrapper.cs b/source/Calamari/AzureContextScriptWrapper.cs index c0c4ea1..60668e0 100644 --- a/source/Calamari/AzureContextScriptWrapper.cs +++ b/source/Calamari/AzureContextScriptWrapper.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Calamari.Common.Features.Discovery; using Calamari.Common.Features.EmbeddedResources; using Calamari.Common.Features.Processes; using Calamari.Common.Features.Scripting; @@ -11,6 +12,7 @@ using Calamari.Common.Plumbing.FileSystem; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; +using Newtonsoft.Json; namespace Calamari.AzureScripting { @@ -49,37 +51,173 @@ public CommandResult ExecuteScript(Script script, variables.Set("OctopusAzureTargetScript", script.File); variables.Set("OctopusAzureTargetScriptParameters", script.Parameters); - SetOutputVariable("OctopusAzureSubscriptionId", variables.Get(SpecialVariables.Action.Azure.SubscriptionId)!); - SetOutputVariable("OctopusAzureStorageAccountName", variables.Get(SpecialVariables.Action.Azure.StorageAccountName)!); - var azureEnvironment = variables.Get(SpecialVariables.Action.Azure.Environment, DefaultAzureEnvironment)!; - if (azureEnvironment != DefaultAzureEnvironment) + var cloudConnectionContext = GetCloudConnectionContext(variables); + AzureContextScriptWrapperAuthentication? scriptAuthentication; + if (cloudConnectionContext != null) { - log.InfoFormat("Using Azure Environment override - {0}", azureEnvironment); + scriptAuthentication = AzureContextScriptWrapperAuthentication.CreateFromCloudConnectionContext(cloudConnectionContext); } - SetOutputVariable("OctopusAzureEnvironment", azureEnvironment); + else + { + scriptAuthentication = AzureContextScriptWrapperAuthentication.CreateFromKnownVariables(variables); + } + + SetOutputVariable("OctopusAzureSubscriptionId", scriptAuthentication.SubscriptionId); + SetOutputVariable("OctopusAzureStorageAccountName", scriptAuthentication.StorageAccountName!); + if (scriptAuthentication.AzureEnvironment != DefaultAzureEnvironment) + { + log.InfoFormat("Using Azure Environment override - {0}", scriptAuthentication.AzureEnvironment!); + } + SetOutputVariable("OctopusAzureEnvironment", scriptAuthentication.AzureEnvironment!); SetOutputVariable("OctopusAzureExtensionsDirectory", variables.Get(SpecialVariables.Action.Azure.ExtensionsDirectory)!); using (new TemporaryFile(Path.Combine(workingDirectory, "AzureProfile.json"))) using (var contextScriptFile = new TemporaryFile(CreateContextScriptFile(workingDirectory, scriptSyntax))) { - if (variables.Get(SpecialVariables.Account.AccountType) == "AzureServicePrincipal") + if (scriptAuthentication.UseServicePrincipal) { SetOutputVariable("OctopusUseServicePrincipal", bool.TrueString); - SetOutputVariable("OctopusAzureADTenantId", variables.Get(SpecialVariables.Action.Azure.TenantId)!); - SetOutputVariable("OctopusAzureADClientId", variables.Get(SpecialVariables.Action.Azure.ClientId)!); - variables.Set("OctopusAzureADPassword", variables.Get(SpecialVariables.Action.Azure.Password)); + SetOutputVariable("OctopusAzureADTenantId", scriptAuthentication.TenantId!); + SetOutputVariable("OctopusAzureADClientId", scriptAuthentication.ClientId!); + variables.Set("OctopusAzureADPassword", scriptAuthentication.Password); return NextWrapper!.ExecuteScript(new Script(contextScriptFile.FilePath), scriptSyntax, commandLineRunner, environmentVars); } //otherwise use management certificate SetOutputVariable("OctopusUseServicePrincipal", false.ToString()); - using (new TemporaryFile(CreateAzureCertificate(workingDirectory))) + using (new TemporaryFile(CreateAzureCertificate(workingDirectory, scriptAuthentication))) { return NextWrapper!.ExecuteScript(new Script(contextScriptFile.FilePath), scriptSyntax, commandLineRunner, environmentVars); } } } + private class CloudConnectionContext + { + public TAuthentication Authentication { get; set; } + + public CloudConnectionContext(TAuthentication authentication) + { + Authentication = authentication; + } + } + + private class AzureCloudConnectionAuthentication + { + public ServicePrincipalAccount AccountDetails { get; set; } + + public AzureCloudConnectionAuthentication(ServicePrincipalAccount accountDetails) + { + AccountDetails = accountDetails; + } + } + + class AzureContextScriptWrapperAuthentication + { + public AzureContextScriptWrapperAuthentication( + string subscriptionId, + string azureEnvironment, + string? clientId, + string? tenantId, + string? password, + string? storageAccountName, + string? certificateThumbprint, + byte[]? certificateBytes, + bool useServicePrincipal) + { + SubscriptionId = subscriptionId; + AzureEnvironment = azureEnvironment; + ClientId = clientId; + TenantId = tenantId; + Password = password; + StorageAccountName = storageAccountName; + CertificateThumbprint = certificateThumbprint; + CertificateBytes = certificateBytes; + UseServicePrincipal = useServicePrincipal; + } + + public static AzureContextScriptWrapperAuthentication CreateFromKnownVariables(IVariables variables) + { + return new AzureContextScriptWrapperAuthentication( + subscriptionId: variables.Get(SpecialVariables.Action.Azure.SubscriptionId)!, + azureEnvironment: variables.Get(SpecialVariables.Action.Azure.Environment, DefaultAzureEnvironment)!, + clientId: variables.Get(SpecialVariables.Action.Azure.ClientId), + tenantId: variables.Get(SpecialVariables.Action.Azure.TenantId), + password: variables.Get(SpecialVariables.Action.Azure.Password), + variables.Get(SpecialVariables.Action.Azure.StorageAccountName)!, + variables.Get(SpecialVariables.Action.Azure.CertificateThumbprint), + variables.IsSet(SpecialVariables.Action.Azure.CertificateBytes) ? Convert.FromBase64String(variables.Get(SpecialVariables.Action.Azure.CertificateBytes)!) : null, + variables.Get(SpecialVariables.Account.AccountType) == "AzureServicePrincipal"); + } + + public static AzureContextScriptWrapperAuthentication CreateFromCloudConnectionContext(CloudConnectionContext context) + { + return new AzureContextScriptWrapperAuthentication( + context.Authentication.AccountDetails.SubscriptionNumber, + !string.IsNullOrEmpty(context.Authentication.AccountDetails.AzureEnvironment) ? context.Authentication.AccountDetails.AzureEnvironment! : DefaultAzureEnvironment, + context.Authentication.AccountDetails.ClientId, + context.Authentication.AccountDetails.TenantId, + context.Authentication.AccountDetails.Password, + null, + null, + null, + true); + } + + public string SubscriptionId { get; } + public string AzureEnvironment { get; } + public string? ClientId { get; } + public string? TenantId { get; } + public string? Password { get; } + public string? StorageAccountName { get; set; } + public string? CertificateThumbprint { get; set; } + public byte[]? CertificateBytes { get; set; } + public bool UseServicePrincipal { get; set; } + } + + class ServicePrincipalAccount + { + public ServicePrincipalAccount( + string subscriptionNumber, + string clientId, + string tenantId, + string password, + string? azureEnvironment) + { + SubscriptionNumber = subscriptionNumber; + ClientId = clientId; + TenantId = tenantId; + Password = password; + AzureEnvironment = azureEnvironment; + } + + public string SubscriptionNumber { get; } + public string ClientId { get; } + public string TenantId { get; } + public string Password { get; } + public string? AzureEnvironment { get; } + } + + private CloudConnectionContext? GetCloudConnectionContext(IVariables variables) + { + const string contextVariableName = "Octopus.CloudConnection.Context"; + var json = variables.Get(contextVariableName); + if (json == null) + { + return null; + } + + try + { + return JsonConvert.DeserializeObject>(json); + } + catch (Exception ex) + { + Log.Warn($"Cloud connection context from variable {contextVariableName} is in wrong format: {ex.Message}"); + return null; + } + } + string CreateContextScriptFile(string workingDirectory, ScriptSyntax syntax) { string contextFile; @@ -101,12 +239,12 @@ string CreateContextScriptFile(string workingDirectory, ScriptSyntax syntax) return azureContextScriptFile; } - string CreateAzureCertificate(string workingDirectory) + string CreateAzureCertificate(string workingDirectory, AzureContextScriptWrapperAuthentication scriptAuthentication) { var certificateFilePath = Path.Combine(workingDirectory, CertificateFileName); var certificatePassword = GenerateCertificatePassword(); - var azureCertificate = CalamariCertificateStore.GetOrAdd(variables.Get(SpecialVariables.Action.Azure.CertificateThumbprint)!, - Convert.FromBase64String(variables.Get(SpecialVariables.Action.Azure.CertificateBytes)!), + var azureCertificate = CalamariCertificateStore.GetOrAdd(scriptAuthentication.CertificateThumbprint!, + scriptAuthentication.CertificateBytes!, StoreName.My); variables.Set("OctopusAzureCertificateFileName", certificateFilePath); diff --git a/source/Sashimi.Tests/AzurePowerShellActionHandlerFixture.cs b/source/Sashimi.Tests/AzurePowerShellActionHandlerFixture.cs index 6b899cf..ec61c70 100644 --- a/source/Sashimi.Tests/AzurePowerShellActionHandlerFixture.cs +++ b/source/Sashimi.Tests/AzurePowerShellActionHandlerFixture.cs @@ -69,6 +69,28 @@ public void ExecuteAnInlinePowerShellCoreScript() .Execute(); } + [Test] + [RequiresPowerShell5OrAbove] + public void ExecuteAnInlinePowerShellCoreScriptWithCloudConnection() + { + var psScript = @" +$ErrorActionPreference = 'Continue' +az --version +Get-AzureEnvironment +az group list"; + + ActionHandlerTestBuilder.CreateAsync() + .WithArrange(context => + { + AddCloudConnection(context); + context.Variables.Add(Calamari.Common.Plumbing.Variables.PowerShellVariables.Edition, "Core"); + context.Variables.Add(KnownVariables.Action.Script.ScriptSource, KnownVariableValues.Action.Script.ScriptSource.Inline); + context.Variables.Add(KnownVariables.Action.Script.Syntax, ScriptSyntax.PowerShell.ToString()); + context.Variables.Add(KnownVariables.Action.Script.ScriptBody, psScript); + }) + .Execute(); + } + [Test] [RequiresPowerShell5OrAbove] public void ExecuteAnInlinePowerShellCoreScriptAgainstAnInvalidAzureEnvironment() @@ -100,5 +122,19 @@ void AddDefaults(TestActionHandlerContext context) context.Variables.Add("Octopus.Action.Azure.ClientId", clientId); context.Variables.Add("Octopus.Action.Azure.Password", clientSecret); } + + void AddCloudConnection(TestActionHandlerContext context) + { + context.Variables.Add("Octopus.CloudConnection.Context", @$"{{ + ""authentication"": {{ + ""account"": {{ + ""subscriptionNumber"": ""{subscriptionId}"", + ""tenantId"": ""{tenantId}"", + ""clientId"": ""{clientId}"", + ""password"": ""{clientSecret}"" + }} + }} +}}"); + } } } \ No newline at end of file diff --git a/source/Sashimi/AzurePowerShellActionHandler.cs b/source/Sashimi/AzurePowerShellActionHandler.cs index bc6d98a..85424f8 100644 --- a/source/Sashimi/AzurePowerShellActionHandler.cs +++ b/source/Sashimi/AzurePowerShellActionHandler.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using Octopus.Server.Extensibility.HostServices.Diagnostics; using Sashimi.Server.Contracts; +using Sashimi.Server.Contracts.Accounts; using Sashimi.Server.Contracts.ActionHandlers; namespace Sashimi.AzureScripting @@ -16,6 +18,7 @@ public class AzurePowerShellActionHandler : IActionHandlerWithAccount public bool CanRunOnDeploymentTarget => false; public ActionHandlerCategory[] Categories => new[] { ActionHandlerCategory.BuiltInStep, AzureConstants.AzureActionHandlerCategory, ActionHandlerCategory.Script }; public string[] StepBasedVariableNameForAccountIds { get; } = { SpecialVariables.Action.Azure.AccountId }; + public IEnumerable SupportedAccountTypes => new[] { Sashimi.Azure.Accounts.AccountTypes.AzureServicePrincipalAccountType }; public IActionHandlerResult Execute(IActionHandlerContext context, ITaskLog taskLog) { diff --git a/source/Sashimi/AzurePowerShellActionHandlerValidator.cs b/source/Sashimi/AzurePowerShellActionHandlerValidator.cs index b2ced99..97292ed 100644 --- a/source/Sashimi/AzurePowerShellActionHandlerValidator.cs +++ b/source/Sashimi/AzurePowerShellActionHandlerValidator.cs @@ -8,12 +8,13 @@ class AzurePowerShellActionHandlerValidator : AbstractValidator a.ActionType == SpecialVariables.Action.Azure.ActionTypeName, - () => - { - RuleFor(a => a.Properties) - .MustHaveProperty(SpecialVariables.Action.Azure.AccountId, "Please select an Account or provide a variable expression for the Account ID to use."); - }); + // TODO: Don't want this rule to run when cloud connections are configured + //When(a => a.ActionType == SpecialVariables.Action.Azure.ActionTypeName, + // () => + // { + // RuleFor(a => a.Properties) + // .MustHaveProperty(SpecialVariables.Action.Azure.AccountId, "Please select an Account or provide a variable expression for the Account ID to use."); + // }); } } } \ No newline at end of file diff --git a/source/Sashimi/Sashimi.csproj b/source/Sashimi/Sashimi.csproj index ebe5d60..143d103 100644 --- a/source/Sashimi/Sashimi.csproj +++ b/source/Sashimi/Sashimi.csproj @@ -23,7 +23,7 @@ - +