diff --git a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs deleted file mode 100644 index 527ebf9cc3..0000000000 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Azure.Core; -using Azure.ResourceManager; -using Azure.ResourceManager.AppService; -using Azure.ResourceManager.Resources; -using Calamari.Azure; -using Calamari.AzureAppService.Azure; -using Calamari.CloudAccounts; -using Calamari.Testing; -using NUnit.Framework; -using Octostache; -using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; - -namespace Calamari.AzureAppService.Tests; - -public abstract class AppServiceIntegrationTestWithStaticResources -{ - //https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourceGroups/calamari-testing-static-rg/overview - protected const string ResourceGroupName = "calamari-testing-static-rg"; - - static readonly CancellationTokenSource CancellationTokenSource = new(); - protected CancellationToken CancellationToken => CancellationTokenSource.Token; - - 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 ArmClient ArmClient { get; private set; } - - protected SubscriptionResource SubscriptionResource { get; private set; } - protected ResourceGroupResource ResourceGroupResource { get; private set; } - - protected virtual string ResourceGroupLocation => "australiaeast"; - [OneTimeSetUp] - public async Task OneTimeSetUp() - { - 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); - - await TestContext.Progress.WriteLineAsync($"Resource group location: {ResourceGroupLocation}"); - - 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); - // AzureAppServiceDeployContainerBehaviorFixture.AzureLinuxContainerSlotDeploy occasional timeout at default 100 seconds - retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); - }); - - //create the resource group - SubscriptionResource = ArmClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(SubscriptionId)); - ResourceGroupResource = await SubscriptionResource.GetResourceGroupAsync(ResourceGroupName, CancellationToken); - } - - protected void AddAzureVariables(CommandTestBuilderContext context) - { - AddAzureVariables(context.Variables); - } - - protected void AddAzureVariables(VariableDictionary variables) - { - variables.Add(AccountVariables.ClientId, ClientId); - variables.Add(AccountVariables.Password, ClientSecret); - variables.Add(AccountVariables.TenantId, TenantId); - variables.Add(AccountVariables.SubscriptionId, SubscriptionId); - variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); - } -} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/AzureAppServiceContainerDeployBehaviourUnitTestFixture.cs b/source/Calamari.AzureAppService.Tests/AzureAppServiceContainerDeployBehaviourUnitTestFixture.cs new file mode 100644 index 0000000000..16be5900c0 --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/AzureAppServiceContainerDeployBehaviourUnitTestFixture.cs @@ -0,0 +1,131 @@ +using System.Threading.Tasks; +using Azure.ResourceManager.AppService; +using Azure.ResourceManager.AppService.Models; +using Calamari.Azure.AppServices; +using Calamari.AzureAppService.Azure; +using Calamari.AzureAppService.Behaviors; +using Calamari.CloudAccounts; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; +using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; + +namespace Calamari.AzureAppService.Tests; + +[TestFixture] +public class AzureAppServiceContainerDeployBehaviourUnitTestFixture +{ + const string WebAppName = "my-web-app"; + const string ResourceGroupName = "my-rg"; + const string Registry = "index.docker.io"; + const string Image = "e2eteam/sample-apiserver:1.17"; + const string RegistryUsername = "registry-user"; + const string RegistryPassword = "registry-password"; + + [Test] + public async Task LinuxWebApp_SetsLinuxFxVersionAndRegistrySettings() + { + // Arrange + var config = new SiteConfigData(); + var appSettings = new AppServiceConfigurationDictionary(); + var configurer = ConfigurerReturning(isLinux: true, config, appSettings); + var sut = new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), configurer); + + // Act + await sut.Execute(ContextFor(slotName: null)); + + // Assert + config.LinuxFxVersion.Should().Be($"DOCKER|{Image}"); + config.WindowsFxVersion.Should().BeNull("the Linux FxVersion field should be set, not the Windows one"); + appSettings.Properties["DOCKER_REGISTRY_SERVER_URL"].Should().Be("https://" + Registry); + appSettings.Properties["DOCKER_REGISTRY_SERVER_USERNAME"].Should().Be(RegistryUsername); + appSettings.Properties["DOCKER_REGISTRY_SERVER_PASSWORD"].Should().Be(RegistryPassword); + await configurer.Received(1).UpdateAppSettings(Arg.Any(), Arg.Any(), appSettings); + await configurer.Received(1).UpdateSiteConfig(Arg.Any(), Arg.Any(), config); + } + + [Test] + public async Task WindowsWebApp_SetsWindowsFxVersion() + { + // Arrange + var config = new SiteConfigData(); + var configurer = ConfigurerReturning(isLinux: false, config, new AppServiceConfigurationDictionary()); + var sut = new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), configurer); + + // Act + await sut.Execute(ContextFor(slotName: null)); + + // Assert + config.WindowsFxVersion.Should().Be($"DOCKER|{Image}"); + config.LinuxFxVersion.Should().BeNull("the Windows FxVersion field should be set, not the Linux one"); + } + + [Test] + public async Task SlotDeploy_TargetsTheSlot() + { + // Arrange + var configurer = ConfigurerReturning(isLinux: true, new SiteConfigData(), new AppServiceConfigurationDictionary()); + var sut = new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), configurer); + + // Act + await sut.Execute(ContextFor(slotName: "stage")); + + // Assert + await configurer.Received().UpdateSiteConfig( + Arg.Any(), + Arg.Is(t => t.HasSlot && t.Slot == "stage"), + Arg.Any()); + } + + [Test] + public async Task NonSlotDeploy_TargetsTheWebAppItself() + { + // Arrange + var configurer = ConfigurerReturning(isLinux: true, new SiteConfigData(), new AppServiceConfigurationDictionary()); + var sut = new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), configurer); + + // Act + await sut.Execute(ContextFor(slotName: null)); + + // Assert + await configurer.Received().UpdateSiteConfig( + Arg.Any(), + Arg.Is(t => !t.HasSlot && t.Site == WebAppName), + Arg.Any()); + } + + static IAzureAppServiceContainerConfigurer ConfigurerReturning(bool isLinux, SiteConfigData config, AppServiceConfigurationDictionary appSettings) + { + var configurer = Substitute.For(); + configurer.IsLinuxWebApp(Arg.Any(), Arg.Any()).Returns(isLinux); + configurer.GetSiteConfig(Arg.Any(), Arg.Any()).Returns(config); + configurer.GetAppSettings(Arg.Any(), Arg.Any()).Returns(appSettings); + return configurer; + } + + static RunningDeployment ContextFor(string slotName) + { + var variables = new CalamariVariables(); + + // Dummy credentials - the configurer that would use them to reach Azure is mocked in these tests. + variables.Add(AccountVariables.SubscriptionId, "subscription-id"); + variables.Add(AccountVariables.ClientId, "client-id"); + variables.Add(AccountVariables.TenantId, "tenant-id"); + variables.Add(AccountVariables.Password, "client-secret"); + + variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + variables.Add(SpecialVariables.Action.Azure.WebAppName, WebAppName); + if (slotName != null) + variables.Add(SpecialVariables.Action.Azure.WebAppSlot, slotName); + + variables.Add(SpecialVariables.Action.Package.Image, Image); + variables.Add(SpecialVariables.Action.Package.Registry, Registry); + variables.Add(SpecialVariables.Action.Package.Feed.Username, RegistryUsername); + variables.Add(SpecialVariables.Action.Package.Feed.Password, RegistryPassword); + + return new RunningDeployment("", variables); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj index 7ef8b53374..61efd16f03 100644 --- a/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj +++ b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj @@ -28,13 +28,8 @@ - - Always - - - Always - - + + functionapp.1.0.0.zip Always diff --git a/source/Calamari.AzureAppService.Tests/AppServiceBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs similarity index 54% rename from source/Calamari.AzureAppService.Tests/AppServiceBehaviourFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index 6c450360af..a4cc698f47 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; @@ -16,7 +16,6 @@ using Azure.ResourceManager.Storage; using Azure.ResourceManager.Storage.Models; using Calamari.AzureAppService.Azure; -using Calamari.Common.FeatureToggles; using Calamari.Common.Plumbing.FileSystem; using Calamari.Common.Plumbing.Variables; using Calamari.Testing; @@ -24,22 +23,22 @@ using Calamari.Testing.LogParser; using FluentAssertions; using NUnit.Framework; -using FileShare = System.IO.FileShare; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { + // The two nested fixtures cover two distinct Azure deployment targets - a Web App and a (Linux) Function + // App - rather than two operating systems. Calamari runs the same zip-deploy code path for both; the split + // exists because the Function App is a different resource type (function runtime, run-from-package), not + // because the OS differs. public class AppServiceBehaviorFixture { [TestFixture] - public class WhenUsingAWindowsDotNetAppService : AppServiceIntegrationTest + public class WhenDeployingToAWebAzureApp : AzureAppServiceWithProvisionedResourcesTestBase { - private AppServicePlanResource appServicePlanResource; - protected override async Task ConfigureTestResources(ResourceGroupResource resourceGroup) { - var (appServicePlan, webSite) = await CreateAppServicePlanAndWebApp(resourceGroup); + var (_, webSite) = await CreateAppServicePlanAndWebApp(resourceGroup); - appServicePlanResource = appServicePlan; WebSiteResource = webSite; } @@ -76,25 +75,6 @@ await CommandTestBuilder.CreateAsync() await AssertContent(WebSiteResource.Data.DefaultHostName, $"Hello {greeting}"); } - [Test] - public async Task CanDeployWebAppZip_WithAsyncDeploymentAndPolling() - { - var packageInfo = PrepareZipPackage(); - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageInfo.packagePath, packageInfo.packageName, packageInfo.packageVersion); - AddVariables(context); - - var existingFeatureToggles = context.Variables.GetStrings(KnownVariables.EnabledFeatureToggles); - context.Variables.SetStrings(KnownVariables.EnabledFeatureToggles, existingFeatureToggles); - }) - .Execute(); - - await AssertContent(WebSiteResource.Data.DefaultHostName, $"Hello {greeting}"); - } - [Test] public async Task CanDeployWebAppZip_ToDeploymentSlot() { @@ -147,117 +127,6 @@ await CommandTestBuilder.CreateAsync() await AssertContent(WebSiteResource.Data.DefaultHostName, $"Hello {greeting}"); } - [Test] - public async Task CanDeployNugetPackage_WithAsyncDeploymentAndPolling() - { - var packageInfo = await PrepareNugetPackage(); - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageInfo.packagePath, packageInfo.packageName, packageInfo.packageVersion); - AddVariables(context); - - var existingFeatureToggles = context.Variables.GetStrings(KnownVariables.EnabledFeatureToggles); - context.Variables.SetStrings(KnownVariables.EnabledFeatureToggles, existingFeatureToggles); - }) - .Execute(); - - //await new AzureAppServiceBehaviour(new InMemoryLog()).Execute(runningContext); - await AssertContent(WebSiteResource.Data.DefaultHostName, $"Hello {greeting}"); - } - - [Test] - public async Task CanDeployWarPackage() - { - // Need to spin up a specific app service with Tomcat installed - // Need java installed on the test runner (MJH 2022-05-06: is this actually true? I don't see why we'd need java on the test runner) - var javaSite = await ResourceGroupResource.GetWebSites() - .CreateOrUpdateAsync(WaitUntil.Completed, - $"{ResourceGroupName}-java", - new WebSiteData(ResourceGroupResource.Data.Location) - { - AppServicePlanId = appServicePlanResource.Data.Id, - SiteConfig = new SiteConfigProperties - { - JavaVersion = "1.8", - JavaContainer = "TOMCAT", - JavaContainerVersion = "9.0", - AppSettings = new List - { - new AppServiceNameValuePair { Name = "WEBSITES_CONTAINER_START_TIME_LIMIT", Value = "600" }, - new AppServiceNameValuePair { Name = "WEBSITE_SCM_ALWAYS_ON_ENABLED", Value = "true" } - } - } - }); - - (string packagePath, string packageName, string packageVersion) packageinfo; - var assemblyFileInfo = new FileInfo(Assembly.GetExecutingAssembly().Location); - packageinfo.packagePath = Path.Combine(assemblyFileInfo.Directory.FullName, "sample.1.0.0.war"); - packageinfo.packageVersion = "1.0.0"; - packageinfo.packageName = "sample"; - greeting = "java"; - - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageinfo.packagePath, packageinfo.packageName, packageinfo.packageVersion); - AddVariables(context); - context.Variables[SpecialVariables.Action.Azure.WebAppName] = javaSite.Value.Data.Name; - context.Variables[PackageVariables.SubstituteInFilesTargets] = "test.jsp"; - }) - .Execute(); - - await DoWithRetries(3, - async () => - { - await AssertContent(javaSite.Value.Data.DefaultHostName, $"Hello! {greeting}", "test.jsp"); - }, - secondsBetweenRetries: 10); - } - - [Test] - public async Task CanDeployJarPackage() - { - // Need to spin up a specific app service with Java installed - var javaSite = await ResourceGroupResource.GetWebSites() - .CreateOrUpdateAsync(WaitUntil.Completed, - $"{ResourceGroupName}-java", - new WebSiteData(ResourceGroupResource.Data.Location) - { - AppServicePlanId = appServicePlanResource.Data.Id, - SiteConfig = new SiteConfigProperties - { - JavaVersion = "1.8", - JavaContainer = "JAVA", - JavaContainerVersion = "9.0", - AppSettings = new List - { - new AppServiceNameValuePair { Name = "WEBSITES_CONTAINER_START_TIME_LIMIT", Value = "600" }, - new AppServiceNameValuePair { Name = "WEBSITE_SCM_ALWAYS_ON_ENABLED", Value = "true" } - } - } - }); - - (string packagePath, string packageName, string packageVersion) packageinfo; - var assemblyFileInfo = new FileInfo(Assembly.GetExecutingAssembly().Location); - packageinfo.packagePath = Path.Combine(assemblyFileInfo.Directory.FullName, "sample4-1.0.0.jar"); - packageinfo.packageVersion = "1.0.0"; - packageinfo.packageName = "sample4"; - greeting = "java"; - - var commandResult = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageinfo.packagePath, packageinfo.packageName, packageinfo.packageVersion); - AddVariables(context); - context.Variables[SpecialVariables.Action.Azure.WebAppName] = javaSite.Value.Data.Name; - }) - .Execute(); - - commandResult.Outcome.Should().Be(TestExecutionOutcome.Successful); - } - [Test] public async Task DeployingWithInvalidEnvironment_ThrowsAnException() { @@ -275,39 +144,6 @@ public async Task DeployingWithInvalidEnvironment_ThrowsAnException() commandResult.Outcome.Should().Be(TestExecutionOutcome.Unsuccessful); } - [Test] - public async Task DeployToTwoTargetsInParallel_Succeeds() - { - // Arrange - var packageInfo = PrepareFunctionAppZipPackage(); - // Without larger changes to Calamari and the Test Framework, it's not possible to run two Calamari - // processes in parallel in the same test method. Simulate the file locking behaviour by directly - // opening the affected file instead - var fileLock = File.Open(packageInfo.packagePath, FileMode.Open, FileAccess.Read, FileShare.Read); - - try - { - // Act - var deployment = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageInfo.packagePath, - packageInfo.packageName, - packageInfo.packageVersion); - AddVariables(context); - context.Variables[KnownVariables.Package.EnabledFeatures] = null; - }) - .Execute(); - - // Assert - deployment.Outcome.Should().Be(TestExecutionOutcome.Successful); - } - finally - { - fileLock.Close(); - } - } - private static (string packagePath, string packageName, string packageVersion) PrepareZipPackage() { (string packagePath, string packageName, string packageVersion) packageinfo; @@ -361,20 +197,6 @@ await Task.Run(() => File.WriteAllText( return packageinfo; } - private static (string packagePath, string packageName, string packageVersion) PrepareFunctionAppZipPackage() - { - (string packagePath, string packageName, string packageVersion) packageInfo; - - var testAssemblyLocation = new FileInfo(Assembly.GetExecutingAssembly().Location); - var sourceZip = Path.Combine(testAssemblyLocation.Directory.FullName, "functionapp.1.0.0.zip"); - - packageInfo.packagePath = sourceZip; - packageInfo.packageVersion = "1.0.0"; - packageInfo.packageName = "functionapp"; - - return packageInfo; - } - private void AddVariables(CommandTestBuilderContext context) { AddAzureVariables(context); @@ -395,14 +217,13 @@ private void AddVariables(CommandTestBuilderContext context) } [TestFixture] - public class WhenUsingALinuxAppService : AppServiceIntegrationTest + public class WhenDeployingToAFunctionAzureApp : AzureAppServiceWithProvisionedResourcesTestBase { // For some reason we are having issues creating these linux resources on Standard in EastUS protected override string DefaultResourceGroupLocation => RandomAzureRegion.GetRandomRegionWithExclusions("eastus"); static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); readonly CancellationToken cancellationToken = CancellationTokenSource.Token; - AppServicePlanResource appServicePlanResource; protected override async Task ConfigureTestResources(ResourceGroupResource resourceGroup) { @@ -440,8 +261,6 @@ protected override async Task ConfigureTestResources(ResourceGroupResource resou await linuxAppServicePlan.WaitForCompletionAsync(cancellationToken); - appServicePlanResource = linuxAppServicePlan.Value; - var linuxWebSiteResponse = await resourceGroup.GetWebSites() .CreateOrUpdateAsync(WaitUntil.Completed, $"{resourceGroup.Data.Name}-linux", @@ -527,77 +346,6 @@ await AssertContent(WebSiteResource.Data.DefaultHostName, secondsBetweenRetries: 10); } - [Test] - public async Task CanDeployZip_ToLinuxFunctionApp_WithAsyncDeploymentAndPolling() - { - // Arrange - var packageInfo = PrepareZipPackage(); - - // Act - await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageInfo.packagePath, packageInfo.packageName, packageInfo.packageVersion); - AddVariables(context); - - var existingFeatureToggles = context.Variables.GetStrings(KnownVariables.EnabledFeatureToggles); - context.Variables.SetStrings(KnownVariables.EnabledFeatureToggles, existingFeatureToggles); - }) - .Execute(); - - // Assert - await DoWithRetries(2, - async () => - { - await AssertContent(WebSiteResource.Data.DefaultHostName, - rootPath: $"api/HttpExample?name={greeting}", - actualText: $"Hello, {greeting}"); - }, - secondsBetweenRetries: 10); - } - - [Test] - public async Task CanDeployJarPackage() - { - // Need to spin up a specific app service with Java installed - var javaSite = await ResourceGroupResource.GetWebSites() - .CreateOrUpdateAsync(WaitUntil.Completed, - $"{ResourceGroupName}-java", - new WebSiteData(ResourceGroupResource.Data.Location) - { - AppServicePlanId = appServicePlanResource.Data.Id, - SiteConfig = new SiteConfigProperties - { - IsAlwaysOn = true, - LinuxFxVersion = "JAVA|21-java21", - Use32BitWorkerProcess = true, - AppSettings = new List - { - new AppServiceNameValuePair { Name = "WEBSITES_CONTAINER_START_TIME_LIMIT", Value = "460" }, - new AppServiceNameValuePair { Name = "WEBSITE_SCM_ALWAYS_ON_ENABLED", Value = "true" } - } - } - }); - - (string packagePath, string packageName, string packageVersion) packageinfo; - var assemblyFileInfo = new FileInfo(Assembly.GetExecutingAssembly().Location); - packageinfo.packagePath = Path.Combine(assemblyFileInfo.Directory.FullName, "sample4-1.0.0.jar"); - packageinfo.packageVersion = "1.0.0"; - packageinfo.packageName = "sample4"; - greeting = "java"; - - var commandResult = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.WithPackage(packageinfo.packagePath, packageinfo.packageName, packageinfo.packageVersion); - AddVariables(context); - context.Variables[SpecialVariables.Action.Azure.WebAppName] = javaSite.Value.Data.Name; - }) - .Execute(); - - commandResult.Outcome.Should().Be(TestExecutionOutcome.Successful); - } - private static (string packagePath, string packageName, string packageVersion) PrepareZipPackage() { // Looks like there's some file locking issues if multiple tests try to copy from the same file when running in parallel. diff --git a/source/Calamari.AzureAppService.Tests/AppServiceSettingsBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs similarity index 98% rename from source/Calamari.AzureAppService.Tests/AppServiceSettingsBehaviourFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs index 2709cd0c9e..b748f8183c 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceSettingsBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs @@ -15,10 +15,10 @@ using Newtonsoft.Json; using NUnit.Framework; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { [TestFixture] - public class AppServiceSettingsBehaviorFixture : AppServiceIntegrationTest + public class AzureAppServiceSettingsBehaviorFixture : AzureAppServiceWithProvisionedResourcesTestBase { string slotName; AppServiceConfigurationDictionary existingSettings; diff --git a/source/Calamari.AzureAppService.Tests/AzureWebAppHealthCheckActionHandlerFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs similarity index 95% rename from source/Calamari.AzureAppService.Tests/AzureWebAppHealthCheckActionHandlerFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs index f260565f43..a187f45cc9 100644 --- a/source/Calamari.AzureAppService.Tests/AzureWebAppHealthCheckActionHandlerFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs @@ -2,10 +2,6 @@ using System; using System.Net; using System.Threading.Tasks; -using Azure; -using Azure.ResourceManager.AppService; -using Azure.ResourceManager.AppService.Models; -using Azure.ResourceManager.Resources; using Calamari.AzureAppService.Azure; using Calamari.Common.Plumbing.Proxies; using Calamari.Testing; @@ -13,10 +9,10 @@ using NUnit.Framework; using NUnit.Framework.Internal; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { [TestFixture] - class AzureWebAppHealthCheckActionHandlerFixture : AppServiceIntegrationTestWithStaticResources + class AzureAppHealthCheckActionHandlerFixture : AzureAppServiceWithStaticResourcesTestBase { // https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourcegroups/calamari-testing-static-rg/providers/Microsoft.Web/sites/calamari-testing-static-health-check/appServices const string WebAppName = "calamari-testing-static-health-check"; diff --git a/source/Calamari.AzureAppService.Tests/AzureAppServiceDeployContainerBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs similarity index 50% rename from source/Calamari.AzureAppService.Tests/AzureAppServiceDeployContainerBehaviourFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs index de16da63fd..216c99415e 100644 --- a/source/Calamari.AzureAppService.Tests/AzureAppServiceDeployContainerBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -13,7 +13,6 @@ using Calamari.AzureAppService.Behaviors; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Variables; -using Calamari.Testing; using Calamari.Testing.Azure; using Calamari.Testing.Helpers; using FluentAssertions; @@ -21,161 +20,19 @@ using Octostache; using Polly; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { /// - /// Tests that both windows and linux app services can have container deployments + /// Real-cloud smoke tests confirming a container deployment round-trips against a real Linux app + /// service, for both the web app itself and a deployment slot (auth + slot-vs-site ARM config/app-settings + /// GET and PATCH). A single OS is sufficient here: the Windows/Linux FxVersion branch, along with the + /// image/registry and slot-targeting logic, is covered without Azure in + /// AzureAppServiceContainerDeployBehaviourUnitTestFixture via a mocked IAzureAppServiceContainerConfigurer. /// - /// - /// Both test fixtures have the same two tests, but they have different setups, so it's just easier to have separate test fixtures. - /// public class AzureAppServiceDeployContainerBehaviourFixture { [TestFixture] - public class WhenUsingAWindowsAppService : AppServiceIntegrationTest - { - CalamariVariables newVariables; - readonly HttpClient client = new HttpClient(); - - //We are having capacity issues in EastUS and WestUS2 - protected override string DefaultResourceGroupLocation => RandomAzureRegion.GetRandomRegionWithExclusions("eastus", "westus2"); - - protected override async Task ConfigureTestResources(ResourceGroupResource resourceGroup) - { - var (_, webSite) = await CreateAppServicePlanAndWebApp(resourceGroup, - new AppServicePlanData(resourceGroup.Data.Location) - { - IsXenon = true, - IsHyperV = true, - Sku = new AppServiceSkuDescription - { - Name = "P1V3", - Tier = "PremiumV3" - } - }, - webSiteData: new WebSiteData(resourceGroup.Data.Location) - { - SiteConfig = new SiteConfigProperties - { - WindowsFxVersion = "DOCKER|mcr.microsoft.com/dotnet/samples:aspnetapp", - IsAlwaysOn = true, - AppSettings = new List - { - new AppServiceNameValuePair { Name = "DOCKER_REGISTRY_SERVER_URL", Value = "https://index.docker.io" }, - new AppServiceNameValuePair { Name = "WEBSITES_ENABLE_APP_SERVICE_STORAGE", Value = "false" }, - new AppServiceNameValuePair { Name = "WEBSITES_CONTAINER_START_TIME_LIMIT", Value = "460" } - } - } - }); - - WebSiteResource = webSite; - - await AssertSetupSuccessAsync(); - } - - [Test] - public async Task AzureWindowsContainerDeploy() - { - newVariables = new CalamariVariables(); - AddVariables(newVariables); - - var runningContext = new RunningDeployment("", newVariables); - - await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog()).Execute(runningContext); - - var targetSite = new AzureTargetSite(SubscriptionId, - ResourceGroupName, - WebSiteResource.Data.Name); - - await AssertDeploySuccessAsync(targetSite); - } - - [Test] - public async Task AzureWindowsContainerSlotDeploy() - { - var slotName = "stage"; - - newVariables = new CalamariVariables(); - AddVariables(newVariables); - newVariables.Add("Octopus.Action.Azure.DeploymentSlot", slotName); - await WebSiteResource.GetWebSiteSlots() - .CreateOrUpdateAsync(WaitUntil.Completed, - slotName, - WebSiteResource.Data); - - var runningContext = new RunningDeployment("", newVariables); - - await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog()).Execute(runningContext); - - var targetSite = new AzureTargetSite(SubscriptionId, - ResourceGroupName, - WebSiteResource.Data.Name, - slotName); - - await AssertDeploySuccessAsync(targetSite); - } - - async Task AssertSetupSuccessAsync() - { - var timeout = Policy.TimeoutAsync(TimeSpan.FromMinutes(5)); - - var receivedContent = await timeout.ExecuteAsync(async () => - { - string content; - do - { - var response = await RetryPolicies.TestsTransientHttpErrorsPolicy - .ExecuteAsync(async context => - { - var r = await client.GetAsync($@"https://{WebSiteResource.Data.DefaultHostName}"); - - if (!r.IsSuccessStatusCode) - { - var messageContent = await r.Content.ReadAsStringAsync(); - TestContext.WriteLine($"Unable to retrieve content from https://{WebSiteResource.Data.DefaultHostName}, failed with: {messageContent}"); - } - - r.EnsureSuccessStatusCode(); - return r; - }, - contextData: new Dictionary()); - - content = await response.Content.ReadAsStringAsync(); - } while (content is null || content.Contains("container is starting up")); - - return content; - }); - - receivedContent.Should().Contain("

Welcome to .NET

"); - } - - async Task AssertDeploySuccessAsync(AzureTargetSite targetSite) - { - var imageName = newVariables.Get(SpecialVariables.Action.Package.PackageId); - var registryUrl = newVariables.Get(SpecialVariables.Action.Package.Registry); - var imageVersion = newVariables.Get(SpecialVariables.Action.Package.PackageVersion) ?? "latest"; - - var config = await WebSiteResource.GetWebSiteConfig().GetAsync(); - Assert.AreEqual($@"DOCKER|{imageName}:{imageVersion}", config.Value.Data.WindowsFxVersion); - - var appSettings = await ArmClient.GetAppSettingsListAsync(targetSite); - Assert.AreEqual("https://" + registryUrl, appSettings.FirstOrDefault(app => app.Name == "DOCKER_REGISTRY_SERVER_URL")?.Value); - } - - void AddVariables(VariableDictionary vars) - { - AddAzureVariables(vars); - vars.Add(SpecialVariables.Action.Package.FeedId, "Feeds-42"); - vars.Add(SpecialVariables.Action.Package.Registry, "index.docker.io"); - vars.Add(SpecialVariables.Action.Package.PackageId, "e2eteam/sample-apiserver"); - vars.Add(SpecialVariables.Action.Package.Image, "e2eteam/sample-apiserver:1.17"); - vars.Add(SpecialVariables.Action.Package.PackageVersion, "1.17"); - vars.Add(SpecialVariables.Action.Azure.DeploymentType, "Container"); - } - } - - [TestFixture] - public class WhenUsingALinuxAppService : AppServiceIntegrationTest + public class WhenUsingALinuxAzureAppService : AzureAppServiceWithProvisionedResourcesTestBase { CalamariVariables newVariables; readonly HttpClient client = new HttpClient(); @@ -225,7 +82,7 @@ public async Task AzureLinuxContainerDeploy() var runningContext = new RunningDeployment("", newVariables); - await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog()).Execute(runningContext); + await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), new AzureAppServiceContainerConfigurer()).Execute(runningContext); var targetSite = new AzureTargetSite(SubscriptionId, ResourceGroupName, @@ -249,7 +106,7 @@ await WebSiteResource.GetWebSiteSlots() var runningContext = new RunningDeployment("", newVariables); - await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog()).Execute(runningContext); + await new AzureAppServiceContainerDeployBehaviour(new InMemoryLog(), new AzureAppServiceContainerConfigurer()).Execute(runningContext); var targetSite = new AzureTargetSite(SubscriptionId, ResourceGroupName, diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs new file mode 100644 index 0000000000..ef1fa4a894 --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Calamari.Azure; +using Calamari.AzureAppService.Azure; +using Calamari.CloudAccounts; +using Calamari.Testing; +using Calamari.Testing.Helpers; +using NUnit.Framework; +using Octostache; +using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; + +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration +{ + /// + /// Shared base for fixtures that talk to real Azure. Authenticates with the test service principal and + /// creates the in a OneTimeSetUp that runs before any derived OneTimeSetUp, so + /// subclasses can create or look up their resources against the established connection. Carries the + /// ExternalCloudIntegration category, which all descendants inherit. + /// + [Category(TestCategory.ExternalCloudIntegration)] + public abstract class AzureAppServiceTestBase + { + 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 AzureServicePrincipalAccount ServicePrincipalAccount { get; private set; } + protected ArmClient ArmClient { get; private set; } + protected SubscriptionResource SubscriptionResource { get; private set; } + + // Set by the derived class once it has created (dynamic) or looked up (static) its resource group. + protected ResourceGroupResource ResourceGroupResource { get; set; } + + static readonly CancellationTokenSource CancellationTokenSource = new(); + protected CancellationToken CancellationToken => CancellationTokenSource.Token; + + [OneTimeSetUp] + public async Task AuthenticateWithAzure() + { + 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); + + 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); + // AzureAppServiceDeployContainerBehaviorFixture.AzureLinuxContainerSlotDeploy occasional timeout at default 100 seconds + retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); + }); + + SubscriptionResource = ArmClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(SubscriptionId)); + } + + protected void AddAzureVariables(CommandTestBuilderContext context) => AddAzureVariables(context.Variables); + + protected virtual void AddAzureVariables(VariableDictionary variables) + { + variables.Add(AccountVariables.ClientId, ClientId); + variables.Add(AccountVariables.Password, ClientSecret); + variables.Add(AccountVariables.TenantId, TenantId); + variables.Add(AccountVariables.SubscriptionId, SubscriptionId); + } + } +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs similarity index 65% rename from source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs index 08dfb8a3b8..ea24a2c366 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs @@ -1,94 +1,47 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Net.Http; -using System.Threading; using System.Threading.Tasks; using Azure; using Azure.Core; -using Azure.ResourceManager; using Azure.ResourceManager.AppService; using Azure.ResourceManager.AppService.Models; using Azure.ResourceManager.Resources; -using Calamari.Azure; using Calamari.Azure.AppServices; using Calamari.AzureAppService.Azure; -using Calamari.CloudAccounts; -using Calamari.Testing; using Calamari.Testing.Azure; using FluentAssertions; -using JetBrains.TeamCity.ServiceMessages.Write.Special.Impl.Writer; using Newtonsoft.Json; using NUnit.Framework; using Octostache; -using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { - public abstract class AppServiceIntegrationTest + // Creates and deploys to a freshly provisioned resource group, deleting it on teardown. + public abstract class AzureAppServiceWithProvisionedResourcesTestBase : AzureAppServiceTestBase { - 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 string greeting = "Calamari"; - protected ArmClient ArmClient { get; private set; } - - protected SubscriptionResource SubscriptionResource { get; private set; } - protected ResourceGroupResource ResourceGroupResource { get; private set; } protected WebSiteResource WebSiteResource { get; private protected set; } private readonly HttpClient client = new HttpClient(); protected virtual string DefaultResourceGroupLocation => RandomAzureRegion.GetRandomRegionWithExclusions(); - static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); - readonly CancellationToken cancellationToken = CancellationTokenSource.Token; - + // Runs after the base AuthenticateWithAzure OneTimeSetUp, so ArmClient/SubscriptionResource are ready. [OneTimeSetUp] - public async Task Setup() + public async Task CreateResourceGroupAndResources() { - var resourceManagementEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ResourceManagementEndPoint) ?? DefaultVariables.ResourceManagementEndpoint; - var activeDirectoryEndpointBaseUri = - Environment.GetEnvironmentVariable(AccountVariables.ActiveDirectoryEndPoint) ?? DefaultVariables.ActiveDirectoryEndpoint; - ResourceGroupName = AzureTestResourceHelpers.GetResourceGroupName(); - - 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") ?? DefaultResourceGroupLocation; - + TestContext.Progress.WriteLine($"Resource group location: {ResourceGroupLocation}"); try { - 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); - // AzureAppServiceDeployContainerBehaviorFixture.AzureLinuxContainerSlotDeploy occasional timeout at default 100 seconds - retryOptions.NetworkTimeout = TimeSpan.FromSeconds(200); - }); - - //create the resource group - SubscriptionResource = ArmClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(SubscriptionId)); - var response = await SubscriptionResource .GetResourceGroups() .CreateOrUpdateAsync(WaitUntil.Completed, @@ -160,17 +113,9 @@ protected static async Task DoWithRetries(int retries, Func action, int se } } - protected void AddAzureVariables(CommandTestBuilderContext context) - { - AddAzureVariables(context.Variables); - } - - protected void AddAzureVariables(VariableDictionary variables) + protected override void AddAzureVariables(VariableDictionary variables) { - variables.Add(AccountVariables.ClientId, ClientId); - variables.Add(AccountVariables.Password, ClientSecret); - variables.Add(AccountVariables.TenantId, TenantId); - variables.Add(AccountVariables.SubscriptionId, SubscriptionId); + base.AddAzureVariables(variables); variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); variables.Add(SpecialVariables.Action.Azure.WebAppName, WebSiteResource.Data.Name); } diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithStaticResourcesTestBase.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithStaticResourcesTestBase.cs new file mode 100644 index 0000000000..1d2d3a2d12 --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithStaticResourcesTestBase.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +using Calamari.AzureAppService.Azure; +using NUnit.Framework; +using Octostache; + +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration; + +// Queries a pre-existing static resource group; creates and deletes nothing. +public abstract class AzureAppServiceWithStaticResourcesTestBase : AzureAppServiceTestBase +{ + //https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourceGroups/calamari-testing-static-rg/overview + protected const string ResourceGroupName = "calamari-testing-static-rg"; + + protected virtual string ResourceGroupLocation => "australiaeast"; + + // Runs after the base AuthenticateWithAzure OneTimeSetUp, so ArmClient/SubscriptionResource are ready. + [OneTimeSetUp] + public async Task LookUpStaticResourceGroup() + { + await TestContext.Progress.WriteLineAsync($"Resource group location: {ResourceGroupLocation}"); + ResourceGroupResource = await SubscriptionResource.GetResourceGroupAsync(ResourceGroupName, CancellationToken); + } + + protected override void AddAzureVariables(VariableDictionary variables) + { + base.AddAzureVariables(variables); + variables.Add(SpecialVariables.Action.Azure.ResourceGroupName, ResourceGroupName); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/TargetDiscoveryBehaviourWithStaticResourcesTestFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/TargetDiscoveryBehaviourWithStaticResourcesTestFixture.cs new file mode 100644 index 0000000000..fb1be8a553 --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/TargetDiscoveryBehaviourWithStaticResourcesTestFixture.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using Calamari.AzureAppService.Azure; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration; + +// Smoke test: validates the real Azure Resource Graph query and authentication still work. +// The tag-matching, slot-handling and service-message logic that consumes these resources is +// covered without Azure in TargetDiscoveryBehaviourUnitTestFixture via a mocked IAzureWebAppDiscoverer. +[TestFixture] +public class TargetDiscoveryBehaviourWithStaticResourcesTestFixture : AzureAppServiceWithStaticResourcesTestBase +{ + // https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourcegroups/calamari-testing-static-rg/providers/Microsoft.Web/sites/calamari-testing-static-target-discovery/appServices + const string WebAppName = "calamari-testing-static-target-discovery"; + + [Test] + public async Task DiscoverWebAppsAndSlots_ReturnsTheStaticTestWebApp() + { + // Arrange + var discoverer = new AzureWebAppDiscoverer(); + + // Act + var resources = await discoverer.DiscoverWebAppsAndSlots(ServicePrincipalAccount); + + // Assert + resources.Should().Contain(r => r.Name == WebAppName && r.ResourceGroup == ResourceGroupName, + "the real Azure Resource Graph query should return the statically provisioned test web app"); + } +} diff --git a/source/Calamari.AzureAppService.Tests/functionapp.1.0.0.zip b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/functionapp.1.0.0.zip similarity index 100% rename from source/Calamari.AzureAppService.Tests/functionapp.1.0.0.zip rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/functionapp.1.0.0.zip diff --git a/source/Calamari.AzureAppService.Tests/PackageProviderFactoryFixture.cs b/source/Calamari.AzureAppService.Tests/PackageProviderFactoryFixture.cs new file mode 100644 index 0000000000..adccf93456 --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/PackageProviderFactoryFixture.cs @@ -0,0 +1,39 @@ +using System; +using Calamari.AzureAppService; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.AzureAppService.Tests; + +[TestFixture] +public class PackageProviderFactoryFixture +{ + // The Kudu upload endpoint and sync/async behaviour are the only things that differ between package + // types (notably .war and .jar route to the same JavaPackageProvider, differing solely by upload URL). + [TestCase(".zip", "/api/zipdeploy", true)] + [TestCase(".nupkg", "/api/zipdeploy", true)] + [TestCase(".war", "/api/wardeploy", false)] + [TestCase(".jar", "/api/publish?type=jar", false)] + public void GetProvider_SelectsCorrectUploadEndpointAndDeploymentMode(string extension, string expectedUploadUrlPath, bool expectedSupportsAsync) + { + var provider = PackageProviderFactory.GetProvider(extension, new InMemoryLog(), Substitute.For(), new CalamariVariables(), DeploymentContext()); + + provider.UploadUrlPath.Should().Be(expectedUploadUrlPath); + provider.SupportsAsynchronousDeployment.Should().Be(expectedSupportsAsync); + } + + [Test] + public void GetProvider_ThrowsForUnsupportedExtension() + { + Action act = () => PackageProviderFactory.GetProvider(".rpm", new InMemoryLog(), Substitute.For(), new CalamariVariables(), DeploymentContext()); + + act.Should().Throw().WithMessage("Unsupported archive type"); + } + + static RunningDeployment DeploymentContext() => new("", new CalamariVariables()); +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourIntegrationTestFixture.cs b/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourIntegrationTestFixture.cs deleted file mode 100644 index fb79e00d93..0000000000 --- a/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourIntegrationTestFixture.cs +++ /dev/null @@ -1,237 +0,0 @@ -using Calamari.AzureAppService.Behaviors; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Variables; -using Calamari.Testing.Helpers; -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Octopus.CoreUtilities.Extensions; - -namespace Calamari.AzureAppService.Tests; - -[TestFixture] -public class TargetDiscoveryBehaviourIntegrationTestFixture : AppServiceIntegrationTestWithStaticResources -{ - // https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourcegroups/calamari-testing-static-rg/providers/Microsoft.Web/sites/calamari-testing-static-target-discovery/appServices - const string WebAppName = "calamari-testing-static-target-discovery"; - readonly List slotNames = ["blue", "green"]; - - const string Type = "Azure"; - const string AuthenticationMethod = "ServicePrincipal"; - const string AccountId = "Accounts-1"; - const string TenantedDeploymentModeName = "TenantedOrUntenanted"; - - const string EnvironmentTagValue = "static-testing-env"; - const string WebAppRoleTagValue = "static-testing-web-app-role"; - const string WebAppSlotRoleTagValue = "static-testing-web-app-slot-role"; - - [Test] - public async Task Execute_WebAppWithMatchingTags_CreatesCorrectTargets() - { - // Arrange - var variables = new CalamariVariables(); - var context = new RunningDeployment(variables); - CreateVariables(context, WebAppRoleTagValue); - - var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); - - // Act - await sut.Execute(context); - - // Assert - var serviceMessageToCreateWebAppTarget = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppRoleTagValue, - null, - null, - TenantedDeploymentModeName); - var serviceMessageString = serviceMessageToCreateWebAppTarget.ToString(); - log.StandardOut.Should().Contain(serviceMessageString); - } - - [Test] - public async Task Execute_WebAppWithNonMatchingTags_CreatesNoTargets() - { - // Arrange - var variables = new CalamariVariables(); - var context = new RunningDeployment(variables); - - const string role = "a-different-role"; - - CreateVariables(context, role); - - var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); - - // Act - await sut.Execute(context); - - // Assert - var serviceMessageToCreateWebAppTarget = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - role, - null, - null, - TenantedDeploymentModeName); - - log.StandardOut.Should().NotContain(serviceMessageToCreateWebAppTarget.ToString(), "The web app target should not be created as the role tag did not match"); - } - - [Test] - public async Task Execute_MultipleWebAppSlotsWithTags_WebAppHasNoTags_CreatesCorrectTargets() - { - // Arrange - var variables = new CalamariVariables(); - var context = new RunningDeployment(variables); - - CreateVariables(context, null, WebAppSlotRoleTagValue); - - var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); - - // Act - await sut.Execute(context); - - var serviceMessageToCreateWebAppTarget = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppRoleTagValue, - null, - null, - null); - log.StandardOut.Should().NotContain(serviceMessageToCreateWebAppTarget.ToString(), "A target should not be created for the web app itself, only for slots within the web app"); - - // Assert - foreach (var slotName in slotNames) - { - var serviceMessageToCreateTargetForSlot = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppSlotRoleTagValue, - null, - slotName, - null); - log.StandardOut.Should().Contain(serviceMessageToCreateTargetForSlot.ToString()); - } - } - - [Test] - public async Task Execute_MultipleWebAppSlotsWithTags_WebAppWithTags_CreatesCorrectTargets() - { - // Arrange - var variables = new CalamariVariables(); - var context = new RunningDeployment(variables); - CreateVariables(context, WebAppRoleTagValue, WebAppSlotRoleTagValue); - var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); - - // Act - await sut.Execute(context); - - // Assert - var serviceMessageToCreateWebAppTarget = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppRoleTagValue, - null, - null, - TenantedDeploymentModeName); - log.StandardOut.Should().Contain(serviceMessageToCreateWebAppTarget.ToString(), "A target should be created for the web app itself as well as for the slots"); - - foreach (var slotName in slotNames) - { - var serviceMessageToCreateTargetForSlot = TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppSlotRoleTagValue, - null, - slotName, - null); - log.StandardOut.Should().Contain(serviceMessageToCreateTargetForSlot.ToString()); - } - } - - [Test] - public async Task Execute_MultipleWebAppSlotsWithPartialTags_WebAppWithPartialTags_CreatesNoTargets() - { - // Arrange - var variables = new CalamariVariables(); - var context = new RunningDeployment(variables); - - CreateVariables(context, null, WebAppSlotRoleTagValue); - - var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); - - const string partialMatchSlotName = "partial-match"; - - // Act - await sut.Execute(context); - - // Assert - var serviceMessageToCreateWebAppTarget = - TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppRoleTagValue, - null, - null, - TenantedDeploymentModeName); - log.StandardOut.Should() - .NotContain(serviceMessageToCreateWebAppTarget.ToString(), - "A target should not be created for the web app as the tags directly on the web app do not match, even though when combined with the slot tags they do"); - - var serviceMessageToCreateTargetForSlot = - TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage(ResourceGroupName, - WebAppName, - AccountId, - WebAppSlotRoleTagValue, - null, - partialMatchSlotName, - null); - log.StandardOut.Should() - .NotContain(serviceMessageToCreateTargetForSlot.ToString(), - "A target should not be created for the web app slot as the tags directly on the slot do not match, even though when combined with the web app tags they do"); - } - - void CreateVariables(RunningDeployment context, - string webAppRoleTagValue = null, - string slotRoleTagValue = null) - { - var tagValues = string.Join(',', new[] { webAppRoleTagValue, slotRoleTagValue }.WhereNotNull().Select(v => $"\"{v}\"")); - - var targetDiscoveryContext = $$""" - { - "scope": { - "spaceName": "default", - "environmentName": "{{EnvironmentTagValue}}", - "projectName": "my-test-project", - "tenantName": null, - "roles": [{{tagValues}}] - }, - "authentication": { - "type": "{{Type}}", - "accountId": "{{AccountId}}", - "authenticationMethod": "{{AuthenticationMethod}}", - "accountDetails": { - "subscriptionNumber": "{{SubscriptionId}}", - "clientId": "{{ClientId}}", - "tenantId": "{{TenantId}}", - "password": "{{ClientSecret}}", - "azureEnvironment": "", - "resourceManagementEndpointBaseUri": "", - "activeDirectoryEndpointBaseUri": "" - } - } - } - """; - - context.Variables.Add("Octopus.TargetDiscovery.Context", targetDiscoveryContext); - } -} \ No newline at end of file diff --git a/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourUnitTestFixture.cs b/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourUnitTestFixture.cs index ca7847332c..09e98bbbbe 100644 --- a/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourUnitTestFixture.cs +++ b/source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourUnitTestFixture.cs @@ -1,10 +1,16 @@ -using Calamari.AzureAppService.Behaviors; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Calamari.AzureAppService.Azure; +using Calamari.AzureAppService.Behaviors; +using Calamari.CloudAccounts; using Calamari.Common.Commands; +using Calamari.Common.Features.Discovery; using Calamari.Common.Plumbing.Variables; using Calamari.Testing.Helpers; using FluentAssertions; +using NSubstitute; using NUnit.Framework; -using System.Threading.Tasks; using Octopus.Calamari.Contracts.TargetDiscovery; namespace Calamari.AzureAppService.Tests; @@ -12,6 +18,15 @@ namespace Calamari.AzureAppService.Tests; [TestFixture] public class TargetDiscoveryBehaviourUnitTestFixture { + const string WebAppName = "calamari-testing-static-target-discovery"; + const string ResourceGroupName = "calamari-testing-static-rg"; + const string AccountId = "Accounts-1"; + const string TenantedDeploymentModeName = "TenantedOrUntenanted"; + const string EnvironmentTagValue = "static-testing-env"; + const string WebAppRoleTagValue = "static-testing-web-app-role"; + const string WebAppSlotRoleTagValue = "static-testing-web-app-slot-role"; + static readonly string[] SlotNames = { "blue", "green" }; + [Test] public async Task Execute_LogsError_WhenContextIsMissing() { @@ -19,7 +34,7 @@ public async Task Execute_LogsError_WhenContextIsMissing() var variables = new CalamariVariables(); var context = new RunningDeployment(variables); var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); + var sut = new TargetDiscoveryBehaviour(log, DiscovererReturning()); // Act await sut.Execute(context); @@ -37,7 +52,7 @@ public async Task Exectute_LogsError_WhenContextIsInIncorrectFormat() var context = new RunningDeployment(variables); context.Variables.Add("Octopus.TargetDiscovery.Context", @"{ authentication: { authenticationMethod: ""ServicePrincipal""}}"); var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); + var sut = new TargetDiscoveryBehaviour(log, DiscovererReturning()); // Act await sut.Execute(context); @@ -55,7 +70,7 @@ public async Task Exectute_LogsError_WhenContextIsInIncorrectFormat_Authenticati var context = new RunningDeployment(variables); context.Variables.Add(TargetDiscoverySpecialVariables.TargetDiscoveryContext, "bogus json"); var log = new InMemoryLog(); - var sut = new TargetDiscoveryBehaviour(log); + var sut = new TargetDiscoveryBehaviour(log, DiscovererReturning()); // Act await sut.Execute(context); @@ -64,4 +79,175 @@ public async Task Exectute_LogsError_WhenContextIsInIncorrectFormat_Authenticati log.StandardOut.Should().Contain(line => line.Contains("Could not read authentication method from target discovery context, Octopus.TargetDiscovery.Context is in wrong format, Unexpected character encountered while parsing value: b. Path '', line 0, position 0.")); log.StandardOut.Should().Contain(line => line.Contains("Aborting target discovery.")); } -} \ No newline at end of file + + [Test] + public async Task Execute_WebAppWithMatchingTags_CreatesCorrectTargets() + { + // Arrange + var context = ContextWithRoles(WebAppRoleTagValue); + var discoverer = DiscovererReturning( + WebApp(WebAppName, TagsFor(role: WebAppRoleTagValue, tenantedMode: TenantedDeploymentModeName))); + var log = new InMemoryLog(); + var sut = new TargetDiscoveryBehaviour(log, discoverer); + + // Act + await sut.Execute(context); + + // Assert + log.StandardOut.Should().Contain(WebAppTargetMessage(WebAppName, WebAppRoleTagValue, slotName: null, tenantedMode: TenantedDeploymentModeName)); + } + + [Test] + public async Task Execute_WebAppWithNonMatchingTags_CreatesNoTargets() + { + // Arrange + const string role = "a-different-role"; + var context = ContextWithRoles(role); + var discoverer = DiscovererReturning( + WebApp(WebAppName, TagsFor(role: WebAppRoleTagValue, tenantedMode: TenantedDeploymentModeName))); + var log = new InMemoryLog(); + var sut = new TargetDiscoveryBehaviour(log, discoverer); + + // Act + await sut.Execute(context); + + // Assert + log.StandardOut.Should().NotContain(WebAppTargetMessage(WebAppName, role, slotName: null, tenantedMode: TenantedDeploymentModeName), + "the web app target should not be created as the role tag did not match"); + } + + [Test] + public async Task Execute_MultipleWebAppSlotsWithTags_WebAppHasNoMatchingTags_CreatesTargetsForSlotsOnly() + { + // Arrange + var context = ContextWithRoles(WebAppSlotRoleTagValue); + var discoverer = DiscovererReturning( + new[] { WebApp(WebAppName, TagsFor()) } + .Concat(SlotNames.Select(s => Slot(s, TagsFor(role: WebAppSlotRoleTagValue)))) + .ToArray()); + var log = new InMemoryLog(); + var sut = new TargetDiscoveryBehaviour(log, discoverer); + + // Act + await sut.Execute(context); + + // Assert + log.StandardOut.Should().NotContain(WebAppTargetMessage(WebAppName, WebAppRoleTagValue, slotName: null, tenantedMode: null), + "a target should not be created for the web app itself, only for slots within the web app"); + foreach (var slotName in SlotNames) + { + log.StandardOut.Should().Contain(WebAppTargetMessage(WebAppName, WebAppSlotRoleTagValue, slotName, tenantedMode: null)); + } + } + + [Test] + public async Task Execute_MultipleWebAppSlotsWithTags_WebAppWithTags_CreatesTargetsForWebAppAndSlots() + { + // Arrange + var context = ContextWithRoles(WebAppRoleTagValue, WebAppSlotRoleTagValue); + var discoverer = DiscovererReturning( + new[] { WebApp(WebAppName, TagsFor(role: WebAppRoleTagValue, tenantedMode: TenantedDeploymentModeName)) } + .Concat(SlotNames.Select(s => Slot(s, TagsFor(role: WebAppSlotRoleTagValue)))) + .ToArray()); + var log = new InMemoryLog(); + var sut = new TargetDiscoveryBehaviour(log, discoverer); + + // Act + await sut.Execute(context); + + // Assert + log.StandardOut.Should().Contain(WebAppTargetMessage(WebAppName, WebAppRoleTagValue, slotName: null, tenantedMode: TenantedDeploymentModeName), + "a target should be created for the web app itself as well as for the slots"); + foreach (var slotName in SlotNames) + { + log.StandardOut.Should().Contain(WebAppTargetMessage(WebAppName, WebAppSlotRoleTagValue, slotName, tenantedMode: null)); + } + } + + [Test] + public async Task Execute_WebAppAndSlotWithPartialTags_CreatesNoTargets() + { + // Arrange + const string partialMatchSlotName = "partial-match"; + var context = ContextWithRoles(WebAppSlotRoleTagValue); + var discoverer = DiscovererReturning( + // Web app has the environment tag but not the role tag - does not match on its own. + WebApp(WebAppName, TagsFor()), + // Slot has the role tag but not the environment tag - does not match on its own. + Slot(partialMatchSlotName, TagsFor(role: WebAppSlotRoleTagValue, environment: null))); + var log = new InMemoryLog(); + var sut = new TargetDiscoveryBehaviour(log, discoverer); + + // Act + await sut.Execute(context); + + // Assert + log.StandardOut.Should().NotContain(WebAppTargetMessage(WebAppName, WebAppRoleTagValue, slotName: null, tenantedMode: TenantedDeploymentModeName), + "a target should not be created for the web app as the tags directly on the web app do not match"); + log.StandardOut.Should().NotContain(WebAppTargetMessage(WebAppName, WebAppSlotRoleTagValue, partialMatchSlotName, tenantedMode: null), + "a target should not be created for the slot as the tags directly on the slot do not match"); + } + + static IAzureWebAppDiscoverer DiscovererReturning(params AzureResource[] resources) + { + var discoverer = Substitute.For(); + discoverer.DiscoverWebAppsAndSlots(Arg.Any()).Returns(resources); + return discoverer; + } + + static AzureResource WebApp(string name, Dictionary tags) => + new() { Name = name, Type = "microsoft.web/sites", ResourceGroup = ResourceGroupName, Tags = tags }; + + static AzureResource Slot(string slotName, Dictionary tags) => + new() { Name = $"{WebAppName}/{slotName}", Type = "microsoft.web/sites/slots", ResourceGroup = ResourceGroupName, Tags = tags }; + + static Dictionary TagsFor(string role = null, string environment = EnvironmentTagValue, string tenantedMode = null) + { + var tags = new Dictionary(); + if (environment != null) tags[TargetTags.EnvironmentTagName] = environment; + if (role != null) tags[TargetTags.RoleTagName] = role; + if (tenantedMode != null) tags[TargetTags.TenantedDeploymentModeTagName] = tenantedMode; + return tags; + } + + static string WebAppTargetMessage(string webAppName, string role, string slotName, string tenantedMode) => + TargetDiscoveryHelpers.CreateWebAppTargetCreationServiceMessage( + ResourceGroupName, webAppName, AccountId, role, null, slotName, tenantedMode).ToString(); + + static RunningDeployment ContextWithRoles(params string[] roles) + { + var variables = new CalamariVariables(); + var context = new RunningDeployment(variables); + var rolesJson = string.Join(",", roles.Select(r => $"\"{r}\"")); + + // Credentials are dummy values - the discoverer that would use them is mocked in these tests. + var targetDiscoveryContext = $$""" + { + "scope": { + "spaceName": "default", + "environmentName": "{{EnvironmentTagValue}}", + "projectName": "my-test-project", + "tenantName": null, + "roles": [{{rolesJson}}] + }, + "authentication": { + "type": "Azure", + "accountId": "{{AccountId}}", + "authenticationMethod": "ServicePrincipal", + "accountDetails": { + "subscriptionNumber": "subscription-id", + "clientId": "client-id", + "tenantId": "tenant-id", + "password": "client-secret", + "azureEnvironment": "", + "resourceManagementEndpointBaseUri": "", + "activeDirectoryEndpointBaseUri": "" + } + } + } + """; + + context.Variables.Add("Octopus.TargetDiscovery.Context", targetDiscoveryContext); + return context; + } +} diff --git a/source/Calamari.AzureAppService.Tests/sample.1.0.0.war b/source/Calamari.AzureAppService.Tests/sample.1.0.0.war deleted file mode 100644 index d192f34443..0000000000 Binary files a/source/Calamari.AzureAppService.Tests/sample.1.0.0.war and /dev/null differ diff --git a/source/Calamari.AzureAppService.Tests/sample4-1.0.0.jar b/source/Calamari.AzureAppService.Tests/sample4-1.0.0.jar deleted file mode 100644 index ad086a679b..0000000000 Binary files a/source/Calamari.AzureAppService.Tests/sample4-1.0.0.jar and /dev/null differ diff --git a/source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs b/source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs new file mode 100644 index 0000000000..e53757c165 --- /dev/null +++ b/source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs @@ -0,0 +1,33 @@ +using System; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AzureAppService +{ + /// + /// Selects the for a package based on its file extension. This mapping + /// (extension to packaging strategy, Kudu upload endpoint and sync/async deployment) is the only + /// Calamari-owned decision in the otherwise Azure-bound deployment, so it is unit tested in + /// PackageProviderFactoryFixture without a real Azure connection. + /// + public static class PackageProviderFactory + { + public static IPackageProvider GetProvider(string fileExtension, + ILog log, + ICalamariFileSystem fileSystem, + IVariables variables, + RunningDeployment deployment) + { + return fileExtension switch + { + ".zip" => new ZipPackageProvider(), + ".nupkg" => new NugetPackageProvider(), + ".war" => new JavaPackageProvider(log, fileSystem, variables, deployment, "/api/wardeploy"), + ".jar" => new JavaPackageProvider(log, fileSystem, variables, deployment, "/api/publish?type=jar"), + _ => throw new Exception("Unsupported archive type") + }; + } + } +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs b/source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs new file mode 100644 index 0000000000..e2d65ecd39 --- /dev/null +++ b/source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs @@ -0,0 +1,61 @@ +using System.Threading.Tasks; +using Azure.ResourceManager.AppService; +using Azure.ResourceManager.AppService.Models; +using Calamari.Azure; +using Calamari.Azure.AppServices; +using Calamari.CloudAccounts; + +namespace Calamari.AzureAppService.Azure +{ + /// + /// Wraps every Azure call that container deployment makes (OS detection, reading/writing site config + /// and app settings). This is the single point at which + /// talks to Azure; mocking it lets the image/registry/OS-branch logic be tested without a real Azure connection. + /// + public interface IAzureAppServiceContainerConfigurer + { + Task IsLinuxWebApp(IAzureAccount account, AzureTargetSite targetSite); + Task GetSiteConfig(IAzureAccount account, AzureTargetSite targetSite); + Task GetAppSettings(IAzureAccount account, AzureTargetSite targetSite); + Task UpdateAppSettings(IAzureAccount account, AzureTargetSite targetSite, AppServiceConfigurationDictionary appSettings); + Task UpdateSiteConfig(IAzureAccount account, AzureTargetSite targetSite, SiteConfigData config); + } + + public class AzureAppServiceContainerConfigurer : IAzureAppServiceContainerConfigurer + { + public async Task IsLinuxWebApp(IAzureAccount account, AzureTargetSite targetSite) + { + var armClient = account.CreateArmClient(); + var webSiteData = targetSite.HasSlot switch + { + true => (await armClient.GetWebSiteSlotResource(WebSiteSlotResource.CreateResourceIdentifier( + targetSite.SubscriptionId, + targetSite.ResourceGroupName, + targetSite.Site, + targetSite.Slot)) + .GetAsync()).Value.Data, + false => (await armClient.GetWebSiteResource(WebSiteResource.CreateResourceIdentifier( + targetSite.SubscriptionId, + targetSite.ResourceGroupName, + targetSite.Site)) + .GetAsync()).Value.Data + }; + + //If the app service is a linux, it will contain linux in the kind string + //possible values are found here: https://github.com/Azure/app-service-linux-docs/blob/master/Things_You_Should_Know/kind_property.md + return webSiteData.Kind.ToLowerInvariant().Contains("linux"); + } + + public Task GetSiteConfig(IAzureAccount account, AzureTargetSite targetSite) + => account.CreateArmClient().GetSiteConfigDataAsync(targetSite); + + public Task GetAppSettings(IAzureAccount account, AzureTargetSite targetSite) + => account.CreateArmClient().GetAppSettingsAsync(targetSite); + + public Task UpdateAppSettings(IAzureAccount account, AzureTargetSite targetSite, AppServiceConfigurationDictionary appSettings) + => account.CreateArmClient().UpdateAppSettingsAsync(targetSite, appSettings); + + public Task UpdateSiteConfig(IAzureAccount account, AzureTargetSite targetSite, SiteConfigData config) + => account.CreateArmClient().UpdateSiteConfigDataAsync(targetSite, config); + } +} \ No newline at end of file diff --git a/source/Calamari.AzureAppService/Azure/IAzureWebAppDiscoverer.cs b/source/Calamari.AzureAppService/Azure/IAzureWebAppDiscoverer.cs new file mode 100644 index 0000000000..c3ce388646 --- /dev/null +++ b/source/Calamari.AzureAppService/Azure/IAzureWebAppDiscoverer.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using Calamari.Azure; +using Calamari.AzureAppService.Behaviors; +using Calamari.CloudAccounts; + +namespace Calamari.AzureAppService.Azure +{ + /// + /// Enumerates the Azure web apps and slots visible to an account. This is the single point at + /// which target discovery talks to Azure; mocking it lets the tag-matching and target-creation + /// logic in be tested without a real Azure connection. + /// + public interface IAzureWebAppDiscoverer + { + Task DiscoverWebAppsAndSlots(IAzureAccount account); + } + + public class AzureWebAppDiscoverer : IAzureWebAppDiscoverer + { + // These values are well-known resource types in Azure's API. + // The format is {resource-provider}/{resource-type} + // WebAppType refers to Azure Web Apps, Azure Functions Apps and Azure App Services + // while WebAppSlotsType refers to Slots of any of the above resources. + // More info about Azure Resource Providers and Types here: + // https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types + const string WebAppSlotsType = "microsoft.web/sites/slots"; + const string WebAppType = "microsoft.web/sites"; + + public Task DiscoverWebAppsAndSlots(IAzureAccount account) + { + var armClient = account.CreateArmClient(retryOptions => + { + retryOptions.MaxDelay = TimeSpan.FromSeconds(10); + retryOptions.MaxRetries = 5; + }); + + return armClient.GetResourcesByType(WebAppType, WebAppSlotsType); + } + } +} diff --git a/source/Calamari.AzureAppService/Behaviors/AppDeployBehaviour.cs b/source/Calamari.AzureAppService/Behaviors/AppDeployBehaviour.cs index 7b054e3f60..ec5ea78927 100644 --- a/source/Calamari.AzureAppService/Behaviors/AppDeployBehaviour.cs +++ b/source/Calamari.AzureAppService/Behaviors/AppDeployBehaviour.cs @@ -15,10 +15,10 @@ public class AppDeployBehaviour : IDeployBehaviour ILog Log { get; } - public AppDeployBehaviour(ILog log, ICalamariFileSystem fileSystem) + public AppDeployBehaviour(ILog log, ICalamariFileSystem fileSystem, IAzureAppServiceContainerConfigurer containerConfigurer) { Log = log; - containerBehaviour = new AzureAppServiceContainerDeployBehaviour(log); + containerBehaviour = new AzureAppServiceContainerDeployBehaviour(log, containerConfigurer); zipDeployBehaviour = new AzureAppServiceZipDeployBehaviour(log, fileSystem); } diff --git a/source/Calamari.AzureAppService/Behaviors/AzureAppServiceContainerDeployBehaviour.cs b/source/Calamari.AzureAppService/Behaviors/AzureAppServiceContainerDeployBehaviour.cs index b8fa672353..d33a4b33a3 100644 --- a/source/Calamari.AzureAppService/Behaviors/AzureAppServiceContainerDeployBehaviour.cs +++ b/source/Calamari.AzureAppService/Behaviors/AzureAppServiceContainerDeployBehaviour.cs @@ -1,8 +1,4 @@ -using System; using System.Threading.Tasks; -using Azure.ResourceManager; -using Azure.ResourceManager.AppService; -using Calamari.Azure; using Calamari.Azure.AppServices; using Calamari.AzureAppService.Azure; using Calamari.CloudAccounts; @@ -17,10 +13,12 @@ namespace Calamari.AzureAppService.Behaviors class AzureAppServiceContainerDeployBehaviour : IDeployBehaviour { private ILog Log { get; } + private readonly IAzureAppServiceContainerConfigurer configurer; - public AzureAppServiceContainerDeployBehaviour(ILog log) + public AzureAppServiceContainerDeployBehaviour(ILog log, IAzureAppServiceContainerConfigurer configurer) { Log = log; + this.configurer = configurer; } public bool IsEnabled(RunningDeployment context) => true; @@ -43,15 +41,13 @@ public async Task Execute(RunningDeployment context) var regUsername = variables.Get(SpecialVariables.Action.Package.Feed.Username); var regPwd = variables.Get(SpecialVariables.Action.Package.Feed.Password); - var armClient = account.CreateArmClient(); - Log.Info($"Updating web app to use image {image} from registry {registryHost}"); Log.Verbose("Retrieving app service to determine operating system"); - var isLinuxWebApp = await IsLinuxWebApp(armClient, targetSite); + var isLinuxWebApp = await configurer.IsLinuxWebApp(account, targetSite); Log.Verbose("Retrieving config (this is required to update image)"); - var config = await armClient.GetSiteConfigDataAsync(targetSite); + var config = await configurer.GetSiteConfig(account, targetSite); var newVersion = $"DOCKER|{image}"; if (isLinuxWebApp) @@ -66,39 +62,17 @@ public async Task Execute(RunningDeployment context) } Log.Verbose("Retrieving app settings"); - var appSettings = await armClient.GetAppSettingsAsync(targetSite); + var appSettings = await configurer.GetAppSettings(account, targetSite); appSettings.Properties["DOCKER_REGISTRY_SERVER_URL"] = "https://" + registryHost; appSettings.Properties["DOCKER_REGISTRY_SERVER_USERNAME"] = regUsername; appSettings.Properties["DOCKER_REGISTRY_SERVER_PASSWORD"] = regPwd; Log.Info("Updating app settings with container registry"); - await armClient.UpdateAppSettingsAsync(targetSite, appSettings); + await configurer.UpdateAppSettings(account, targetSite, appSettings); Log.Info("Updating configuration with container image"); - await armClient.UpdateSiteConfigDataAsync(targetSite, config); - } - - static async Task IsLinuxWebApp(ArmClient armClient, AzureTargetSite targetSite) - { - var webSiteData = targetSite.HasSlot switch - { - true => (await armClient.GetWebSiteSlotResource(WebSiteSlotResource.CreateResourceIdentifier( - targetSite.SubscriptionId, - targetSite.ResourceGroupName, - targetSite.Site, - targetSite.Slot)) - .GetAsync()).Value.Data, - false => (await armClient.GetWebSiteResource(WebSiteResource.CreateResourceIdentifier( - targetSite.SubscriptionId, - targetSite.ResourceGroupName, - targetSite.Site)) - .GetAsync()).Value.Data - }; - - //If the app service is a linux, it will contain linux in the kind string - //possible values are found here: https://github.com/Azure/app-service-linux-docs/blob/master/Things_You_Should_Know/kind_property.md - return webSiteData.Kind.ToLowerInvariant().Contains("linux"); + await configurer.UpdateSiteConfig(account, targetSite, config); } } } \ No newline at end of file diff --git a/source/Calamari.AzureAppService/Behaviors/AzureAppServiceZipDeployBehaviour.cs b/source/Calamari.AzureAppService/Behaviors/AzureAppServiceZipDeployBehaviour.cs index 865f90b66f..d2493d6d3f 100644 --- a/source/Calamari.AzureAppService/Behaviors/AzureAppServiceZipDeployBehaviour.cs +++ b/source/Calamari.AzureAppService/Behaviors/AzureAppServiceZipDeployBehaviour.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using System.Collections.Generic; @@ -95,14 +95,7 @@ public async Task Execute(RunningDeployment context) var packageFileInfo = new FileInfo(variables.Get(TentacleVariables.CurrentDeployment.PackageFilePath)!); - IPackageProvider packageProvider = packageFileInfo.Extension switch - { - ".zip" => new ZipPackageProvider(), - ".nupkg" => new NugetPackageProvider(), - ".war" => new JavaPackageProvider(Log, fileSystem, variables, context, "/api/wardeploy"), - ".jar" => new JavaPackageProvider(Log, fileSystem, variables, context, "/api/publish?type=jar"), - _ => throw new Exception("Unsupported archive type") - }; + var packageProvider = PackageProviderFactory.GetProvider(packageFileInfo.Extension, Log, fileSystem, variables, context); // Let's process our archive while the slot is spun up. We will await it later before we try to upload to it. Task? slotCreateTask = null; diff --git a/source/Calamari.AzureAppService/Behaviors/TargetDiscoveryBehaviour.cs b/source/Calamari.AzureAppService/Behaviors/TargetDiscoveryBehaviour.cs index 0aaeeb3675..19b2cde741 100644 --- a/source/Calamari.AzureAppService/Behaviors/TargetDiscoveryBehaviour.cs +++ b/source/Calamari.AzureAppService/Behaviors/TargetDiscoveryBehaviour.cs @@ -22,20 +22,13 @@ namespace Calamari.AzureAppService.Behaviors { public class TargetDiscoveryBehaviour : IDeployBehaviour { - // These values are well-known resource types in Azure's API. - // The format is {resource-provider}/{resource-type} - // WebAppType refers to Azure Web Apps, Azure Functions Apps and Azure App Services - // while WebAppSlotsType refers to Slots of any of the above resources. - // More info about Azure Resource Providers and Types here: - // https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types - private const string WebAppSlotsType = "microsoft.web/sites/slots"; - private const string WebAppType = "microsoft.web/sites"; - private ILog Log { get; } + private readonly IAzureWebAppDiscoverer discoverer; - public TargetDiscoveryBehaviour(ILog log) + public TargetDiscoveryBehaviour(ILog log, IAzureWebAppDiscoverer discoverer) { Log = log; + this.discoverer = discoverer; } public bool IsEnabled(RunningDeployment context) => true; @@ -54,7 +47,7 @@ public async Task Execute(RunningDeployment runningDeployment) if (!TryGetAuthenticationMethod(json!, contextVariableName, out string? authenticationMethod)) return; - TargetDiscoveryContext>? targetDiscoveryContext = authenticationMethod == "AzureOidc" + var targetDiscoveryContext = authenticationMethod == "AzureOidc" ? GetTargetDiscoveryContext(json!) : GetTargetDiscoveryContext(json!); @@ -70,14 +63,9 @@ public async Task Execute(RunningDeployment runningDeployment) Log.Verbose($" Subscription ID: {account.SubscriptionNumber}"); Log.Verbose($" Tenant ID: {account.TenantId}"); Log.Verbose($" Client ID: {account.ClientId}"); - var armClient = account.CreateArmClient(retryOptions => - { - retryOptions.MaxDelay = TimeSpan.FromSeconds(10); - retryOptions.MaxRetries = 5; - }); try { - var resources = await armClient.GetResourcesByType(WebAppType, WebAppSlotsType); + var resources = await discoverer.DiscoverWebAppsAndSlots(account); var discoveredTargetCount = 0; Log.Verbose($"Found {resources.Length} candidate web app resources."); foreach (var resource in resources) diff --git a/source/Calamari.AzureAppService/Program.cs b/source/Calamari.AzureAppService/Program.cs index cf9432c19a..3045dcdbff 100644 --- a/source/Calamari.AzureAppService/Program.cs +++ b/source/Calamari.AzureAppService/Program.cs @@ -1,9 +1,9 @@ using System; -using System.Collections.Generic; -using System.Net; -using System.Text; using System.Threading.Tasks; +using Autofac; +using Calamari.AzureAppService.Azure; using Calamari.Common; +using Calamari.Common.Plumbing.Commands; using Calamari.Common.Plumbing.Logging; @@ -15,6 +15,14 @@ public Program(ILog log) : base(log) { } + protected override void ConfigureContainer(ContainerBuilder builder, CommonOptions options) + { + base.ConfigureContainer(builder, options); + + builder.RegisterType().As(); + builder.RegisterType().As(); + } + public static Task Main(string[] args) { return new Program(ConsoleLog.Instance).Run(args); diff --git a/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs b/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs index 4400d992cc..daa694ca76 100644 --- a/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs +++ b/source/Calamari.AzureResourceGroup.Tests/AzureResourceGroupActionHandlerFixture.cs @@ -23,6 +23,7 @@ namespace Calamari.AzureResourceGroup.Tests { [TestFixture] + [Category(TestCategory.ExternalCloudIntegration)] class AzureResourceGroupActionHandlerFixture { string clientId; diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs index 6ce8922365..4071933659 100644 --- a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs +++ b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs @@ -19,6 +19,7 @@ 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. + [Category(TestCategory.ExternalCloudIntegration)] class DeployAzureBicepTemplateCommandFixture { string clientId; diff --git a/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs b/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs index 9f69dfe9fc..a0345b5a58 100644 --- a/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs +++ b/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs @@ -6,11 +6,13 @@ using Calamari.Scripting; using NUnit.Framework; using Calamari.Testing; +using Calamari.Testing.Helpers; using Calamari.Testing.Requirements; namespace Calamari.AzureScripting.Tests { [TestFixture] + [Category(TestCategory.ExternalCloudIntegration)] class AzurePowerShellCommandFixture { string? clientId; diff --git a/source/Calamari.AzureServiceFabric.Tests/DeployAzureServiceFabricAppCommandFixture.cs b/source/Calamari.AzureServiceFabric.Tests/DeployAzureServiceFabricAppCommandFixture.cs index 1d51b93523..760a3c6150 100644 --- a/source/Calamari.AzureServiceFabric.Tests/DeployAzureServiceFabricAppCommandFixture.cs +++ b/source/Calamari.AzureServiceFabric.Tests/DeployAzureServiceFabricAppCommandFixture.cs @@ -9,6 +9,7 @@ using Calamari.Common.Plumbing.Variables; using Calamari.Testing; using Calamari.Testing.LogParser; +using Calamari.Testing.Helpers; using Calamari.Testing.Requirements; using FluentAssertions; using NUnit.Framework; @@ -17,6 +18,7 @@ namespace Calamari.AzureServiceFabric.Tests { [TestFixture] [WindowsTest] + [Category(TestCategory.ExternalCloudIntegration)] public class DeployAzureServiceFabricAppCommandFixture { string clientCertThumbprint; diff --git a/source/Calamari.AzureServiceFabric.Tests/HealthCheckCommandFixture.cs b/source/Calamari.AzureServiceFabric.Tests/HealthCheckCommandFixture.cs index 057d2915bf..6c70b946b2 100644 --- a/source/Calamari.AzureServiceFabric.Tests/HealthCheckCommandFixture.cs +++ b/source/Calamari.AzureServiceFabric.Tests/HealthCheckCommandFixture.cs @@ -7,6 +7,7 @@ using Calamari.Common.Plumbing.Variables; using Calamari.Testing; using Calamari.Testing.LogParser; +using Calamari.Testing.Helpers; using Calamari.Testing.Requirements; using FluentAssertions; using NUnit.Framework; @@ -15,6 +16,7 @@ namespace Calamari.AzureServiceFabric.Tests { [TestFixture] [WindowsTest] + [Category(TestCategory.ExternalCloudIntegration)] public class HealthCheckCommandFixture { string clientCertThumbprint; diff --git a/source/Calamari.AzureWebApp.Tests/DeployAzureWebCommandFixture.cs b/source/Calamari.AzureWebApp.Tests/DeployAzureWebCommandFixture.cs index 661a4a8b61..0f35c75c50 100644 --- a/source/Calamari.AzureWebApp.Tests/DeployAzureWebCommandFixture.cs +++ b/source/Calamari.AzureWebApp.Tests/DeployAzureWebCommandFixture.cs @@ -28,6 +28,7 @@ namespace Calamari.AzureWebApp.Tests { [TestFixture] + [Category(TestCategory.ExternalCloudIntegration)] public class DeployAzureWebCommandFixture { int webAppCount = 0; diff --git a/source/Calamari.Testing/Helpers/TestCategory.cs b/source/Calamari.Testing/Helpers/TestCategory.cs index 7a24d51fe1..e2ff7492bd 100644 --- a/source/Calamari.Testing/Helpers/TestCategory.cs +++ b/source/Calamari.Testing/Helpers/TestCategory.cs @@ -22,6 +22,13 @@ public static class CompatibleOS public const string PlatformAgnostic = "PlatformAgnostic"; + /// + /// Tests that authenticate against or call a real external cloud service (e.g. real Azure). + /// These require credentials and provisioned resources, so they run as a separate smoke suite. + /// Tests without this category are expected to be unit/integration tests that need no external service. + /// + public const string ExternalCloudIntegration = "ExternalCloudIntegration"; + public const string RunOnceOnWindowsAndLinux = "RunOnceOnWindowsAndLinux"; public const string RequiresOpenSsl1_1OrOpenSsl3 = "RequiresOpenSsl1_1OrOpenSsl3";