diff --git a/source/Calamari.Tests/AppServiceBehaviorFixture.cs b/source/Calamari.Tests/AppServiceBehaviorFixture.cs index 8aa82536..946c484a 100644 --- a/source/Calamari.Tests/AppServiceBehaviorFixture.cs +++ b/source/Calamari.Tests/AppServiceBehaviorFixture.cs @@ -1,16 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; -using System.Net.Http; using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Xml; using System.Xml.Linq; -using Azure.Identity; -using Azure.ResourceManager.Resources; using Azure.ResourceManager.Resources.Models; using Calamari.Azure; using Calamari.Common.Plumbing.FileSystem; @@ -281,12 +277,7 @@ private static (string packagePath, string packageName, string packageVersion) P private void AddVariables(CommandTestBuilderContext context) { - context.Variables.Add(AccountVariables.ClientId, clientId); - context.Variables.Add(AccountVariables.Password, clientSecret); - context.Variables.Add(AccountVariables.TenantId, tenantId); - context.Variables.Add(AccountVariables.SubscriptionId, subscriptionId); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupName", resourceGroupName); - context.Variables.Add("Octopus.Action.Azure.WebAppName", site.Name); + AddAzureVariables(context); context.Variables.Add("Greeting", greeting); context.Variables.Add(KnownVariables.Package.EnabledFeatures, KnownVariables.Features.SubstituteInFiles); context.Variables.Add(PackageVariables.SubstituteInFilesTargets, "index.html"); @@ -297,9 +288,6 @@ private void AddVariables(CommandTestBuilderContext context) [TestFixture] public class WhenUsingALinuxAppService : AppServiceIntegrationTest { - private string linuxServicePlanName; - private string functionAppSiteName; - protected override async Task ConfigureTestResources(ResourceGroup resourceGroup) { var storageClient = new StorageManagementClient(new TokenCredentials(authToken)) @@ -329,7 +317,7 @@ protected override async Task ConfigureTestResources(ResourceGroup resourceGroup } ); - var functionAppSite = await webMgmtClient.WebApps.BeginCreateOrUpdateAsync(resourceGroupName, + site = await webMgmtClient.WebApps.BeginCreateOrUpdateAsync(resourceGroupName, $"{resourceGroupName}-linux", new Site(resourceGroupLocation) { @@ -350,9 +338,6 @@ protected override async Task ConfigureTestResources(ResourceGroup resourceGroup } } ); - - linuxServicePlanName = linuxSvcPlan.Name; - functionAppSiteName = functionAppSite.Name; } [Test] @@ -371,7 +356,7 @@ await CommandTestBuilder.CreateAsync().Wi // Assert await DoWithRetries(10, async () => { - await AssertContent($"{functionAppSiteName}.azurewebsites.net", + await AssertContent($"{site.Name}.azurewebsites.net", rootPath: $"api/HttpExample?name={greeting}", actualText: $"Hello, {greeting}"); }, @@ -382,9 +367,9 @@ await AssertContent($"{functionAppSiteName}.azurewebsites.net", public async Task CanDeployZip_ToLinuxFunctionApp_WithRunFromPackageFlag() { // Arrange - var settings = await webMgmtClient.WebApps.ListApplicationSettingsAsync(resourceGroupName, functionAppSiteName); + var settings = await webMgmtClient.WebApps.ListApplicationSettingsAsync(resourceGroupName, site.Name); settings.Properties["WEBSITE_RUN_FROM_PACKAGE"] = "1"; - await webMgmtClient.WebApps.UpdateApplicationSettingsAsync(resourceGroupName, functionAppSiteName, settings); + await webMgmtClient.WebApps.UpdateApplicationSettingsAsync(resourceGroupName, site.Name, settings); var packageInfo = PrepareZipPackage(); @@ -398,7 +383,7 @@ await CommandTestBuilder.CreateAsync().Wi // Assert await DoWithRetries(10, async () => { - await AssertContent($"{functionAppSiteName}.azurewebsites.net", + await AssertContent($"{site.Name}.azurewebsites.net", rootPath: $"api/HttpExample?name={greeting}", actualText: $"Hello, {greeting}"); }, @@ -428,104 +413,9 @@ private static (string packagePath, string packageName, string packageVersion) P private void AddVariables(CommandTestBuilderContext context) { - context.Variables.Add(AccountVariables.ClientId, clientId); - context.Variables.Add(AccountVariables.Password, clientSecret); - context.Variables.Add(AccountVariables.TenantId, tenantId); - context.Variables.Add(AccountVariables.SubscriptionId, subscriptionId); - context.Variables.Add("Octopus.Action.Azure.ResourceGroupName", resourceGroupName); - context.Variables.Add("Octopus.Action.Azure.WebAppName", functionAppSiteName); + AddAzureVariables(context); context.Variables.Add(SpecialVariables.Action.Azure.DeploymentType, "ZipDeploy"); } } } - - public abstract class AppServiceIntegrationTest - { - protected string clientId; - protected string clientSecret; - protected string tenantId; - protected string subscriptionId; - protected string resourceGroupName; - protected string resourceGroupLocation; - protected string greeting = "Calamari"; - protected string authToken; - protected WebSiteManagementClient webMgmtClient; - protected Site site; - - private ResourceGroupsOperations resourceGroupClient; - private readonly HttpClient client = new HttpClient(); - - [OneTimeSetUp] - public async Task Setup() - { - var resourceManagementEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? - DefaultVariables.ResourceManagementEndpoint; - var activeDirectoryEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? - DefaultVariables.ActiveDirectoryEndpoint; - - resourceGroupName = Guid.NewGuid().ToString(); - - clientId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId); - clientSecret = ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword); - tenantId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId); - subscriptionId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionId); - resourceGroupLocation = Environment.GetEnvironmentVariable("AZURE_NEW_RESOURCE_REGION") ?? "eastus"; - - authToken = await Auth.GetAuthTokenAsync(activeDirectoryEndpointBaseUri, resourceManagementEndpointBaseUri, - tenantId, clientId, clientSecret); - - var resourcesClient = new ResourcesManagementClient(subscriptionId, - new ClientSecretCredential(tenantId, clientId, clientSecret)); - - resourceGroupClient = resourcesClient.ResourceGroups; - - var resourceGroup = new ResourceGroup(resourceGroupLocation); - resourceGroup = await resourceGroupClient.CreateOrUpdateAsync(resourceGroupName, resourceGroup); - - webMgmtClient = new WebSiteManagementClient(new TokenCredentials(authToken)) - { - SubscriptionId = subscriptionId, - HttpClient = { BaseAddress = new Uri(DefaultVariables.ResourceManagementEndpoint) }, - }; - - await ConfigureTestResources(resourceGroup); - } - - protected abstract Task ConfigureTestResources(ResourceGroup resourceGroup); - - [OneTimeTearDown] - public async Task Cleanup() - { - if (resourceGroupClient != null) - await resourceGroupClient.StartDeleteAsync(resourceGroupName); - } - - protected async Task AssertContent(string hostName, string actualText, string rootPath = null) - { - var result = await client.GetStringAsync($"https://{hostName}/{rootPath}"); - - result.Should().Contain(actualText); - } - - protected static async Task DoWithRetries(int retries, Func action, int secondsBetweenRetries) - { - foreach (var retry in Enumerable.Range(1, retries)) - { - try - { - await action(); - break; - } - catch - { - if (retry == retries) - throw; - - await Task.Delay(secondsBetweenRetries * 1000); - } - } - } - } } diff --git a/source/Calamari.Tests/AppServiceIntegrationTest.cs b/source/Calamari.Tests/AppServiceIntegrationTest.cs new file mode 100644 index 00000000..c0d93769 --- /dev/null +++ b/source/Calamari.Tests/AppServiceIntegrationTest.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.Identity; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; +using Calamari.Azure; +using Calamari.Tests.Shared; +using FluentAssertions; +using Microsoft.Azure.Management.WebSites; +using Microsoft.Azure.Management.WebSites.Models; +using Microsoft.Rest; +using NUnit.Framework; + +namespace Calamari.AzureAppService.Tests +{ + public abstract class AppServiceIntegrationTest + { + protected string clientId; + protected string clientSecret; + protected string tenantId; + protected string subscriptionId; + protected string resourceGroupName; + protected string resourceGroupLocation; + protected string greeting = "Calamari"; + protected string authToken; + protected WebSiteManagementClient webMgmtClient; + protected Site site; + + private ResourceGroupsOperations resourceGroupClient; + private readonly HttpClient client = new HttpClient(); + + [OneTimeSetUp] + public async Task Setup() + { + var resourceManagementEndpointBaseUri = + Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? + DefaultVariables.ResourceManagementEndpoint; + var activeDirectoryEndpointBaseUri = + Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? + DefaultVariables.ActiveDirectoryEndpoint; + + resourceGroupName = Guid.NewGuid().ToString(); + + clientId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId); + clientSecret = ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword); + tenantId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId); + subscriptionId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionId); + resourceGroupLocation = Environment.GetEnvironmentVariable("AZURE_NEW_RESOURCE_REGION") ?? "eastus"; + + authToken = await Auth.GetAuthTokenAsync(activeDirectoryEndpointBaseUri, resourceManagementEndpointBaseUri, + tenantId, clientId, clientSecret); + + var resourcesClient = new ResourcesManagementClient(subscriptionId, + new ClientSecretCredential(tenantId, clientId, clientSecret)); + + resourceGroupClient = resourcesClient.ResourceGroups; + + var resourceGroup = new ResourceGroup(resourceGroupLocation); + resourceGroup = await resourceGroupClient.CreateOrUpdateAsync(resourceGroupName, resourceGroup); + + webMgmtClient = new WebSiteManagementClient(new TokenCredentials(authToken)) + { + SubscriptionId = subscriptionId, + HttpClient = { BaseAddress = new Uri(DefaultVariables.ResourceManagementEndpoint) }, + }; + + await ConfigureTestResources(resourceGroup); + } + + protected abstract Task ConfigureTestResources(ResourceGroup resourceGroup); + + [OneTimeTearDown] + public async Task Cleanup() + { + if (resourceGroupClient != null) + await resourceGroupClient.StartDeleteAsync(resourceGroupName); + } + + protected async Task AssertContent(string hostName, string actualText, string rootPath = null) + { + var result = await client.GetStringAsync($"https://{hostName}/{rootPath}"); + + result.Should().Contain(actualText); + } + + protected static async Task DoWithRetries(int retries, Func action, int secondsBetweenRetries) + { + foreach (var retry in Enumerable.Range(1, retries)) + { + try + { + await action(); + break; + } + catch + { + if (retry == retries) + throw; + + await Task.Delay(secondsBetweenRetries * 1000); + } + } + } + + protected void AddAzureVariables(CommandTestBuilderContext context) + { + context.Variables.Add(AccountVariables.ClientId, clientId); + context.Variables.Add(AccountVariables.Password, clientSecret); + context.Variables.Add(AccountVariables.TenantId, tenantId); + context.Variables.Add(AccountVariables.SubscriptionId, subscriptionId); + context.Variables.Add("Octopus.Action.Azure.ResourceGroupName", resourceGroupName); + context.Variables.Add("Octopus.Action.Azure.WebAppName", site.Name); + } + } +} diff --git a/source/Calamari/Azure/AzureClient.cs b/source/Calamari/Azure/AzureClient.cs index 75e58216..3d208bfc 100644 --- a/source/Calamari/Azure/AzureClient.cs +++ b/source/Calamari/Azure/AzureClient.cs @@ -1,5 +1,8 @@ using System.Net; using System.Net.Http; +using Azure.Core.Pipeline; +using Azure.Identity; +using Azure.ResourceManager; using Microsoft.Azure.Management.Fluent; using Microsoft.Azure.Management.ResourceManager.Fluent; @@ -23,5 +26,24 @@ public static IAzure CreateAzureClient(this ServicePrincipalAccount servicePrinc .Authenticate(credentials) .WithSubscription(servicePrincipal.SubscriptionNumber); } + + /// + /// Creates an ArmClient for the new Azure SDK, which replaces the older fluent libraries. + /// We should migrate to this SDK once it stabilises. + /// + /// Service Principal Account to use when connecting to Azure + /// + public static ArmClient CreateArmClient(this ServicePrincipalAccount servicePrincipal) + { + var environment = new AzureKnownEnvironment(servicePrincipal.AzureEnvironment).AsAzureArmEnvironment(); + + var httpClientTransport = new HttpClientTransport(new HttpClientHandler { Proxy = WebRequest.DefaultWebProxy }); + + var tokenCredentialOptions = new TokenCredentialOptions { Transport = httpClientTransport }; + var credential = new ClientSecretCredential(servicePrincipal.TenantId, servicePrincipal.ClientId, servicePrincipal.Password, tokenCredentialOptions); + + var armClientOptions = new ArmClientOptions() { Transport = httpClientTransport, Environment = environment }; + return new ArmClient(credential, defaultSubscriptionId: servicePrincipal.SubscriptionNumber, armClientOptions); + } } } diff --git a/source/Calamari/Azure/AzureKnownEnvironment.cs b/source/Calamari/Azure/AzureKnownEnvironment.cs index 39cdac89..5fdd1212 100644 --- a/source/Calamari/Azure/AzureKnownEnvironment.cs +++ b/source/Calamari/Azure/AzureKnownEnvironment.cs @@ -1,4 +1,5 @@ using System; +using Azure.ResourceManager; using Microsoft.Azure.Management.ResourceManager.Fluent; namespace Calamari.Azure @@ -15,11 +16,11 @@ public AzureKnownEnvironment(string environment) if (string.IsNullOrEmpty(environment) || environment == "AzureCloud") // This environment name is defined in Sashimi.Azure.Accounts.AzureEnvironmentsListAction Value = Global.Value; // We interpret it as the normal Azure environment for historical reasons) - azureEnvironment = AzureEnvironment.FromName(Value) ?? + azureSdkEnvironment = AzureEnvironment.FromName(Value) ?? throw new InvalidOperationException($"Unknown environment name {Value}"); } - private readonly AzureEnvironment azureEnvironment; + private readonly AzureEnvironment azureSdkEnvironment; public string Value { get; } public static readonly AzureKnownEnvironment Global = new AzureKnownEnvironment("AzureGlobalCloud"); @@ -29,7 +30,18 @@ public AzureKnownEnvironment(string environment) public AzureEnvironment AsAzureSDKEnvironment() { - return azureEnvironment; + return azureSdkEnvironment; } + + public ArmEnvironment AsAzureArmEnvironment() => ToArmEnvironment(Value); + + private static ArmEnvironment ToArmEnvironment(string name) => name switch + { + "AzureGlobalCloud" => ArmEnvironment.AzurePublicCloud, + "AzureChinaCloud" => ArmEnvironment.AzureChina, + "AzureGermanCloud" => ArmEnvironment.AzureGermany, + "AzureUSGovernment" => ArmEnvironment.AzureGovernment, + _ => throw new InvalidOperationException($"ARM Environment {name} is not a known Azure Environment name.") + }; } } diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index eaa6d44a..4671f9c7 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -16,6 +16,8 @@ net5.0 + + diff --git a/source/Calamari/HealthCheckCommand.cs b/source/Calamari/HealthCheckCommand.cs index 7826298a..557a5d7c 100644 --- a/source/Calamari/HealthCheckCommand.cs +++ b/source/Calamari/HealthCheckCommand.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Azure; +using Azure.ResourceManager.AppService; +using Azure.ResourceManager.Resources; using Calamari.Azure; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Pipeline; -using Microsoft.Azure.Management.ResourceManager.Fluent; namespace Calamari.AzureAppService { @@ -34,14 +36,21 @@ public Task Execute(RunningDeployment context) return ConfirmWebAppExists(account, resourceGroupName, webAppName); } - async Task ConfirmWebAppExists(ServicePrincipalAccount servicePrincipal, string resourceGroupName, string siteAndSlotName) + private async Task ConfirmWebAppExists(ServicePrincipalAccount servicePrincipal, string resourceGroupName, string siteAndSlotName) { - var azureClient = servicePrincipal.CreateAzureClient(); - var webApp = await azureClient.WebApps.GetByResourceGroupAsync(resourceGroupName, siteAndSlotName); - if (webApp == null) + var client = servicePrincipal.CreateArmClient(); + var subscription = await client.GetDefaultSubscriptionAsync(); + var resourceGroups = subscription.GetResourceGroups(); + + try + { + ResourceGroupResource resourceGroup = await resourceGroups.GetAsync(resourceGroupName); + _ = await resourceGroup.GetWebSiteAsync(siteAndSlotName); + } + catch (RequestFailedException rfe) when (rfe.Status == 404) { - throw new Exception($"Could not find site {siteAndSlotName} in resource group {resourceGroupName}, using Service Principal with subscription {servicePrincipal.SubscriptionNumber}"); + throw new Exception($"Could not find site {siteAndSlotName} in resource group {resourceGroupName}, using Service Principal with subscription {servicePrincipal.SubscriptionNumber}", rfe); } } } -} \ No newline at end of file +} diff --git a/source/Sashimi.Tests/AzureWebAppHealthCheckActionHandlerFixtures.cs b/source/Sashimi.Tests/AzureWebAppHealthCheckActionHandlerFixtures.cs index 9fb1c74d..bdb75ee1 100644 --- a/source/Sashimi.Tests/AzureWebAppHealthCheckActionHandlerFixtures.cs +++ b/source/Sashimi.Tests/AzureWebAppHealthCheckActionHandlerFixtures.cs @@ -1,7 +1,12 @@ #nullable disable +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; using System.Threading.Tasks; using Calamari.Azure; using Calamari.AzureAppService; +using Calamari.Common.Plumbing.Proxies; using Calamari.Tests.Shared; using FluentAssertions; using Microsoft.Azure.Management.AppService.Fluent; @@ -9,7 +14,9 @@ using Microsoft.Azure.Management.ResourceManager.Fluent.Core; using NUnit.Framework; using Sashimi.Azure.Accounts; +using Sashimi.Server.Contracts.ActionHandlers; using Sashimi.Tests.Shared.Server; +using OperatingSystem = Microsoft.Azure.Management.AppService.Fluent.OperatingSystem; namespace Sashimi.AzureAppService.Tests { @@ -113,4 +120,118 @@ public void WebApp_Is_Not_Found() .Execute(false); } } -} \ No newline at end of file + + [TestFixture] + class AzureWebAppHealthCheckActionHandlerProxyFixture + { + private const string NonExistentProxyHostname = "non-existent-proxy.local"; + private const int NonExistentProxyPort = 3128; + + private IWebProxy? originalProxy; + private string originalProxyHost; + private string originalProxyPort; + + private StringWriter errorStream; + private TextWriter originalConsoleErrorOut; + + [SetUp] + public void SetUp() + { + SetLocalEnvironmentProxySettings(NonExistentProxyHostname, NonExistentProxyPort); + SetCiEnvironmentProxySettings(NonExistentProxyHostname, NonExistentProxyPort); + SetConsoleErrorOut(); + } + + /// + /// Configuring all the infrastructure required for a proper proxy test (with blocking certain addresses, proxy + /// server itself etc) is over the top for a test here. We can implicitly test that the proxy settings are being + /// picked up properly by setting a non-existent property, and ensuring that we fail with connectivity errors + /// *to the non-existent proxy* rather than a successful healthcheck directly to Azure. + /// + [Test] + public void ConfiguredProxy_IsUsedForHealthCheck() + { + // Arrange + var randomName = SdkContext.RandomResourceName(nameof(AzureWebAppHealthCheckActionHandlerFixtures), 60); + var clientId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionClientId); + var clientSecret = ExternalVariables.Get(ExternalVariable.AzureSubscriptionPassword); + var tenantId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionTenantId); + var subscriptionId = ExternalVariables.Get(ExternalVariable.AzureSubscriptionId); + + // Act + var result = ActionHandlerTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(AccountVariables.SubscriptionId, subscriptionId); + context.Variables.Add(AccountVariables.TenantId, tenantId); + context.Variables.Add(AccountVariables.ClientId, clientId); + context.Variables.Add(AccountVariables.Password, clientSecret); + context.Variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, randomName); + context.Variables.Add(SpecialVariables.Action.Azure.WebAppName, randomName); + context.Variables.Add(SpecialVariables.AccountType, AccountTypes.AzureServicePrincipalAccountType.ToString()); + }) + .Execute(assertWasSuccess: false); + + // Assert + result.Outcome.Should().Be(ExecutionOutcome.Unsuccessful); + + // This also operates differently locally vs on CI, so combine both StdErr and Calamari Log to get + // the full picture + var windowsNetFxDnsError = "The remote name could not be resolved: 'non-existent-proxy.local'"; + var ubuntuDnsError = "Resource temporarily unavailable (non-existent-proxy.local:3128)"; + var generalLinuxDnsError = "Name or service not known (non-existent-proxy.local:3128)"; + var windowsDotNetDnsError = "No such host is known. (non-existent-proxy.local:3128)"; + + var calamariOutput = result.FullLog + errorStream; + calamariOutput.Should().ContainAny(windowsDotNetDnsError, ubuntuDnsError,generalLinuxDnsError, windowsNetFxDnsError); + } + + [TearDown] + public void TearDown() + { + RestoreLocalEnvironmentProxySettings(); + RestoreCiEnvironmentProxySettings(); + RestoreConsoleErrorOut(); + } + + private void SetConsoleErrorOut() + { + originalConsoleErrorOut = Console.Error; + errorStream = new StringWriter(); + Console.SetError(errorStream); + } + + private void SetLocalEnvironmentProxySettings(string hostname, int port) + { + originalProxy = WebRequest.DefaultWebProxy; + + var proxySettings = new UseCustomProxySettings(hostname, port, null!, null!).CreateProxy().Value; + WebRequest.DefaultWebProxy = proxySettings; + } + + private void SetCiEnvironmentProxySettings(string hostname, int port) + { + originalProxyHost = Environment.GetEnvironmentVariable(EnvironmentVariables.TentacleProxyHost); + originalProxyPort = Environment.GetEnvironmentVariable(EnvironmentVariables.TentacleProxyPort); + + Environment.SetEnvironmentVariable(EnvironmentVariables.TentacleProxyHost, hostname); + Environment.SetEnvironmentVariable(EnvironmentVariables.TentacleProxyPort, $"{port}"); + } + + private void RestoreConsoleErrorOut() + { + Console.SetError(originalConsoleErrorOut); + } + + private void RestoreLocalEnvironmentProxySettings() + { + WebRequest.DefaultWebProxy = originalProxy; + } + + private void RestoreCiEnvironmentProxySettings() + { + Environment.SetEnvironmentVariable(EnvironmentVariables.TentacleProxyHost, originalProxyHost); + Environment.SetEnvironmentVariable(EnvironmentVariables.TentacleProxyPort, originalProxyPort); + } + } +}