diff --git a/source/Calamari.Aws/Calamari.Aws.csproj b/source/Calamari.Aws/Calamari.Aws.csproj index 61f592dc7..435e3e63b 100644 --- a/source/Calamari.Aws/Calamari.Aws.csproj +++ b/source/Calamari.Aws/Calamari.Aws.csproj @@ -32,6 +32,7 @@ + diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index 381d584c2..0320d074c 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -111,14 +111,14 @@ EcsCommandInputs ReadAndValidateInputs() var userTags = JsonConvert.DeserializeObject>>(variables.Get(AwsSpecialVariables.CloudFormation.Tags) ?? "[]") ?? []; var tags = EcsDefaultTags.Merge(variables, userTags); - var waitOptionType = variables.Get(AwsSpecialVariables.Ecs.WaitOption.Type); + var waitOptionType = variables.Get(AwsSpecialVariables.Ecs.WaitOptionLegacy.Type); Guard.NotNullOrWhiteSpace(waitOptionType, "The wait option is required"); if (waitOptionType != "waitUntilCompleted" && waitOptionType != "waitWithTimeout" && waitOptionType != "dontWait") { throw new CommandException($"The wait option has an invalid value '{waitOptionType}'. Expected one of: 'waitUntilCompleted', 'waitWithTimeout', 'dontWait'."); } - var waitOptionTimeoutMs = variables.GetInt32(AwsSpecialVariables.Ecs.WaitOption.Timeout); + var waitOptionTimeoutMs = variables.GetInt32(AwsSpecialVariables.Ecs.WaitOptionLegacy.Timeout); if (waitOptionType == "waitWithTimeout" && !waitOptionTimeoutMs.HasValue) { throw new CommandException("Wait option is 'waitWithTimeout' but timeout value is not set."); diff --git a/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs b/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs index ac7935234..d27ffe93e 100644 --- a/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs @@ -10,8 +10,7 @@ using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; using Calamari.Deployment; -using Calamari.Serialization; -using Newtonsoft.Json; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Aws.Commands; @@ -47,8 +46,7 @@ public override int Execute(string[] commandLineArguments) inputs.TargetTaskDefinitionName, inputs.Containers, inputs.Tags, - inputs.WaitOption, - inputs.WaitTimeout) + inputs.WaitOption) ], log).RunConventions(); @@ -60,20 +58,19 @@ EcsUpdateServiceInputs ReadAndValidateInputs() var clusterName = variables.Get(AwsSpecialVariables.Ecs.ClusterName); Guard.NotNullOrWhiteSpace(clusterName, "Cluster name is required"); - var serviceName = variables.Get(AwsSpecialVariables.Ecs.ServiceName); + var serviceName = variables.Get(AwsSpecialVariables.Ecs.Update.ServiceName); Guard.NotNullOrWhiteSpace(serviceName, "Service name is required"); - var targetFamily = variables.Get(AwsSpecialVariables.Ecs.TargetTaskDefinitionName); + var targetFamily = variables.Get(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName); Guard.NotNullOrWhiteSpace(targetFamily, "Target task definition name is required"); - var templateFamily = variables.Get(AwsSpecialVariables.Ecs.TemplateTaskDefinitionName); + var templateFamily = variables.Get(AwsSpecialVariables.Ecs.Update.TemplateTaskDefinitionName); if (string.IsNullOrWhiteSpace(templateFamily)) { templateFamily = targetFamily; } - var containersJson = variables.Get(AwsSpecialVariables.Ecs.Containers) ?? "[]"; - var containers = JsonConvert.DeserializeObject>(containersJson, JsonSerialization.GetDefaultSerializerSettings()) ?? []; + var containers = variables.GetValueDeserialisedAs>(AwsSpecialVariables.Ecs.Update.ContainerUpdates); if (containers.Count == 0) { throw new CommandException("At least one container is required."); @@ -84,8 +81,7 @@ EcsUpdateServiceInputs ReadAndValidateInputs() Guard.NotNullOrWhiteSpace(c.ContainerName, "Container name is required"); } - var tagsJson = variables.Get(AwsSpecialVariables.CloudFormation.Tags) ?? "[]"; - var userTags = JsonConvert.DeserializeObject>>(tagsJson) ?? []; + var userTags = variables.GetValueDeserialisedAs>>(AwsSpecialVariables.ResourceTags); var seenTagKeys = new HashSet(StringComparer.Ordinal); foreach (var tag in userTags) { @@ -95,24 +91,10 @@ EcsUpdateServiceInputs ReadAndValidateInputs() } } - var waitOptionRaw = variables.Get(AwsSpecialVariables.Ecs.WaitOption.Type); - Guard.NotNullOrWhiteSpace(waitOptionRaw, "The wait option is required"); - if (!Enum.TryParse(waitOptionRaw, ignoreCase: true, out var waitOption)) + var waitOption = variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.WaitOption); + if (waitOption.Type == WaitType.WaitWithTimeout && waitOption.GetTimeoutSpan() is null) { - throw new CommandException( - $"The wait option has an invalid value '{waitOptionRaw}'. Expected one of: 'waitUntilCompleted', 'waitWithTimeout', 'dontWait'."); - } - - TimeSpan? timeout = null; - var timeoutMs = variables.GetInt32(AwsSpecialVariables.Ecs.WaitOption.Timeout); - if (waitOption == WaitOptionType.WaitWithTimeout) - { - if (!timeoutMs.HasValue) - { - throw new CommandException("Wait option is 'waitWithTimeout' but timeout value is not set."); - } - - timeout = TimeSpan.FromMilliseconds(timeoutMs.Value); + throw new CommandException($"Wait option is '{nameof(WaitType.WaitWithTimeout)}' but got invalid timeout '{waitOption.TimeoutMinutes}'."); } return new EcsUpdateServiceInputs( @@ -122,8 +104,7 @@ EcsUpdateServiceInputs ReadAndValidateInputs() templateFamily, containers, userTags, - waitOption, - timeout); + waitOption); } } @@ -132,7 +113,6 @@ public record EcsUpdateServiceInputs( string ServiceName, string TargetTaskDefinitionName, string TemplateTaskDefinitionName, - List Containers, + List Containers, List> Tags, - WaitOptionType WaitOption, - TimeSpan? WaitTimeout); + WaitOption WaitOption); diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 359394845..55a531a1f 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -3,6 +3,7 @@ public static class AwsSpecialVariables { public const string IamCapabilities = "Octopus.Action.Aws.IamCapabilities"; + public const string ResourceTags = "Octopus.Action.Aws.Tags"; public static class Authentication { @@ -23,11 +24,18 @@ public static class Ecs { public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; - public const string TargetTaskDefinitionName = "Octopus.Action.Aws.Ecs.TargetTaskDefinitionName"; - public const string TemplateTaskDefinitionName = "Octopus.Action.Aws.Ecs.TemplateTaskDefinitionName"; - public const string Containers = "Octopus.Action.Aws.Ecs.Containers"; + public const string WaitOption = "Octopus.Action.Aws.Ecs.WaitOption"; - public static class WaitOption + public static class Update + { + public const string ServiceName = "Octopus.Action.Aws.Ecs.Update.ServiceName"; + public const string TargetTaskDefinitionName = "Octopus.Action.Aws.Ecs.Update.TargetTaskDefinitionName"; + public const string TemplateTaskDefinitionName = "Octopus.Action.Aws.Ecs.Update.TemplateTaskDefinitionName"; + public const string ContainerUpdates = "Octopus.Action.Aws.Ecs.Update.ContainerUpdates"; + } + + // Deploy ECS step: legacy flat key/value pair. Will consolidate when Deploy migrates. + public static class WaitOptionLegacy { public const string Type = "Octopus.Action.Aws.Ecs.WaitOption.Type"; public const string Timeout = "Octopus.Action.Aws.Ecs.WaitOption.Timeout"; @@ -42,6 +50,7 @@ public static class CloudFormation public const string TemplateParameters = "Octopus.Action.Aws.CloudFormationTemplateParameters"; public const string TemplateParametersRaw = "Octopus.Action.Aws.CloudFormationTemplateParametersRaw"; public const string RoleArn = "Octopus.Action.Aws.CloudFormation.RoleArn"; + // TODO: Tags aren't CFN specific so migrate to use ResourceTags = "Octopus.Action.Aws.Tags" above public const string Tags = "Octopus.Action.Aws.CloudFormation.Tags"; public static class Changesets diff --git a/source/Calamari.Aws/Deployment/Conventions/UpdateEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/UpdateEcsServiceConvention.cs index 32459d6e8..67e9ce716 100644 --- a/source/Calamari.Aws/Deployment/Conventions/UpdateEcsServiceConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/UpdateEcsServiceConvention.cs @@ -10,6 +10,7 @@ using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; using Calamari.Deployment.Conventions; +using Octopus.Calamari.Contracts.Aws.Ecs; using Task = System.Threading.Tasks.Task; namespace Calamari.Aws.Deployment.Conventions; @@ -22,15 +23,14 @@ public class UpdateEcsServiceConvention( string serviceName, string templateTaskDefinitionName, string targetTaskDefinitionName, - List containers, + List containers, List> tags, - WaitOptionType waitOption, - TimeSpan? waitTimeout, + WaitOption waitOption, Func deploymentPollInterval = null, Func taskPollInterval = null) : IInstallConvention { - readonly EcsPostDeployWatcher watcher = new(ecs, log, clusterName, serviceName, waitOption, waitTimeout, deploymentPollInterval, taskPollInterval); + readonly EcsPostDeployWatcher watcher = new(ecs, log, clusterName, serviceName, waitOption, deploymentPollInterval, taskPollInterval); public void Install(RunningDeployment deployment) => InstallAsync(deployment).GetAwaiter().GetResult(); @@ -67,7 +67,7 @@ await ecs.DescribeTaskDefinitionAsync( } var taskDefTags = EcsDefaultTags.MergeAndDeduplicateTags(deployment.Variables, tags, templateResp.Tags); - var registerRequest = RegisterTaskDefinitionRequestFactory.FromTaskDefinition(template, targetTaskDefinitionName, containers, taskDefTags); + var registerRequest = RegisterTaskDefinitionRequestFactory.FromTaskDefinition(template, targetTaskDefinitionName, containers, taskDefTags, deployment.Variables); var registerResp = await ecs.RegisterTaskDefinitionAsync(registerRequest, ct); var registeredTaskDef = registerResp.TaskDefinition; diff --git a/source/Calamari.Aws/Integration/Ecs/EcsContainerUpdate.cs b/source/Calamari.Aws/Integration/Ecs/EcsContainerUpdate.cs deleted file mode 100644 index 8e1d9475b..000000000 --- a/source/Calamari.Aws/Integration/Ecs/EcsContainerUpdate.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Calamari.Aws.Integration.Ecs; - -public record EcsContainerUpdate( - string ContainerName, - string Image, - EnvAction EnvironmentVariables, - EnvAction EnvironmentFiles); diff --git a/source/Calamari.Aws/Integration/Ecs/EcsPostDeployWatcher.cs b/source/Calamari.Aws/Integration/Ecs/EcsPostDeployWatcher.cs index 1ec961c47..80d4bb2da 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsPostDeployWatcher.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsPostDeployWatcher.cs @@ -5,6 +5,7 @@ using Amazon.ECS.Model; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; +using Octopus.Calamari.Contracts.Aws.Ecs; using Task = System.Threading.Tasks.Task; namespace Calamari.Aws.Integration.Ecs; @@ -15,8 +16,7 @@ public class EcsPostDeployWatcher readonly ILog log; readonly string clusterName; readonly string serviceName; - readonly WaitOptionType waitOption; - readonly TimeSpan? waitTimeout; + readonly WaitOption waitOption; readonly Func deploymentPollInterval; readonly Func taskPollInterval; @@ -25,8 +25,7 @@ public EcsPostDeployWatcher( ILog log, string clusterName, string serviceName, - WaitOptionType waitOption, - TimeSpan? waitTimeout, + WaitOption waitOption, Func deploymentPollInterval = null, Func taskPollInterval = null) { @@ -35,14 +34,13 @@ public EcsPostDeployWatcher( this.clusterName = clusterName; this.serviceName = serviceName; this.waitOption = waitOption; - this.waitTimeout = waitTimeout; this.deploymentPollInterval = deploymentPollInterval ?? (() => TimeSpan.FromSeconds(3)); this.taskPollInterval = taskPollInterval ?? (() => TimeSpan.FromSeconds(10)); } public async Task WaitAsync(Service service, CancellationToken ct = default) { - if (waitOption == WaitOptionType.DontWait) + if (waitOption.Type == WaitType.DontWait) { return; } @@ -60,7 +58,7 @@ public async Task WaitAsync(Service service, CancellationToken ct = default) async Task WaitForDeploymentAsync(CancellationToken ct) { - var timeout = GetTimeout(waitTimeout); + var timeout = GetTimeout(); log.Info($"Waiting for ECS service '{serviceName}' deployment to reach COMPLETED."); string lastReportedState = null; @@ -120,7 +118,7 @@ async Task WaitForDeploymentAsync(CancellationToken ct) async Task WaitForTaskStatesAsync(CancellationToken ct) { - var timeout = GetTimeout(waitTimeout); + var timeout = GetTimeout(); while (true) { @@ -171,5 +169,14 @@ async Task WaitForTaskStatesAsync(CancellationToken ct) } } - static DateTime? GetTimeout(TimeSpan? waitTimeout) => waitTimeout.HasValue ? DateTime.UtcNow + waitTimeout.Value : null; + DateTime? GetTimeout() + { + if (waitOption.Type != WaitType.WaitWithTimeout) + { + return null; + } + + var span = waitOption.GetTimeoutSpan(); + return span.HasValue ? DateTime.UtcNow + span.Value : null; + } } diff --git a/source/Calamari.Aws/Integration/Ecs/EnvAction.cs b/source/Calamari.Aws/Integration/Ecs/EnvAction.cs deleted file mode 100644 index 2364073f5..000000000 --- a/source/Calamari.Aws/Integration/Ecs/EnvAction.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Collections.Generic; - -namespace Calamari.Aws.Integration.Ecs; - -public enum EnvActionMode { Replace, Merge } - -public record EnvAction(EnvActionMode Action, IReadOnlyList Items); diff --git a/source/Calamari.Aws/Integration/Ecs/EnvVarItem.cs b/source/Calamari.Aws/Integration/Ecs/EnvVarItem.cs deleted file mode 100644 index ceffc1d4a..000000000 --- a/source/Calamari.Aws/Integration/Ecs/EnvVarItem.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Calamari.Aws.Integration.Ecs; - -public enum EnvVarType { Text, Secret } - -public record EnvVarItem(EnvVarType Type, string Key, string Value); diff --git a/source/Calamari.Aws/Integration/Ecs/RegisterTaskDefinitionRequestFactory.cs b/source/Calamari.Aws/Integration/Ecs/RegisterTaskDefinitionRequestFactory.cs index a0a66b88a..2d50758c3 100644 --- a/source/Calamari.Aws/Integration/Ecs/RegisterTaskDefinitionRequestFactory.cs +++ b/source/Calamari.Aws/Integration/Ecs/RegisterTaskDefinitionRequestFactory.cs @@ -2,7 +2,9 @@ using System.Linq; using Amazon.ECS.Model; using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; using Newtonsoft.Json; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Aws.Integration.Ecs; @@ -13,8 +15,9 @@ public static class RegisterTaskDefinitionRequestFactory public static RegisterTaskDefinitionRequest FromTaskDefinition( TaskDefinition source, string targetFamily, - IReadOnlyList containerUpdates, - IReadOnlyList> tags) + IReadOnlyList containerUpdates, + IReadOnlyList> tags, + IVariables variables) { // Serialize task definition to JSON and then deserialize back to request // This avoids us dropping any fields from task definition when performing our update @@ -24,36 +27,40 @@ public static RegisterTaskDefinitionRequest FromTaskDefinition( request.Tags = tags.Select(t => new Tag { Key = t.Key, Value = t.Value }).ToList(); var containerLookup = (request.ContainerDefinitions ?? []).ToDictionary(c => c.Name); - foreach (var containerUpdate in containerUpdates) + foreach (var update in containerUpdates) { - if (!containerLookup.TryGetValue(containerUpdate.ContainerName, out var containerToUpdate)) + if (!containerLookup.TryGetValue(update.ContainerName, out var containerToUpdate)) { - throw new CommandException($"No matching container found for '{containerUpdate.ContainerName}' in template task definition '{source.Family}'."); + throw new CommandException($"No matching container found for '{update.ContainerName}' in template task definition '{source.Family}'."); } - if (!string.IsNullOrWhiteSpace(containerUpdate.Image)) + if (!string.IsNullOrWhiteSpace(update.PackageReference)) { - containerToUpdate.Image = containerUpdate.Image; + var image = variables.Get(PackageVariables.IndexedImage(update.PackageReference)); + if (!string.IsNullOrWhiteSpace(image)) + { + containerToUpdate.Image = image; + } } - ApplyEnvVars(containerToUpdate, containerUpdate.EnvironmentVariables); - ApplyEnvFiles(containerToUpdate, containerUpdate.EnvironmentFiles); + ApplyEnvVars(containerToUpdate, update.EnvironmentVariables); + ApplyEnvFiles(containerToUpdate, update.EnvironmentFiles); } return request; } - static void ApplyEnvVars(ContainerDefinition container, EnvAction action) + static void ApplyEnvVars(ContainerDefinition container, EnvAction action) { if (action is null || action.Items.Count == 0) { return; } - var plain = action.Items.Where(i => i.Type == EnvVarType.Text) + var plain = action.Items.Where(i => i.Type == KeyValueType.Plain) .Select(i => new Amazon.ECS.Model.KeyValuePair { Name = i.Key, Value = i.Value }) .ToList(); - var secrets = action.Items.Where(i => i.Type == EnvVarType.Secret) + var secrets = action.Items.Where(i => i.Type == KeyValueType.Secret) .Select(i => new Secret { Name = i.Key, ValueFrom = i.Value }) .ToList(); @@ -62,7 +69,7 @@ static void ApplyEnvVars(ContainerDefinition container, EnvAction ac container.Environment = plain; container.Secrets = secrets; } - else // Merge + else // Append { var existingEnv = container.Environment ?? []; container.Environment = existingEnv @@ -91,7 +98,7 @@ static void ApplyEnvFiles(ContainerDefinition container, EnvAction actio { container.EnvironmentFiles = files; } - else // Merge + else // Append { var existing = container.EnvironmentFiles ?? []; container.EnvironmentFiles = existing.Concat(files) diff --git a/source/Calamari.Aws/Integration/Ecs/WaitOptionType.cs b/source/Calamari.Aws/Integration/Ecs/WaitOptionType.cs deleted file mode 100644 index 3c119663d..000000000 --- a/source/Calamari.Aws/Integration/Ecs/WaitOptionType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Calamari.Aws.Integration.Ecs; - -public enum WaitOptionType -{ - WaitUntilCompleted, - WaitWithTimeout, - DontWait -} \ No newline at end of file diff --git a/source/Calamari.Common/Plumbing/Variables/CalamariContractSerializationSettings.cs b/source/Calamari.Common/Plumbing/Variables/CalamariContractSerializationSettings.cs index 2578b93f6..87ffef327 100644 --- a/source/Calamari.Common/Plumbing/Variables/CalamariContractSerializationSettings.cs +++ b/source/Calamari.Common/Plumbing/Variables/CalamariContractSerializationSettings.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; namespace Calamari.Common.Plumbing.Variables; @@ -14,5 +15,6 @@ public static class CalamariContractSerializationSettings NullValueHandling = NullValueHandling.Ignore, ContractResolver = new CamelCasePropertyNamesContractResolver(), MissingMemberHandling = MissingMemberHandling.Error, + Converters = { new StringEnumConverter() }, }; -} \ No newline at end of file +} diff --git a/source/Calamari.Contracts/Aws/Ecs/ContainerDefinition.cs b/source/Calamari.Contracts/Aws/Ecs/ContainerSpec.cs similarity index 98% rename from source/Calamari.Contracts/Aws/Ecs/ContainerDefinition.cs rename to source/Calamari.Contracts/Aws/Ecs/ContainerSpec.cs index 213d011ff..1649e263f 100644 --- a/source/Calamari.Contracts/Aws/Ecs/ContainerDefinition.cs +++ b/source/Calamari.Contracts/Aws/Ecs/ContainerSpec.cs @@ -1,6 +1,6 @@ namespace Octopus.Calamari.Contracts.Aws.Ecs; -public record ContainerDefinition +public record ContainerSpec { public string ContainerName { get; init; } = string.Empty; public ContainerImageReference ContainerImageReference { get; init; } = new(); diff --git a/source/Calamari.Contracts/Aws/Ecs/ContainerUpdate.cs b/source/Calamari.Contracts/Aws/Ecs/ContainerUpdate.cs index 260fd5644..0251eab48 100644 --- a/source/Calamari.Contracts/Aws/Ecs/ContainerUpdate.cs +++ b/source/Calamari.Contracts/Aws/Ecs/ContainerUpdate.cs @@ -3,7 +3,7 @@ namespace Octopus.Calamari.Contracts.Aws.Ecs; public record ContainerUpdate { public string ContainerName { get; init; } = string.Empty; - public string? Image { get; init; } + public string? PackageReference { get; init; } public EnvAction? EnvironmentVariables { get; init; } public EnvAction? EnvironmentFiles { get; init; } } diff --git a/source/Calamari.Contracts/Aws/Ecs/WaitOption.cs b/source/Calamari.Contracts/Aws/Ecs/WaitOption.cs index 0718040cc..ce40cddb8 100644 --- a/source/Calamari.Contracts/Aws/Ecs/WaitOption.cs +++ b/source/Calamari.Contracts/Aws/Ecs/WaitOption.cs @@ -1,9 +1,17 @@ +using System; + namespace Octopus.Calamari.Contracts.Aws.Ecs; public record WaitOption { public WaitType Type { get; init; } - public string? Timeout { get; init; } + + public string? TimeoutMinutes { get; init; } + + public TimeSpan? GetTimeoutSpan() => + int.TryParse(TimeoutMinutes, out var minutes) && minutes >= 0 + ? TimeSpan.FromMinutes(minutes) + : null; } public enum WaitType diff --git a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs index 9b6ef0f93..19a81bf0b 100644 --- a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs +++ b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs @@ -188,7 +188,7 @@ static async Task CreateVariables(string serviceName, string cfStack variables.Set(AwsSpecialVariables.Ecs.ClusterName, ClusterName); variables.Set(AwsSpecialVariables.Ecs.ServiceName, serviceName); //the integration test only needs to verify we can submit a valid template so don't wait for stack to be ready - variables.Set(AwsSpecialVariables.Ecs.WaitOption.Type, "dontWait"); + variables.Set(AwsSpecialVariables.Ecs.WaitOptionLegacy.Type, "dontWait"); return variables; } diff --git a/source/Calamari.Tests/AWS/Ecs/EcsPostDeployWatcherTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsPostDeployWatcherTests.cs index 2dbe6b084..344d33013 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsPostDeployWatcherTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsPostDeployWatcherTests.cs @@ -8,6 +8,7 @@ using FluentAssertions; using NSubstitute; using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; using Task = System.Threading.Tasks.Task; using EcsTask = Amazon.ECS.Model.Task; using EcsDeployment = Amazon.ECS.Model.Deployment; @@ -25,21 +26,20 @@ public void SetUp() ecs = Substitute.For(); } - EcsPostDeployWatcher Watcher(WaitOptionType waitOption = WaitOptionType.WaitUntilCompleted, TimeSpan? waitTimeout = null) => + EcsPostDeployWatcher Watcher(WaitType waitType = WaitType.WaitUntilCompleted, string timeoutMinutes = null) => new( ecs, new InMemoryLog(), clusterName: "cluster-x", serviceName: "svc-x", - waitOption: waitOption, - waitTimeout: waitTimeout, + waitOption: new WaitOption { Type = waitType, TimeoutMinutes = timeoutMinutes }, deploymentPollInterval: () => TimeSpan.Zero, taskPollInterval: () => TimeSpan.Zero); [Test] public async Task DontWaitSkipsPolling() { - await Watcher(WaitOptionType.DontWait).WaitAsync(Service()); + await Watcher(WaitType.DontWait).WaitAsync(Service()); await ecs.DidNotReceive().DescribeServicesAsync(Arg.Any(), Arg.Any()); await ecs.DidNotReceive().ListTasksAsync(Arg.Any(), Arg.Any()); @@ -116,7 +116,7 @@ public async Task DeploymentDoesNotCompleteTimesOut() ecs.DescribeServicesAsync(Arg.Any(), Arg.Any()) .Returns(ServicesWithRollout(DeploymentRolloutState.IN_PROGRESS)); - var act = async () => await Watcher(WaitOptionType.WaitWithTimeout, TimeSpan.Zero).WaitAsync(Service()); + var act = async () => await Watcher(WaitType.WaitWithTimeout, "0").WaitAsync(Service()); await act.Should().ThrowAsync().WithMessage("*Timed out*deployment*"); } @@ -134,7 +134,7 @@ public async Task TasksStuckPendingTimesOut() Tasks = [new EcsTask { TaskArn = "arn:task/1", LastStatus = "PENDING" }] }); - var act = async () => await Watcher(WaitOptionType.WaitWithTimeout, TimeSpan.Zero).WaitAsync(Service()); + var act = async () => await Watcher(WaitType.WaitWithTimeout, "0").WaitAsync(Service()); await act.Should().ThrowAsync().WithMessage("*tasks to reach RUNNING*"); } diff --git a/source/Calamari.Tests/AWS/Ecs/RegisterTaskDefinitionRequestFactoryTests.cs b/source/Calamari.Tests/AWS/Ecs/RegisterTaskDefinitionRequestFactoryTests.cs index c5addfdf2..fd8ac8d3f 100644 --- a/source/Calamari.Tests/AWS/Ecs/RegisterTaskDefinitionRequestFactoryTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/RegisterTaskDefinitionRequestFactoryTests.cs @@ -2,8 +2,10 @@ using Amazon.ECS.Model; using Calamari.Aws.Integration.Ecs; using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; using FluentAssertions; using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Tests.AWS.Ecs; @@ -16,8 +18,23 @@ public class RegisterTaskDefinitionRequestFactoryTests ContainerDefinitions = [..containers] }; - static RegisterTaskDefinitionRequest Build(TaskDefinition template, EcsContainerUpdate[] updates) => - RegisterTaskDefinitionRequestFactory.FromTaskDefinition(template, targetFamily: "fam", updates, tags: []); + static IVariables Variables(params (string packageReference, string image)[] packages) + { + var variables = new CalamariVariables(); + foreach (var (packageReference, image) in packages) + { + variables.Set(PackageVariables.IndexedImage(packageReference), image); + } + return variables; + } + + static RegisterTaskDefinitionRequest Build(TaskDefinition template, ContainerUpdate[] updates, IVariables variables = null) => + RegisterTaskDefinitionRequestFactory.FromTaskDefinition( + template, + targetFamily: "fam", + updates, + tags: [], + variables ?? new CalamariVariables()); [Test] public void DoesNotMutateInputTemplate() @@ -25,10 +42,10 @@ public void DoesNotMutateInputTemplate() var template = Template(new ContainerDefinition { Name = "web", Image = "old:1" }); var updates = new[] { - new EcsContainerUpdate("web", "new:2", null, null) + new ContainerUpdate { ContainerName = "web", PackageReference = "web-pkg" } }; - var request = Build(template, updates); + var request = Build(template, updates, Variables(("web-pkg", "new:2"))); template.ContainerDefinitions[0].Image.Should().Be("old:1"); request.ContainerDefinitions[0].Image.Should().Be("new:2"); @@ -40,14 +57,15 @@ public void AppliesTargetFamilyAndTags() var template = Template(new ContainerDefinition { Name = "web", Image = "old:1" }); var updates = new[] { - new EcsContainerUpdate("web", "new:2", null, null) + new ContainerUpdate { ContainerName = "web", PackageReference = "web-pkg" } }; var request = RegisterTaskDefinitionRequestFactory.FromTaskDefinition( template, targetFamily: "different-fam", updates, - tags: [new KeyValuePair("Owner", "platform")]); + tags: [new KeyValuePair("Owner", "platform")], + Variables(("web-pkg", "new:2"))); request.Family.Should().Be("different-fam"); request.Tags.Should().ContainSingle().Which.Key.Should().Be("Owner"); @@ -59,13 +77,41 @@ public void NoMatchingContainerThrows() var template = Template(new ContainerDefinition { Name = "web" }); var updates = new[] { - new EcsContainerUpdate("api", "x:1", null, null) + new ContainerUpdate { ContainerName = "api", PackageReference = "api-pkg" } }; - var act = () => Build(template, updates); + var act = () => Build(template, updates, Variables(("api-pkg", "x:1"))); act.Should().Throw().WithMessage("*No matching container*"); } + [Test] + public void PreservesExistingImageWhenPackageReferenceEmpty() + { + var template = Template(new ContainerDefinition { Name = "web", Image = "old:1" }); + var updates = new[] + { + new ContainerUpdate { ContainerName = "web", PackageReference = null } + }; + + var request = Build(template, updates, Variables(("web-pkg", "should-not-be-used"))); + + request.ContainerDefinitions[0].Image.Should().Be("old:1"); + } + + [Test] + public void PreservesExistingImageWhenImageVariableMissing() + { + var template = Template(new ContainerDefinition { Name = "web", Image = "old:1" }); + var updates = new[] + { + new ContainerUpdate { ContainerName = "web", PackageReference = "web-pkg" } + }; + + var request = Build(template, updates); + + request.ContainerDefinitions[0].Image.Should().Be("old:1"); + } + [Test] public void EnvVarsReplaceOverwritesEntireSet() { @@ -76,9 +122,15 @@ public void EnvVarsReplaceOverwritesEntireSet() }); var updates = new[] { - new EcsContainerUpdate("web", null, - new EnvAction(EnvActionMode.Replace, [new EnvVarItem(EnvVarType.Text, "NEW", "1")]), - null) + new ContainerUpdate + { + ContainerName = "web", + EnvironmentVariables = new EnvAction + { + Action = EnvActionMode.Replace, + Items = [new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "NEW", Value = "1" }] + } + } }; var request = Build(template, updates); @@ -88,7 +140,7 @@ public void EnvVarsReplaceOverwritesEntireSet() } [Test] - public void EnvVarsMergePrefersNewValueOnKeyCollision() + public void EnvVarsAppendPrefersNewValueOnKeyCollision() { var template = Template(new ContainerDefinition { @@ -97,9 +149,15 @@ public void EnvVarsMergePrefersNewValueOnKeyCollision() }); var updates = new[] { - new EcsContainerUpdate("web", null, - new EnvAction(EnvActionMode.Merge, [new EnvVarItem(EnvVarType.Text, "K", "new")]), - null) + new ContainerUpdate + { + ContainerName = "web", + EnvironmentVariables = new EnvAction + { + Action = EnvActionMode.Append, + Items = [new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "K", Value = "new" }] + } + } }; var request = Build(template, updates); @@ -113,9 +171,15 @@ public void SecretsReplaceMapsValueFromCorrectly() var template = Template(new ContainerDefinition { Name = "web" }); var updates = new[] { - new EcsContainerUpdate("web", null, - new EnvAction(EnvActionMode.Replace, [new EnvVarItem(EnvVarType.Secret, "S", "arn:aws:ssm:::parameter/x")]), - null) + new ContainerUpdate + { + ContainerName = "web", + EnvironmentVariables = new EnvAction + { + Action = EnvActionMode.Replace, + Items = [new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "S", Value = "arn:aws:ssm:::parameter/x" }] + } + } }; var request = Build(template, updates); @@ -126,7 +190,7 @@ public void SecretsReplaceMapsValueFromCorrectly() } [Test] - public void EnvFilesMergeDedupesByValue() + public void EnvFilesAppendDedupesByValue() { var template = Template(new ContainerDefinition { @@ -135,8 +199,15 @@ public void EnvFilesMergeDedupesByValue() }); var updates = new[] { - new EcsContainerUpdate("web", null, null, - new EnvAction(EnvActionMode.Merge, ["arn:aws:s3:::bucket/file"])) + new ContainerUpdate + { + ContainerName = "web", + EnvironmentFiles = new EnvAction + { + Action = EnvActionMode.Append, + Items = ["arn:aws:s3:::bucket/file"] + } + } }; var request = Build(template, updates); diff --git a/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs index a90316c13..4df52d940 100644 --- a/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs +++ b/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Amazon; @@ -8,15 +9,14 @@ using Amazon.Runtime; using Calamari.Aws.Commands; using Calamari.Aws.Deployment; -using Calamari.Aws.Integration.Ecs; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Variables; -using Calamari.Serialization; using Calamari.Testing; using Calamari.Testing.Helpers; using FluentAssertions; using Newtonsoft.Json; using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; using Task = System.Threading.Tasks.Task; namespace Calamari.Tests.AWS.Ecs.Update; @@ -103,8 +103,8 @@ public async Task FailsWhenTargetTaskDefinitionMissing() var variables = await CreateVariables(serviceName: $"unused-{unique}", newImage: "public.ecr.aws/docker/library/nginx:1.28-alpine"); // Default behavior collapses TemplateTaskDefinitionName to TargetTaskDefinitionName when // the former is empty — so we set both explicitly: a known-good template, a known-missing target. - variables.Set(AwsSpecialVariables.Ecs.TemplateTaskDefinitionName, TaskDefinitionFamily); - variables.Set(AwsSpecialVariables.Ecs.TargetTaskDefinitionName, missingTarget); + variables.Set(AwsSpecialVariables.Ecs.Update.TemplateTaskDefinitionName, TaskDefinitionFamily); + variables.Set(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName, missingTarget); var log = new InMemoryLog(); var command = new UpdateEcsServiceCommand(log, variables); @@ -135,21 +135,36 @@ static async Task CreateVariables(string serviceName, string newImag variables.Set("Octopus.Action.Name", "Update ECS"); variables.Set(AwsSpecialVariables.Ecs.ClusterName, ClusterName); - variables.Set(AwsSpecialVariables.Ecs.ServiceName, serviceName); - variables.Set(AwsSpecialVariables.Ecs.TargetTaskDefinitionName, TaskDefinitionFamily); + variables.Set(AwsSpecialVariables.Ecs.Update.ServiceName, serviceName); + variables.Set(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName, TaskDefinitionFamily); + + const string packageReference = "web"; + variables.Set(PackageVariables.IndexedImage(packageReference), newImage); - var environment = new EnvAction(EnvActionMode.Replace, - [ - new EnvVarItem(EnvVarType.Text, "LOG_LEVEL", "info"), - new EnvVarItem(EnvVarType.Secret, "DB_PASSWORD", "arn:aws:ssm:us-east-1:017645897735:parameter/calamari-ecs-integration-tests-fake") - ]); var containers = new[] { - new EcsContainerUpdate("web", newImage, environment, null) + new ContainerUpdate + { + ContainerName = "web", + PackageReference = packageReference, + EnvironmentVariables = new EnvAction + { + Action = EnvActionMode.Replace, + Items = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "LOG_LEVEL", Value = "info" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "DB_PASSWORD", Value = "arn:aws:ssm:us-east-1:017645897735:parameter/calamari-ecs-integration-tests-fake" } + ] + } + } }; - variables.Set(AwsSpecialVariables.Ecs.Containers, JsonConvert.SerializeObject(containers, JsonSerialization.GetDefaultSerializerSettings())); + variables.Set(AwsSpecialVariables.Ecs.Update.ContainerUpdates, JsonConvert.SerializeObject(containers, CalamariContractSerializationSettings.Default)); + + var tags = new[] { new KeyValuePair("Environment", "Test") }; + variables.Set(AwsSpecialVariables.ResourceTags, JsonConvert.SerializeObject(tags, CalamariContractSerializationSettings.Default)); - variables.Set(AwsSpecialVariables.Ecs.WaitOption.Type, "dontWait"); + var waitOption = new WaitOption { Type = WaitType.DontWait }; + variables.Set(AwsSpecialVariables.Ecs.WaitOption, JsonConvert.SerializeObject(waitOption, CalamariContractSerializationSettings.Default)); return variables; }