From 596706b06c84e38c879bddbbefb809e07d6b321d Mon Sep 17 00:00:00 2001 From: robert Date: Sun, 21 Jun 2026 08:35:19 +1000 Subject: [PATCH 01/12] Categorise Azure tests that hit real cloud services Introduce a single ExternalCloudIntegration test category and apply it to every Azure test fixture that authenticates against or calls a real Azure service. Tests without the category are treated as unit/integration tests that need no external service, so CI can run them with no credentials and run the real-cloud fixtures as a separate smoke suite. The two AppService integration base classes are tagged so all derived fixtures inherit the category (NUnit categories are inherited). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppServiceIntegrationTest.cs | 3 +++ .../AppServiceIntegrationTestWithStaticResources.cs | 3 +++ .../AzureResourceGroupActionHandlerFixture.cs | 1 + .../DeployAzureBicepTemplateCommandFixture.cs | 1 + .../AzurePowershellCommandFixture.cs | 2 ++ .../DeployAzureServiceFabricAppCommandFixture.cs | 2 ++ .../HealthCheckCommandFixture.cs | 2 ++ .../DeployAzureWebCommandFixture.cs | 1 + source/Calamari.Testing/Helpers/TestCategory.cs | 7 +++++++ 9 files changed, 22 insertions(+) diff --git a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs index 08dfb8a3b8..65c867714e 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs +++ b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs @@ -20,12 +20,15 @@ using FluentAssertions; using JetBrains.TeamCity.ServiceMessages.Write.Special.Impl.Writer; using Newtonsoft.Json; +using Calamari.Testing.Helpers; using NUnit.Framework; using Octostache; using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; namespace Calamari.AzureAppService.Tests { + // Creates and deploys to real Azure resources. Derived fixtures inherit this category (NUnit categories are inherited). + [Category(TestCategory.ExternalCloudIntegration)] public abstract class AppServiceIntegrationTest { protected string ClientId { get; private set; } diff --git a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs index 527ebf9cc3..65a9c1e565 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs +++ b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs @@ -9,12 +9,15 @@ 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; +// Queries real Azure resources. Derived fixtures inherit this category (NUnit categories are inherited). +[Category(TestCategory.ExternalCloudIntegration)] public abstract class AppServiceIntegrationTestWithStaticResources { //https://portal.azure.com/#@octopusdeploy.onmicrosoft.com/resource/subscriptions/cf21dc34-73dc-4d7d-bd86-041884e0bc75/resourceGroups/calamari-testing-static-rg/overview 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"; From 8772b6087844b9d69669f1a35257d6952e838658 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 17:16:42 +1000 Subject: [PATCH 02/12] Extract IAzureWebAppDiscoverer seam and unit-test target discovery Target discovery only touched Azure through a single Resource Graph query (GetResourcesByType). The surrounding tag-matching, slot and service-message logic is pure Calamari code, yet it was only covered by TargetDiscoveryBehaviourIntegrationTestFixture, which authenticated against and queried the real static resource group. Introduce IAzureWebAppDiscoverer over that one call and inject it into TargetDiscoveryBehaviour (registered in Program.cs). With the Azure call mockable, the five tag-matching scenarios move into the credential-free TargetDiscoveryBehaviourUnitTestFixture, and the live-Azure integration fixture is removed. Its remaining real-cloud value (that the Resource Graph query and auth still work) is replaced by a thin smoke test in a following commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...iscoveryBehaviourIntegrationTestFixture.cs | 237 ------------------ ...TargetDiscoveryBehaviourUnitTestFixture.cs | 198 ++++++++++++++- .../Azure/IAzureWebAppDiscoverer.cs | 41 +++ .../Behaviors/TargetDiscoveryBehaviour.cs | 22 +- source/Calamari.AzureAppService/Program.cs | 13 +- 5 files changed, 248 insertions(+), 263 deletions(-) delete mode 100644 source/Calamari.AzureAppService.Tests/TargetDiscoveryBehaviourIntegrationTestFixture.cs create mode 100644 source/Calamari.AzureAppService/Azure/IAzureWebAppDiscoverer.cs 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/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/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..fb01208020 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,13 @@ public Program(ILog log) : base(log) { } + protected override void ConfigureContainer(ContainerBuilder builder, CommonOptions options) + { + base.ConfigureContainer(builder, options); + + builder.RegisterType().As(); + } + public static Task Main(string[] args) { return new Program(ConsoleLog.Instance).Run(args); From 71e5e711dff995d883ce293a2624e5886c0917aa Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 20:02:03 +1000 Subject: [PATCH 03/12] Consolidate App Service cloud test bases and group under ExternalCloudIntegration The real-cloud fixtures previously derived from two base classes (AppServiceIntegrationTest and AppServiceIntegrationTestWithStaticResources) that each independently authenticated and built an ArmClient. Replace them with a 3-level hierarchy whose only genuine difference is resource lifecycle: AzureAppServiceTestBase - authenticates + ArmClient, once |- AzureAppServiceWithProvisionedResourcesTestBase - creates/destroys its own RG |- AzureAppServiceWithStaticResourcesTestBase - reuses calamari-testing-static-rg NUnit runs the base [OneTimeSetUp] before the derived one, so the base authenticates and the child provisions or looks up its resources. Every fixture that talks to real Azure moves into an ExternalCloudIntegration/ folder so the credential-free vs live-Azure split is visible in the tree, and functionapp.1.0.0.zip moves with the only fixture that uses it (a csproj keeps it copying to the output root). The target-discovery smoke test that replaces the integration fixture removed in the previous commit is added here, now that the static-resource base exists. This commit is behaviour-preserving: fixtures are relocated and re-pointed at the new bases, but no test logic is removed - those changes follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rviceIntegrationTestWithStaticResources.cs | 89 ------------------ .../Calamari.AzureAppService.Tests.csproj | 3 +- .../AppServiceBehaviourFixture.cs | 6 +- .../AppServiceSettingsBehaviourFixture.cs | 4 +- ...zureAppHealthCheckActionHandlerFixture.cs} | 8 +- ...pServiceDeployContainerBehaviourFixture.cs | 6 +- .../AzureAppServiceTestBase.cs | 81 ++++++++++++++++ ...erviceWithProvisionedResourcesTestBase.cs} | 76 ++------------- ...reAppServiceWithStaticResourcesTestBase.cs | 30 ++++++ ...BehaviourWithStaticResourcesTestFixture.cs | 30 ++++++ .../functionapp.1.0.0.zip | Bin 11 files changed, 162 insertions(+), 171 deletions(-) delete mode 100644 source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs rename source/Calamari.AzureAppService.Tests/{ => ExternalCloudIntegration}/AppServiceBehaviourFixture.cs (99%) rename source/Calamari.AzureAppService.Tests/{ => ExternalCloudIntegration}/AppServiceSettingsBehaviourFixture.cs (98%) rename source/Calamari.AzureAppService.Tests/{AzureWebAppHealthCheckActionHandlerFixture.cs => ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs} (95%) rename source/Calamari.AzureAppService.Tests/{ => ExternalCloudIntegration}/AzureAppServiceDeployContainerBehaviourFixture.cs (98%) create mode 100644 source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs rename source/Calamari.AzureAppService.Tests/{AppServiceIntegrationTest.cs => ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs} (63%) create mode 100644 source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithStaticResourcesTestBase.cs create mode 100644 source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/TargetDiscoveryBehaviourWithStaticResourcesTestFixture.cs rename source/Calamari.AzureAppService.Tests/{ => ExternalCloudIntegration}/functionapp.1.0.0.zip (100%) diff --git a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs b/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs deleted file mode 100644 index 65a9c1e565..0000000000 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTestWithStaticResources.cs +++ /dev/null @@ -1,89 +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 Calamari.Testing.Helpers; -using NUnit.Framework; -using Octostache; -using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; - -namespace Calamari.AzureAppService.Tests; - -// Queries real Azure resources. Derived fixtures inherit this category (NUnit categories are inherited). -[Category(TestCategory.ExternalCloudIntegration)] -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/Calamari.AzureAppService.Tests.csproj b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj index 7ef8b53374..e3d253ba5c 100644 --- a/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj +++ b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj @@ -34,7 +34,8 @@ 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 99% rename from source/Calamari.AzureAppService.Tests/AppServiceBehaviourFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index 6c450360af..3f30b205ae 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs @@ -26,12 +26,12 @@ using NUnit.Framework; using FileShare = System.IO.FileShare; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { public class AppServiceBehaviorFixture { [TestFixture] - public class WhenUsingAWindowsDotNetAppService : AppServiceIntegrationTest + public class WhenUsingAWindowsDotNetAppService : AzureAppServiceWithProvisionedResourcesTestBase { private AppServicePlanResource appServicePlanResource; @@ -395,7 +395,7 @@ private void AddVariables(CommandTestBuilderContext context) } [TestFixture] - public class WhenUsingALinuxAppService : AppServiceIntegrationTest + public class WhenUsingALinuxAppService : AzureAppServiceWithProvisionedResourcesTestBase { // For some reason we are having issues creating these linux resources on Standard in EastUS protected override string DefaultResourceGroupLocation => RandomAzureRegion.GetRandomRegionWithExclusions("eastus"); 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 98% rename from source/Calamari.AzureAppService.Tests/AzureAppServiceDeployContainerBehaviourFixture.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs index de16da63fd..e5cd7c2ffe 100644 --- a/source/Calamari.AzureAppService.Tests/AzureAppServiceDeployContainerBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs @@ -21,7 +21,7 @@ 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 @@ -32,7 +32,7 @@ namespace Calamari.AzureAppService.Tests public class AzureAppServiceDeployContainerBehaviourFixture { [TestFixture] - public class WhenUsingAWindowsAppService : AppServiceIntegrationTest + public class WhenUsingAWindowsAppService : AzureAppServiceWithProvisionedResourcesTestBase { CalamariVariables newVariables; readonly HttpClient client = new HttpClient(); @@ -175,7 +175,7 @@ void AddVariables(VariableDictionary vars) } [TestFixture] - public class WhenUsingALinuxAppService : AppServiceIntegrationTest + public class WhenUsingALinuxAppService : AzureAppServiceWithProvisionedResourcesTestBase { CalamariVariables newVariables; readonly HttpClient client = new HttpClient(); diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs new file mode 100644 index 0000000000..6328ca995d --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceTestBase.cs @@ -0,0 +1,81 @@ +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 +{ + // Authenticates and builds the ArmClient in a OneTimeSetUp that runs before any derived one, so subclasses + // can provision or look up resources against it. Carries the ExternalCloudIntegration category for descendants. + [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 63% rename from source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs rename to source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs index 65c867714e..ea24a2c366 100644 --- a/source/Calamari.AzureAppService.Tests/AppServiceIntegrationTest.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceWithProvisionedResourcesTestBase.cs @@ -1,97 +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 Calamari.Testing.Helpers; using NUnit.Framework; using Octostache; -using AccountVariables = Calamari.AzureAppService.Azure.AccountVariables; -namespace Calamari.AzureAppService.Tests +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { - // Creates and deploys to real Azure resources. Derived fixtures inherit this category (NUnit categories are inherited). - [Category(TestCategory.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, @@ -163,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..feabb310ce --- /dev/null +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/TargetDiscoveryBehaviourWithStaticResourcesTestFixture.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +using Calamari.AzureAppService.Azure; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration; + +// Smoke test: the real Azure Resource Graph query and auth still work. +// The tag-matching logic is unit-tested in TargetDiscoveryBehaviourUnitTestFixture. +[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 From dfd053d4418d38f36ac13576e0426dd3f90d4f6b Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 20:48:53 +1000 Subject: [PATCH 04/12] Extract IAzureAppServiceContainerConfigurer and unit-test container deploy Container deployment made several Azure calls inline: detecting the app service OS, then reading and writing site config and app settings. The only Calamari-owned logic around them is the Windows/Linux FxVersion branch, the registry/image app settings, and slot-vs-site targeting. Introduce IAzureAppServiceContainerConfigurer over those calls and inject it into AzureAppServiceContainerDeployBehaviour (threaded through AppDeployBehaviour and registered in Program.cs). With Azure mockable, the OS branch and targeting logic move into the credential-free AzureAppServiceContainerDeployBehaviourUnitTestFixture, covering both Windows and Linux. That makes the separate Windows cloud fixture redundant - the "Windows vs Linux" cloud split was only ever the OS branch - so it is removed. The remaining cloud fixture is a single Linux smoke test proving auth and the slot-vs-site ARM round-trip still work. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ContainerDeployBehaviourUnitTestFixture.cs | 131 ++++++++++++++ ...pServiceDeployContainerBehaviourFixture.cs | 160 +----------------- .../IAzureAppServiceContainerConfigurer.cs | 57 +++++++ .../Behaviors/AppDeployBehaviour.cs | 4 +- ...AzureAppServiceContainerDeployBehaviour.cs | 42 +---- source/Calamari.AzureAppService/Program.cs | 1 + 6 files changed, 205 insertions(+), 190 deletions(-) create mode 100644 source/Calamari.AzureAppService.Tests/AzureAppServiceContainerDeployBehaviourUnitTestFixture.cs create mode 100644 source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs 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/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppServiceDeployContainerBehaviourFixture.cs index e5cd7c2ffe..7772b0edc5 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/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; @@ -23,159 +22,12 @@ namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { - /// - /// Tests that both windows and linux app services can have container deployments - /// - /// - /// Both test fixtures have the same two tests, but they have different setups, so it's just easier to have separate test fixtures. - /// + // Smoke test: a container deploy round-trips against a real Linux app service (web app + slot). + // The OS branch and targeting logic are unit-tested in AzureAppServiceContainerDeployBehaviourUnitTestFixture. public class AzureAppServiceDeployContainerBehaviourFixture { [TestFixture] - public class WhenUsingAWindowsAppService : AzureAppServiceWithProvisionedResourcesTestBase - { - 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 : AzureAppServiceWithProvisionedResourcesTestBase + public class WhenUsingALinuxAzureAppService : AzureAppServiceWithProvisionedResourcesTestBase { CalamariVariables newVariables; readonly HttpClient client = new HttpClient(); @@ -225,7 +77,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 +101,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/Azure/IAzureAppServiceContainerConfigurer.cs b/source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs new file mode 100644 index 0000000000..52b364c5e0 --- /dev/null +++ b/source/Calamari.AzureAppService/Azure/IAzureAppServiceContainerConfigurer.cs @@ -0,0 +1,57 @@ +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 +{ + // The single seam through which container deployment talks to Azure, so the deploy logic can be mocked in tests. + 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/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/Program.cs b/source/Calamari.AzureAppService/Program.cs index fb01208020..3045dcdbff 100644 --- a/source/Calamari.AzureAppService/Program.cs +++ b/source/Calamari.AzureAppService/Program.cs @@ -20,6 +20,7 @@ protected override void ConfigureContainer(ContainerBuilder builder, CommonOptio base.ConfigureContainer(builder, options); builder.RegisterType().As(); + builder.RegisterType().As(); } public static Task Main(string[] args) From 647670960fdae5b1f4653bb35e08e1504ea6000c Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 20:58:40 +1000 Subject: [PATCH 05/12] Extract PackageProviderFactory and unit-test package routing Choosing the IPackageProvider from a package's file extension - which sets the Kudu upload endpoint and whether the deploy is synchronous or async - was an inline switch in AzureAppServiceZipDeployBehaviour, only covered through full cloud deploys. Move it into PackageProviderFactory and unit-test the routing directly: .zip/.nupkg/.war/.jar each map to the expected upload URL and sync/async mode, and an unsupported extension throws. Notably .war and .jar route to the same JavaPackageProvider, differing only by upload URL - so a real-cloud deploy of each adds no coverage. This sets up removing the WAR/JAR cloud tests next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PackageProviderFactoryFixture.cs | 39 +++++++++++++++++++ .../PackageProviderFactory.cs | 29 ++++++++++++++ .../AzureAppServiceZipDeployBehaviour.cs | 11 +----- 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 source/Calamari.AzureAppService.Tests/PackageProviderFactoryFixture.cs create mode 100644 source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs 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/ArchivePackagProvider/PackageProviderFactory.cs b/source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs new file mode 100644 index 0000000000..8d057deeb0 --- /dev/null +++ b/source/Calamari.AzureAppService/ArchivePackagProvider/PackageProviderFactory.cs @@ -0,0 +1,29 @@ +using System; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AzureAppService +{ + // Maps a file extension to its package provider (upload endpoint + sync/async mode). The one Calamari-owned + // decision in an otherwise Azure-bound deploy, so it's unit-tested in PackageProviderFactoryFixture. + 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/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; From 4a5a3a11d9d37affe8ecec66ff0d07b0d42ce9d4 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 21:03:01 +1000 Subject: [PATCH 06/12] Drop the WAR and JAR cloud deploy tests WAR and JAR both route through JavaPackageProvider, differing only by the Kudu upload URL - which PackageProviderFactoryFixture now verifies without Azure. The cloud tests spun up a dedicated Tomcat/Java app service purely to assert the deploy succeeded, adding no coverage beyond that unit test while paying for a real provision-and-deploy. Remove both CanDeployWarPackage and the two CanDeployJarPackage tests, along with the appServicePlanResource fields that only existed to host the Java sites, and delete the now-orphaned sample.1.0.0.war and sample4-1.0.0.jar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Calamari.AzureAppService.Tests.csproj | 6 - .../AppServiceBehaviourFixture.cs | 141 +----------------- .../sample.1.0.0.war | Bin 4878 -> 0 bytes .../sample4-1.0.0.jar | Bin 2645 -> 0 bytes 4 files changed, 1 insertion(+), 146 deletions(-) delete mode 100644 source/Calamari.AzureAppService.Tests/sample.1.0.0.war delete mode 100644 source/Calamari.AzureAppService.Tests/sample4-1.0.0.jar diff --git a/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj index e3d253ba5c..61efd16f03 100644 --- a/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj +++ b/source/Calamari.AzureAppService.Tests/Calamari.AzureAppService.Tests.csproj @@ -28,12 +28,6 @@ - - Always - - - Always - functionapp.1.0.0.zip Always diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index 3f30b205ae..3853ae6349 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs @@ -33,13 +33,10 @@ public class AppServiceBehaviorFixture [TestFixture] public class WhenUsingAWindowsDotNetAppService : 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; } @@ -167,97 +164,6 @@ await CommandTestBuilder.CreateAsync() 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() { @@ -402,7 +308,6 @@ public class WhenUsingALinuxAppService : AzureAppServiceWithProvisionedResources static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); readonly CancellationToken cancellationToken = CancellationTokenSource.Token; - AppServicePlanResource appServicePlanResource; protected override async Task ConfigureTestResources(ResourceGroupResource resourceGroup) { @@ -440,8 +345,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", @@ -556,48 +459,6 @@ await AssertContent(WebSiteResource.Data.DefaultHostName, 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/sample.1.0.0.war b/source/Calamari.AzureAppService.Tests/sample.1.0.0.war deleted file mode 100644 index d192f3444347b2311fdae4ee49a2a5ff04d03fd8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4878 zcmaJ_2UL?u7fk~KDS|XbN?1CCUY2eEK_GyX&;+CiQVdcOAyTA-UY05JuJ z%zzuQD@0=7v4zNIB)(0QNW_{G64}+?rGHSk(nM>0XpU%TG=B$Hf5v z9&UKNkCYd|H#F543zlV$c@6u**Qy~ zQ#N<~6*I7Ywk}pT=z?7;wJw=O5Um($T~K(jSBm&6?8Cd07gpFpJwwMo&b?Ir@cFF5 z*g=ZTd%qo-0P@>@R5z>`rln5C+lbq+2wI{B=8BkFZxi0IXr2%7;$9uu^UaC)#6z;H z_Ib$4yv-!8?rsEW+AjX+#6oi-`gosrCQ7+`UI5;vJGXn%8vEF>@N|}dLDUY4a!=}#i&$e6X0Saahzzn&Y=_j0KjET z&rux^0$^%(E->gBhU(;vK!vIRQ`?XP8_5TaQYnDY_zDT2vjKML!caJ1H`pQ;4kDtR z#MFp%a6-UUV*nbSKxPEcGcwY_vU76i=osm}G(hPd$rv?Y8vFves=5l2Mkpy&R)bZ5 zD$VjBcr2_9f74bWz8(CV!{|N-Sy(mkQA7KWky8x+RF~gxUMM4Rmk*6#@_ zOTBneUlC}hapkV)qks~wx?&fDAR^RV9DrQX3aIqg73<5u%8veg6fb&DTUm`=?zv?E z!KhSHBjpIZ^D5FbNe&Z{H9moMd{@T9wazBOxWYK=swTkZ~*7y z;kV}i&c!Z9f<@)if}R<%rXFv*iE~1po2YG%X*Ikj3S~*@?=N2qN|u zke@X~=Xg6bvoo`vE-vFjfalyWvC#H)WRSc)&K~jHbrln>J`kt_vPBmFt|E>`0AkrJ zA9wk67vIwc7MZ#VC=B!4m^t%@$7+jq*vIFCdK*8l1Bg~C2t}Z6w>hSchY$}FGja!+ zqmNl&Vl8KhqzN5I6efO(WnP*(|MQP8FeM zz|Z3a)!bUnIM!hH^xNQr*;$`*;7K)^p!JYzl`M`4+m>CU+MKoB=!OR)?VKn4pbTi= z%}plpu;Jg^S6{xJYI?&AltD;^<#?o&d@P>40Vg1B_;RMI<}E{9vmj@(}4iuUUWUUV=2 z&DM}4m&B)PNpoHYCn~2L5EFrIKP9i30;8UzrGIhmen0}R)S?CtBnq6&ZFzx4tSZf5 zckbSE9-p>#EUGiv>_r2kHFu{KiQYc!!F!K#jR#mJA#6uFK^}9X}EcAd!RH4tv(@X1+Rz`Yh(iaApfIn=|Kx_kj z3nsWd^hKsQIln{1!ydp~8o@64>MCe7v>E z><`E=Ir_;ZXU(irqv}raDA=wp6sB~EaZo3sE?rxhEpI4d=O)fti8D#K{f}e*LnoFK z-j^SXtTD2`8wqD7D)EDsT*{YjWG`v)?bb%w;tLVk1qF46CT0_{7oeCS`XxqV!8Hbh zILp}PogU%g?SxTr9Uk6ocKeC=WN(N7B6(?tI(CFrlbsQuD8N)h~UwOsVKF{ zc>07S!M$@V;goW(MrDGsFZ9<-=m3D*ltC-<6{r45l@xvecwTaKBT0D>z46KB#@H?l za|`D#UyOZore1YAJw)q`Aa1+aK=^5Gj?sM2z3m#8vH%+p%Lw-pw zF{_Lz)_KbQK({W9D9oD-CV2+ZC11ce((_Kas+r~&xaqC^wy-CXbB9fB#wSaCgA*OO z#^P)R-mq;CACp_pVMy#lg1{;dc2L4ju2t4~oSrPINAcjH<2Lm26GHWk*7sPI+KNFc z562Y*U)y?k8EkzG>cRRdl(QJW*gEY?>K0JVGrOd&F8TQPkHPzL)>b7-gj!Wpg%aO+ z6V^L($4$_&h+9`8rj5e$pGc`Pq-z=ub7=%mzci7})l=CCo;o|pp`4ITFAlbn6+&>T7+48zTC=xa_6Jy>Db?yBr^P)yZ8EG8G(PmsK+ud6OrObA?FZEn?*a8 z?H}4RY9mH;BM_+#OugAX%YLk+HB?G$a2Pvd`c{8rV89w@S`{E|XeyOUp*A$9Uup*Q}jsEZSPmOM^tNpF_qj{bAp-EsqwNwqrOi0hW&DT}`cItxw=VA6)01(}%vrUVHNX*Y{sj6r$SM+$t!~+&F8-&$_&9BY zYnGb$bXnVZ*Xj9q?azW?tBD+%JHV@%ZeyF`nVbsORC85+)0ihbly__k-3K`xtvJrz z2RYr!sZ`~0W3@56L(%?uV~1bH3P+#!FKXNt+;qs;!hW;m#v#9y^Vut!eega!7r}E_ zo~U+i$U-P)bfUHtUVhw`6&~tVEo3!2k^r5VPdH;#g|4VNc#P0!G3n3rBC&M+?oW{L zt*D-RSi>3pdI+RrZ7c=t*>-LCIvKihX*LbIxTF0PQ49)BbCML!i<|LgDepWve+P5_ zC$n+Ok}l;q=hc&E+?SmFL0xTFL;o~&ADuJtsm|@)Px2b9b*hV%;Ks1jw=%lYsi_YhJrDi^Jv#GZo>*wZw zd*^r6K}w|J^-)K=MI{zYqCw%;A-o)XYcVutpRe*<(EIvFZP7vt!Q4{&i#S>Cq^RsP zNuje|p%N$;RG$8`K)cJq#uMRFBey<1)|K;w_v!WR%SG;m6&OTds=sn*oaoz_8qY_{ zXhcSpdbnk{xjvE#1$}Y4e}*;S#M__f8S87oF}^Wqa&-NwZU;UNpU}AV#=Z}!swUG) zX6qU}RrN~Xp@_dHdCn=NBdOyY*_O-z=T&&}AXde7yQX6>y^3LeoR-^UP&@EV{H)xRqgnF&xjW|zRMRc)k9AD$<-SOl-f?9yZC`=W#U68+v&V$J2MT)-Z$9z6 z_&*`NgwC2^ZuX&el(Z_5m#1|@gWl8v-4JpyfRBqC(+>%wdt0px1n^eSztpvN}h^D_E ztc2YmZsSBFcbHI?s>#eMm`!AaVYJKEZ1i>Lny3Quams8tWhf6lcp$A68W0P z+gYI+pL=s1kr(an+(+)Qbc|@P^`BK}9h<%zb+uf!;j2Tr{C zR%34nRQ&eH=DZInpz@RLq}(OZh3+sp$!3#WD6r+`_8s60uah#W4lB8f_BVDuAgITC z`>Gp=L?I7J$`PAlfgm=(Kh^!;(w(aA|7qXlI#uic2tBN-soI`q8kFgG=pm(lI099D zAC{erG&p58PZ{5}I2FbO`1hBRs!3_KPg&)6g-Tl}_5D2D*ibbp%>*dx{|iJ@tcO#j zc0Y#+n3BmMqx?;Bs24hnqtf$XYNZk=&GIQL{+q1-YlZ(r?NLxP5~l6Tj|54jVVVh0 zQvMr7Bjx{(rqVLa%>D&^n51bNP0iwPF`?2l&Eo$v&LPtNukD~#A)4(WXbbzZCef0i jp8DZepIVh@CW53P|Ef`@SUP&D1A_A5rredeZ;1Z^=Q#)- 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 ad086a679bd53dd8c5fffee514f7a6652b81ba58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2645 zcmWIWW@h1HVBlb2=xNN3WIzI(K(?=Ih@-BjpPPOFP?-o&83$NdzI5cjY@l==5CcUK z%6uLDJl$M_L-c&zSPvfLI%L4ZaG`p^{|WLLh3A6vZ|5~PX;kp%PD$`ReT(bg-uJ9Q zl|`?=9dw_(NKU=nXoIZJ+{v-^a?G`nh4Fb6XCJF}ZQi}(Q8w65V^PAa~rmt&gSaKxZ*+}%-t(5C$ zSuQO95c)@$Yvm`#8jaKQ?w&{$2%7rt=F8Wwv+Mr;`zU%#vfUCkK$;r^DzpW#NrID_}^rWwo6`N=w`O4!^p&A<8qVFelmtfOkY^7TQD*4 z#s7U_t{02BT@PM}5GgvQ>k&BNb-c~440D~-Y^yyJjIS@r7cW{lk@xAoNOx`_5fk;4 zTicg@khJQZ7;v}jtmvh+96^Cw43CJnJl;{ed)@n#CI;7>9mZ8^pL;YTkl^>Peeqc^9#7y=rjm!j!_# z%Vtkp7p^ty@;q^8qa#_bb~JOgNjO?$y?M6OAYyrS)J8k@s9Ve5tPs?C7CU#@+qxST zsY|`?CWmb2e!9{^?$N}y<5u%lTzq2}KXuo!@||nD>>j6OHq6r5tuW2&r|!e`pLN^w zgr?1Yc#iFR?$_9zYo^{Vo4D|(Rq#p?%QL$^uhLoFR(esQUaxbJP4vvq%jQ0taqCID z8_(hCU(N}9?f>>P$zy87l}9)C_Iz04J6}rd-_7@32PaNyyRQB%sqm+V!`4&9vm@F+ zZoHK@Y0huURjJvchI^%qZiuxVT*Yg@H&~N*b$uX9p5KCwwL4Yqm7nEY-KViND`w@w z`EJ%sUsp2*>q}k~shxR5^5d?HwHw}-bR=|T)D}ecFL(JGzqljBfSUnzC6Lx(K>!R|ebtcG}(LUs}~UQ^8Lgy6BVB?&YW6@lg9EY<_yj)Ufu6&MKZ2yL&#CZ0b45 zY?EN&cExvIqngtdpDnDh9tRY1ghgT(zUEMVpgXPO^6tjeH3GYKD%7`?e~IVbv-UqD zqBwH|W}h?BcASS985mXqiwiB{@?QZkGgsv1^adZyyKTU;SNw%N%g$)8YdkNQt{*VS zInW>`yVbGsoTS?1Ij%H9oI!_|cUsx9;nHSG=M7 zyw{>3?GV>a_OzIz`|GP8NN-D8otl#8AJ_Zl@y8Q==0~cxN3H$&zWuj+#ElN0O!<~+ z8}{sr(%F#mnsdK-5KHP8=`UjI4gL1@B)HkMecbdS<DZj7R z&+FgR?fYoY_x0)IV=Pk>9H%cxRWeJeuU3iF>a+5Z^=W>dcEQHcGS(neg}e7m&y1NS zd~s`LYqiPNADDW7-~4xbX4jd8GpV$l_`74Cc|ulzJukLh?fqBSU~UBa;X-?y4B*7$6W}cQAtCu*FA!Oh|2ta4NbEP<;vkAg4Y9GC?{J^&Ubew)z#JQvjGB z;jV?%wzxHe>RW_nTTIQUwJ&aiK=m)epfdahA!=igfsjbRRv*KRT++B5XdrTcz-wnn zEI^FFRwf~g_y#Pc&_V{;b1*kSODJS>u_bVXxs2>Yn~O;90p6^@HU|R(7Z7e^VqoY5 I8p6N;0M!+ Date: Wed, 24 Jun 2026 21:05:26 +1000 Subject: [PATCH 07/12] Remove the no-op async-deployment-and-polling duplicate tests CanDeployWebAppZip_WithAsyncDeploymentAndPolling, CanDeployNugetPackage_WithAsyncDeploymentAndPolling and CanDeployZip_ToLinuxFunctionApp_WithAsyncDeploymentAndPolling each differed from their plain counterparts only by an arrange block that read the enabled feature toggles and wrote the identical values back - a no-op. The async path is not toggle-gated; it is selected by packageProvider.SupportsAsynchronousDeployment (true for zip and nuget), so the baseline zip/nuget deploy tests already cover it. These three were behavioural duplicates, each paying for a second real cloud deploy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppServiceBehaviourFixture.cs | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index 3853ae6349..f74da11fed 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs @@ -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; @@ -73,25 +72,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() { @@ -144,26 +124,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 DeployingWithInvalidEnvironment_ThrowsAnException() { @@ -430,35 +390,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); - } - 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. From 0854784b658abc20209e56c743a7853bdeb9ab09 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 21:20:24 +1000 Subject: [PATCH 08/12] Remove the misnamed DeployToTwoTargetsInParallel cloud test The test did not deploy two targets in parallel: it opened the package file with a read share-lock and ran a single deployment, asserting only that the outcome was successful. It was really checking that Calamari's package staging tolerates a concurrently-held file handle - a generic Calamari.Common file-share concern, not an Azure App Service one - while paying for a full real-cloud deploy. Remove it along with its sole helper PrepareFunctionAppZipPackage and the FileShare alias. If the file-locking behaviour is worth covering, it belongs in Calamari.Common tests against the staging code, without Azure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppServiceBehaviourFixture.cs | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index f74da11fed..a5fb8556b6 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs @@ -23,7 +23,6 @@ using Calamari.Testing.LogParser; using FluentAssertions; using NUnit.Framework; -using FileShare = System.IO.FileShare; namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { @@ -141,39 +140,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; @@ -227,20 +193,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); From 60bac5c6068aff0aa89ebb0c08cf5952bb3fc5cc Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 21:34:39 +1000 Subject: [PATCH 09/12] Reframe the deploy fixtures as Web App vs Function App The two nested fixtures were named for an operating-system split (WhenUsingAWindowsDotNetAppService / WhenUsingALinuxAppService), but Calamari only branches on OS inside container deploy - which is now unit-tested. What these fixtures actually differ by is the resource type: a plain Web App versus a (Linux) Function App, both deployed through the same zip-deploy path. Rename them to WhenDeployingToAWebAzureApp and WhenDeployingToAFunctionAzureApp and add a short comment so the fixtures describe what they exercise. Also strip the file's leading byte-order mark. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppServiceBehaviourFixture.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceBehaviourFixture.cs index a5fb8556b6..cfc33a0dac 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/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; @@ -26,10 +26,12 @@ namespace Calamari.AzureAppService.Tests.ExternalCloudIntegration { + // The two nested fixtures cover two deployment targets - a Web App and a (Linux) Function App - not two + // operating systems. Both deploy through the same zip-deploy path; they differ only by resource type. public class AppServiceBehaviorFixture { [TestFixture] - public class WhenUsingAWindowsDotNetAppService : AzureAppServiceWithProvisionedResourcesTestBase + public class WhenDeployingToAWebAzureApp : AzureAppServiceWithProvisionedResourcesTestBase { protected override async Task ConfigureTestResources(ResourceGroupResource resourceGroup) { @@ -213,7 +215,7 @@ private void AddVariables(CommandTestBuilderContext context) } [TestFixture] - public class WhenUsingALinuxAppService : AzureAppServiceWithProvisionedResourcesTestBase + 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"); From caf6fec8c8fbc16eac2bdbdfe72bee4407c64979 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 21:59:49 +1000 Subject: [PATCH 10/12] Strip BOM from two ExternalCloudIntegration fixtures AppServiceSettingsBehaviourFixture and AzureAppHealthCheckActionHandlerFixture were the only files in the folder still carrying a UTF-8 byte-order mark, which triggered an editor encoding warning. The repo convention is no-BOM, so remove it - the whole folder is now uniformly no-BOM. No content change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppServiceSettingsBehaviourFixture.cs | 2 +- .../AzureAppHealthCheckActionHandlerFixture.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs index b748f8183c..436ef5af53 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AppServiceSettingsBehaviourFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs index a187f45cc9..a9787abe69 100644 --- a/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs +++ b/source/Calamari.AzureAppService.Tests/ExternalCloudIntegration/AzureAppHealthCheckActionHandlerFixture.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable using System; using System.Net; using System.Threading.Tasks; From 6b331c9af55d1d0bcb2d2c057c591ce64c1faae9 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 24 Jun 2026 22:30:47 +1000 Subject: [PATCH 11/12] Add a separate build target for external cloud integration tests The ExternalCloudIntegration category now needs to run as its own CI step: the main flavour test run should stay credential-free, and the live-Azure smoke suite should run separately. Exclude TestCategory=ExternalCloudIntegration from TestCalamariFlavourProject, and add a TestCalamariExternalCloudIntegrations target that runs only those tests across every flavour. Both combine with, rather than overwrite, any caller-supplied VSTest_TestCaseFilter. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d.TestCalamariExternalCloudIntegrations.cs | 30 +++++++++++++++++++ build/Build.TestCalamariFlavourProject.cs | 10 ++++++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 build/Build.TestCalamariExternalCloudIntegrations.cs diff --git a/build/Build.TestCalamariExternalCloudIntegrations.cs b/build/Build.TestCalamariExternalCloudIntegrations.cs new file mode 100644 index 0000000000..4c7608630e --- /dev/null +++ b/build/Build.TestCalamariExternalCloudIntegrations.cs @@ -0,0 +1,30 @@ +using JetBrains.Annotations; + +namespace Calamari.Build; + +partial class Build +{ + [PublicAPI] + Target TestCalamariExternalCloudIntegrations => + target => target + .Executes(async () => + { + var dotnetPath = await LocateOrInstallDotNetSdk(); + + // Run only the tests that hit real external cloud services (e.g. real Azure) - the + // credential-requiring smoke suite kept out of the main flavour run. Combine with any + // caller-supplied VSTest_TestCaseFilter rather than overwriting it (WithFilter is last-wins). + const string onlyExternalCloud = "TestCategory=ExternalCloudIntegration"; + var filter = string.IsNullOrWhiteSpace(CalamariFlavourTestCaseFilter) + ? onlyExternalCloud + : $"({CalamariFlavourTestCaseFilter}) & {onlyExternalCloud}"; + + foreach (var flavour in GetCalamariFlavours()) + { + CreateTestRun($"CalamariTests/{flavour}.Tests.dll") + .WithDotNetPath(dotnetPath) + .WithFilter(filter) + .Execute(); + } + }); +} diff --git a/build/Build.TestCalamariFlavourProject.cs b/build/Build.TestCalamariFlavourProject.cs index 46e4fe8b27..696240083c 100644 --- a/build/Build.TestCalamariFlavourProject.cs +++ b/build/Build.TestCalamariFlavourProject.cs @@ -14,9 +14,17 @@ partial class Build { var dotnetPath = await LocateOrInstallDotNetSdk(); + // Exclude tests that hit real external cloud services (e.g. real Azure) - these run as a + // separate smoke suite, not as part of the main flavour test run. Combine with any + // caller-supplied VSTest_TestCaseFilter rather than overwriting it (WithFilter is last-wins). + const string excludeExternalCloud = "TestCategory!=ExternalCloudIntegration"; + var filter = string.IsNullOrWhiteSpace(CalamariFlavourTestCaseFilter) + ? excludeExternalCloud + : $"({CalamariFlavourTestCaseFilter}) & {excludeExternalCloud}"; + CreateTestRun($"CalamariTests/Calamari.{CalamariFlavourToTest}.Tests.dll") .WithDotNetPath(dotnetPath) - .WithFilter(CalamariFlavourTestCaseFilter) + .WithFilter(filter) .Execute(); }); } \ No newline at end of file From c014297d42786190fcd2a826fb49c293dcb3f235 Mon Sep 17 00:00:00 2001 From: robert Date: Thu, 25 Jun 2026 22:06:38 +1000 Subject: [PATCH 12/12] Change some test categories. This can be ignored as its a reverse of the first commit --- .../DeployAzureBicepTemplateCommandFixture.cs | 1 - .../AzurePowershellCommandFixture.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs index 4071933659..6ce8922365 100644 --- a/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs +++ b/source/Calamari.AzureResourceGroup.Tests/DeployAzureBicepTemplateCommandFixture.cs @@ -19,7 +19,6 @@ 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 a0345b5a58..26f42505b3 100644 --- a/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs +++ b/source/Calamari.AzureScripting.Tests/AzurePowershellCommandFixture.cs @@ -12,7 +12,6 @@ namespace Calamari.AzureScripting.Tests { [TestFixture] - [Category(TestCategory.ExternalCloudIntegration)] class AzurePowerShellCommandFixture { string? clientId;