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;
}