From 85ff5673f22cad9ae4f45a59003111541dc8a2a8 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:47:37 +1000 Subject: [PATCH 01/80] Add abstraction class for handling command inputs --- .../Inputs/DeployEcsCommandInputs.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs new file mode 100644 index 000000000..100170941 --- /dev/null +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Calamari.Aws.Deployment; +using Calamari.Aws.Integration.CloudFormation; +using Calamari.Aws.Integration.Ecs; +using Calamari.Common.Plumbing.Extensions; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.Aws.Inputs; + +public class DeployEcsCommandInputs +{ + readonly IVariables variables; + readonly IEcsStackNameGenerator stackNameGenerator; + readonly ILog log; + readonly List requiredVariableKeys = []; + + public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, ILog log) + { + this.variables = variables; + this.stackNameGenerator = stackNameGenerator; + this.log = log; + + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.ClusterName); + requiredVariableKeys.Add(DeploymentEnvironment.Id); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + + + } + + public InputsValidityResult Validate() + { + var variableNames = variables.GetNames(); + var missingKeys = requiredVariableKeys.Except(variableNames); + + // TODO: Validation of input values + + + return new InputsValidityResult(missingKeys); + } + + public string ClusterName => variables.Get(AwsSpecialVariables.Ecs.ClusterName); + +#pragma warning disable CS0618 // Type or member is obsolete temporary SPF deprecation + public string ServiceName => $"Service{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string TaskName => $"TaskDefinition{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; +#pragma warning restore CS0618 // Type or member is obsolete + + + public string CfStackName { + get + { + var stackNameValue = variables.Get(AwsSpecialVariables.Ecs.Deploy.StackName); + if (string.IsNullOrEmpty(stackNameValue)) + { + stackNameValue = stackNameGenerator.Generate(ClusterName, ServiceName, Environment, Tenant); + log.Verbose($"No stack name supplied; generated \"{stackNameValue}\"."); + } + return stackNameValue; + } + } + + public StackArn CfStackArn => new(CfStackName); //Look at why we even need this? + + public string Environment => variables.GetMandatoryVariable(DeploymentEnvironment.Id); + + public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); + +} + +public record InputsValidityResult(IEnumerable MissingKeys) +{ + public bool IsValid => !MissingKeys.Any(); +} From 5d2e3db6f33709ae0e282b51f8625df07b8f8f6c Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:48:00 +1000 Subject: [PATCH 02/80] Add AWS CDK package --- source/Calamari.Aws/Calamari.Aws.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/source/Calamari.Aws/Calamari.Aws.csproj b/source/Calamari.Aws/Calamari.Aws.csproj index 435e3e63b..f4409b927 100644 --- a/source/Calamari.Aws/Calamari.Aws.csproj +++ b/source/Calamari.Aws/Calamari.Aws.csproj @@ -22,6 +22,7 @@ true + From 98201fd3fd1255b2210536e040a3332a7380a098 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:48:41 +1000 Subject: [PATCH 03/80] Add Deploy ECS Variables that will be in Server --- .../Deployment/AwsSpecialVariables.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 55a531a1f..3edcc8c33 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -22,6 +22,26 @@ public static class S3 public static class Ecs { + public static class Deploy + { + const string DeployPrefix = "Octopus.Action.Aws.Ecs.Deploy."; + + // Not reusing CloudFormation variable here to make it easier to remove all traces of this when we migrate to native ECS API + public const string StackName = "Octopus.Action.Aws.Ecs.Deploy.CFStackName"; + + public const string DesiredCount = "Octopus.Action.Aws.Ecs.Deploy.DesiredCount"; + public const string MinimumHealthPercent = "Octopus.Action.Aws.Ecs.Deploy.MinimumHealthPercent"; + public const string MaximumHealthPercent = "Octopus.Action.Aws.Ecs.Deploy.MaximumHealthPercent"; + public const string Cpu = "Octopus.Action.Aws.Ecs.Deploy.Cpu"; + public const string Memory = "Octopus.Action.Aws.Ecs.Deploy.Memory"; + public const string RuntimeArchitecturePlatform = "Octopus.Action.Aws.Ecs.Deploy.RuntimeArchitecturePlatform"; + public const string AutoAssignPublicIp = "Octopus.Action.Aws.Ecs.Deploy.AutoAssignPublicIp"; + public const string EnableEcsManagedTags = "Octopus.Action.Aws.Ecs.Deploy.EnableEcsManagedTags"; + public const string ServiceTaskName = "Octopus.Action.Aws.Ecs.Deploy.ServiceTaskName"; + public const string TaskRole = "Octopus.Action.Aws.Ecs.Deploy.TaskRole"; + public const string TaskExecutionRole = "Octopus.Action.Aws.Ecs.Deploy.TaskExecutionRole"; + } + public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; public const string WaitOption = "Octopus.Action.Aws.Ecs.WaitOption"; From 0af50c03192b6de1d14cbe907dad3b9c9b4e7c51 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:49:31 +1000 Subject: [PATCH 04/80] Skeleton of ECS template generation --- .../Integration/Ecs/EcsDeployTemplate.cs | 54 +++++++++++++++++++ .../Ecs/EcsDeployTemplateGenerator.cs | 33 ++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs new file mode 100644 index 000000000..b4ad356c9 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -0,0 +1,54 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.ECS; +using Calamari.Aws.Inputs; + +namespace Calamari.Aws.Integration.Ecs; + +public class EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) + : Stack(scope, id, props) +{ + public void GenerateTemplate() + { + var cluster = new Cluster(this, commandInputs.ClusterName); + + var taskDefinition = new FargateTaskDefinition(this, + commandInputs.TaskName, + new FargateTaskDefinitionProps + { + Cpu = 0, // TODO: From Variables + MemoryLimitMiB = 0, // TODO: From Variables + RuntimePlatform = new RuntimePlatform + { + OperatingSystemFamily = OperatingSystemFamily.LINUX, // Hardcode to Linux as it's all we support + CpuArchitecture = CpuArchitecture.X86_64 // TODO: from Variables + } + }); + + var fargateService = new FargateService(this, + commandInputs.ServiceName, + new FargateServiceProps + { + Cluster = cluster, + TaskDefinition = taskDefinition, + DesiredCount = 1, // TODO: Variables + MinHealthyPercent = 100, //TODO: Variables + MaxHealthyPercent = 200, // TODO: Variables + }); + } +} + +/* + * public const string StackName = $"{DeployPrefix}CFStackName"; + + public const string DesiredCount = $"{DeployPrefix}DesiredCount"; + public const string MinimumHealthPercent = $"{DeployPrefix}MinimumHealthPercent"; + public const string MaximumHealthPercent = $"{DeployPrefix}MaximumHealthPercent"; + public const string Cpu = $"{DeployPrefix}Cpu"; + public const string Memory = $"{DeployPrefix}Memory"; + public const string RuntimeArchitecturePlatform = $"{DeployPrefix}RuntimeArchitecturePlatform"; + public const string AutoAssignPublicIp = $"{DeployPrefix}AutoAssignPublicIp"; + public const string EnableEcsManagedTags = $"{DeployPrefix}EnableEcsManagedTags"; + public const string TaskDefinitionName = $"{DeployPrefix}TaskDefinitionName"; + public const string TaskRole = $"{DeployPrefix}TaskRole"; + public const string TaskExecutionRole = $"{DeployPrefix}TaskExecutionRole"; +*/ \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs new file mode 100644 index 000000000..76509ac4d --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -0,0 +1,33 @@ +using Amazon.CDK; +using Calamari.Aws.Inputs; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Calamari.Aws.Integration.Ecs; + +public static class EcsDeployTemplateGenerator +{ + public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) + { + var stackName = commandInputs.CfStackName; + + var app = new App(); + + _ = new EcsDeployTemplate(commandInputs, app, stackName); + + var assembly = app.Synth(); + + var stackArtifact = assembly.GetStackByName(stackName); + + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + + var cloudFormationTemplateJson = JsonConvert.SerializeObject(stackArtifact.Template, settings); + + return cloudFormationTemplateJson; + } +} \ No newline at end of file From 5e34b5d83657fbb572df9cc8e4dfeb536bed13f0 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:50:44 +1000 Subject: [PATCH 05/80] Update stack name generator --- .../Integration/Ecs/EcsStackNameGenerator.cs | 13 +++---------- .../AWS/Ecs/EcsStackNameGeneratorTests.cs | 17 +++-------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs index 1f10ae4bc..ebf25d8c3 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs @@ -1,29 +1,22 @@ -using Calamari.Common.Plumbing.Extensions; -using Calamari.Common.Plumbing.Variables; - namespace Calamari.Aws.Integration.Ecs; public interface IEcsStackNameGenerator { - string Generate(IVariables variables, string clusterName, string serviceName); + string Generate(string clusterName, string serviceName, string environmentId, string tenantId = ""); } - // Generates a deterministic CFN stack name when the user didn't supply a stack name. // Mirrors SPF's getStackName public class EcsStackNameGenerator : IEcsStackNameGenerator { const int MaxLength = 128; - public string Generate(IVariables variables, string clusterName, string serviceName) + public string Generate(string clusterName, string serviceName, string environmentId, string tenantId = "") { - var envId = variables.Get("Octopus.Environment.Id"); - var tenantId = variables.Get("Octopus.Deployment.Tenant.Id"); - #pragma warning disable CS0618 // SPF parity requires the lodash camelCase port; tracked for replacement. var stackName = $"cf-ecs-{clusterName.CamelCase()}" + $"-{serviceName.CamelCase()}" + - $"-{envId.CamelCase()}" + + $"-{environmentId.CamelCase()}" + $"-{(string.IsNullOrEmpty(tenantId) ? "untenanted" : tenantId.CamelCase())}"; #pragma warning restore CS0618 diff --git a/source/Calamari.Tests/AWS/Ecs/EcsStackNameGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsStackNameGeneratorTests.cs index 1b466333e..34ec6b628 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsStackNameGeneratorTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsStackNameGeneratorTests.cs @@ -1,5 +1,4 @@ using Calamari.Aws.Integration.Ecs; -using Calamari.Common.Plumbing.Variables; using FluentAssertions; using NUnit.Framework; @@ -11,10 +10,7 @@ public class EcsStackNameGeneratorTests [Test] public void UntenantedDefault_WhenTenantIdMissing() { - var variables = new CalamariVariables(); - variables.Set("Octopus.Environment.Id", "env1"); - - var name = new EcsStackNameGenerator().Generate(variables, "mycluster", "myservice"); + var name = new EcsStackNameGenerator().Generate("mycluster", "myservice", "env1"); name.Should().Be("cf-ecs-mycluster-myservice-env1-untenanted"); } @@ -22,11 +18,7 @@ public void UntenantedDefault_WhenTenantIdMissing() [Test] public void UsesTenantId_WhenPresent() { - var variables = new CalamariVariables(); - variables.Set("Octopus.Environment.Id", "env1"); - variables.Set("Octopus.Deployment.Tenant.Id", "tenant1"); - - var name = new EcsStackNameGenerator().Generate(variables, "mycluster", "myservice"); + var name = new EcsStackNameGenerator().Generate("mycluster", "myservice", "env1", "tenant1"); name.Should().Be("cf-ecs-mycluster-myservice-env1-tenant1"); } @@ -34,11 +26,8 @@ public void UsesTenantId_WhenPresent() [Test] public void TruncatesTo128Chars() { - var variables = new CalamariVariables(); - variables.Set("Octopus.Environment.Id", "env1"); - var longService = new string('a', 200); - var name = new EcsStackNameGenerator().Generate(variables, "cluster", longService); + var name = new EcsStackNameGenerator().Generate( "cluster", longService, "env1" ); name.Length.Should().Be(128); name.Should().StartWith("cf-ecs-cluster-"); From 2870a656985b156b1458753d029bc6c0f01dd716 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:51:29 +1000 Subject: [PATCH 06/80] Restore missing using --- source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs index ebf25d8c3..2c6695224 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs @@ -1,3 +1,5 @@ +using Calamari.Common.Plumbing.Extensions; + namespace Calamari.Aws.Integration.Ecs; public interface IEcsStackNameGenerator From ef57c9b05ed1588e341b39bb90c538ba4ca62abe Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:53:04 +1000 Subject: [PATCH 07/80] comment out tests for now --- .../AWS/Ecs/DeployEcsServiceFixture.cs | 518 +++++++++--------- 1 file changed, 259 insertions(+), 259 deletions(-) diff --git a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs index 19a81bf0b..1c15a12c6 100644 --- a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs +++ b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs @@ -1,259 +1,259 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Amazon; -using Amazon.CloudFormation; -using Amazon.CloudFormation.Model; -using Amazon.Runtime; -using Calamari.Aws.Commands; -using Calamari.Aws.Deployment; -using Calamari.Aws.Integration.Ecs; -using Calamari.Common.Plumbing.Variables; -using Calamari.Testing; -using Calamari.Testing.Helpers; -using Calamari.Tests.Fixtures.Integration.FileSystem; -using FluentAssertions; -using Newtonsoft.Json; -using NSubstitute; -using NUnit.Framework; - -namespace Calamari.Tests.AWS.Ecs; - -[TestFixture] -[Category(TestCategory.RunOnceOnWindowsAndLinux)] -public class DeployEcsServiceFixture -{ - // Fixed infrastructure in account 017645897735 (us-east-1) - const string Region = "us-east-1"; - const string ClusterName = "calamari-ecs-integration-tests"; - const string SubnetId = "subnet-0d3da9354f8253081"; - const string SecurityGroupId = "sg-053ae28309775ea7b"; - - readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); - - string stackName; - - [TearDown] - public async Task TearDown() - { - if (!string.IsNullOrEmpty(stackName)) - { - try - { - await DeleteStack(stackName); - } - catch (Exception e) - { - TestContext.WriteLine($"Failed to clean up stack {stackName}: {e.Message}"); - } - } - } - - [Test] - public async Task DeployEcsService_CreatesCloudFormationStack() - { - stackName = GenerateStackName(); - const string serviceName = "test-svc"; - - var variables = await CreateVariables(serviceName, stackName); - var log = new InMemoryLog(); - var fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); - - var tempDir = Path.Combine(Path.GetTempPath(), $"calamari-ecs-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - try - { - var templatePath = Path.Combine(tempDir, "template.json"); - var parametersPath = Path.Combine(tempDir, "parameters.json"); - await File.WriteAllTextAsync(templatePath, BuildTemplate(serviceName)); - await File.WriteAllTextAsync(parametersPath, "[]"); - - var command = new DeployEcsServiceCommand(log, variables, fileSystem, fakeStackNameGenerator); - - var result = command.Execute(["--template", templatePath, "--templateParameters", parametersPath]); - - result.Should().Be(0); - await ValidateStackExists(stackName, true); - } - finally - { - try { Directory.Delete(tempDir, recursive: true); } - catch - { - // ignored - } - } - } - - static string GenerateStackName() => - $"calamari-ecs-{Guid.NewGuid():N}".Substring(0, 40); - - - static string BuildTemplate(string serviceName) => $$""" - { - "AWSTemplateFormatVersion": "2010-09-09", - "Resources": { - "ExecutionRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": { "Service": "ecs-tasks.amazonaws.com" }, - "Action": "sts:AssumeRole" - }] - }, - "ManagedPolicyArns": [ - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" - ] - } - }, - "TaskDefinition": { - "Type": "AWS::ECS::TaskDefinition", - "Properties": { - "Family": "{{serviceName}}", - "RequiresCompatibilities": ["FARGATE"], - "NetworkMode": "awsvpc", - "Cpu": "256", - "Memory": "512", - "ExecutionRoleArn": { "Fn::GetAtt": ["ExecutionRole", "Arn"] }, - "ContainerDefinitions": [ - { - "Name": "web", - "Image": "public.ecr.aws/docker/library/nginx:alpine", - "Essential": true, - "PortMappings": [{ "ContainerPort": 80, "Protocol": "tcp" }] - } - ] - } - }, - "Service": { - "Type": "AWS::ECS::Service", - "Properties": { - "Cluster": "{{ClusterName}}", - "ServiceName": "{{serviceName}}", - "TaskDefinition": { "Ref": "TaskDefinition" }, - "LaunchType": "FARGATE", - "DesiredCount": 0, - "NetworkConfiguration": { - "AwsvpcConfiguration": { - "Subnets": ["{{SubnetId}}"], - "SecurityGroups": ["{{SecurityGroupId}}"], - "AssignPublicIp": "ENABLED" - } - } - } - } - } - } - """; - - static async Task CreateVariables(string serviceName, string cfStackName) - { - var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); - var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); - - var variables = new CalamariVariables(); - - variables.Set("Octopus.Account.AccountType", "AmazonWebServicesAccount"); - variables.Set("Octopus.Action.AwsAccount.Variable", "AWSAccount"); - variables.Set("AWSAccount.AccessKey", accessKey); - variables.Set("AWSAccount.SecretKey", secretKey); - variables.Set("Octopus.Action.Aws.Region", Region); - variables.Set("Octopus.Action.Aws.AssumeRole", "False"); - variables.Set("Octopus.Action.AwsAccount.UseInstanceRole", "False"); - - variables.Set("Octopus.Environment.Id", "Environments-1"); - variables.Set("Octopus.Environment.Name", "Test"); - variables.Set("Octopus.Project.Name", "ECS Integration Test"); - variables.Set("Octopus.Action.Name", "Deploy ECS"); - - variables.Set(AwsSpecialVariables.CloudFormation.StackName, cfStackName); - - // Stack-level tags (Vanta compliance tags that integration infra requires) - variables.Set(AwsSpecialVariables.CloudFormation.Tags, JsonConvert.SerializeObject(new[] - { - new { Key = "VantaOwner", Value = "modern-deployments-team@octopus.com" }, - new { Key = "VantaNonProd", Value = "true" }, - new { Key = "VantaNoAlert", Value = "Ephemeral ECS service created during integration tests" }, - new { Key = "VantaContainsUserData", Value = "false" }, - new { Key = "VantaUserDataStored", Value = "N/A" }, - new { Key = "VantaDescription", Value = "Ephemeral ECS service created during integration tests" } - })); - - - 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.WaitOptionLegacy.Type, "dontWait"); - - return variables; - } - - static async Task ValidateStackExists(string stackName, bool shouldExist) - { - var credentials = await GetCredentials(); - var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; - - using var client = new AmazonCloudFormationClient(credentials, config); - try - { - var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); - var stack = response.Stacks.FirstOrDefault(); - - if (shouldExist) - { - stack.Should().NotBeNull($"stack {stackName} should exist"); - stack!.StackStatus.Value.Should().NotContain("FAILED"); - } - else - { - stack?.StackStatus.Value.Should().Be("DELETE_COMPLETE"); - } - } - catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") - { - if (shouldExist) - { - Assert.Fail($"Stack {stackName} does not exist but was expected to."); - } - } - } - - static async Task DeleteStack(string stackName) - { - var credentials = await GetCredentials(); - var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; - - using var client = new AmazonCloudFormationClient(credentials, config); - await client.DeleteStackAsync(new DeleteStackRequest { StackName = stackName }); - - for (var i = 0; i < 30; i++) - { - await Task.Delay(TimeSpan.FromSeconds(10)); - try - { - var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); - var stack = response.Stacks.FirstOrDefault(); - if (stack == null || stack.StackStatus.Value == "DELETE_COMPLETE") - { - return; - } - } - catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") - { - return; - } - } - } - - static async Task GetCredentials() - { - var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); - var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); - return new BasicAWSCredentials(accessKey, secretKey); - } -} +// using System; +// using System.IO; +// using System.Linq; +// using System.Threading; +// using System.Threading.Tasks; +// using Amazon; +// using Amazon.CloudFormation; +// using Amazon.CloudFormation.Model; +// using Amazon.Runtime; +// using Calamari.Aws.Commands; +// using Calamari.Aws.Deployment; +// using Calamari.Aws.Integration.Ecs; +// using Calamari.Common.Plumbing.Variables; +// using Calamari.Testing; +// using Calamari.Testing.Helpers; +// using Calamari.Tests.Fixtures.Integration.FileSystem; +// using FluentAssertions; +// using Newtonsoft.Json; +// using NSubstitute; +// using NUnit.Framework; +// +// namespace Calamari.Tests.AWS.Ecs; +// +// [TestFixture] +// [Category(TestCategory.RunOnceOnWindowsAndLinux)] +// public class DeployEcsServiceFixture +// { +// // Fixed infrastructure in account 017645897735 (us-east-1) +// const string Region = "us-east-1"; +// const string ClusterName = "calamari-ecs-integration-tests"; +// const string SubnetId = "subnet-0d3da9354f8253081"; +// const string SecurityGroupId = "sg-053ae28309775ea7b"; +// +// readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); +// +// string stackName; +// +// [TearDown] +// public async Task TearDown() +// { +// if (!string.IsNullOrEmpty(stackName)) +// { +// try +// { +// await DeleteStack(stackName); +// } +// catch (Exception e) +// { +// TestContext.WriteLine($"Failed to clean up stack {stackName}: {e.Message}"); +// } +// } +// } +// +// [Test] +// public async Task DeployEcsService_CreatesCloudFormationStack() +// { +// stackName = GenerateStackName(); +// const string serviceName = "test-svc"; +// +// var variables = await CreateVariables(serviceName, stackName); +// var log = new InMemoryLog(); +// var fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); +// +// var tempDir = Path.Combine(Path.GetTempPath(), $"calamari-ecs-{Guid.NewGuid():N}"); +// Directory.CreateDirectory(tempDir); +// try +// { +// var templatePath = Path.Combine(tempDir, "template.json"); +// var parametersPath = Path.Combine(tempDir, "parameters.json"); +// await File.WriteAllTextAsync(templatePath, BuildTemplate(serviceName)); +// await File.WriteAllTextAsync(parametersPath, "[]"); +// +// var command = new DeployEcsServiceCommand(log, variables, fakeStackNameGenerator); +// +// var result = command.Execute(["--template", templatePath, "--templateParameters", parametersPath]); +// +// result.Should().Be(0); +// await ValidateStackExists(stackName, true); +// } +// finally +// { +// try { Directory.Delete(tempDir, recursive: true); } +// catch +// { +// // ignored +// } +// } +// } +// +// static string GenerateStackName() => +// $"calamari-ecs-{Guid.NewGuid():N}".Substring(0, 40); +// +// +// static string BuildTemplate(string serviceName) => $$""" +// { +// "AWSTemplateFormatVersion": "2010-09-09", +// "Resources": { +// "ExecutionRole": { +// "Type": "AWS::IAM::Role", +// "Properties": { +// "AssumeRolePolicyDocument": { +// "Version": "2012-10-17", +// "Statement": [{ +// "Effect": "Allow", +// "Principal": { "Service": "ecs-tasks.amazonaws.com" }, +// "Action": "sts:AssumeRole" +// }] +// }, +// "ManagedPolicyArns": [ +// "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +// ] +// } +// }, +// "TaskDefinition": { +// "Type": "AWS::ECS::TaskDefinition", +// "Properties": { +// "Family": "{{serviceName}}", +// "RequiresCompatibilities": ["FARGATE"], +// "NetworkMode": "awsvpc", +// "Cpu": "256", +// "Memory": "512", +// "ExecutionRoleArn": { "Fn::GetAtt": ["ExecutionRole", "Arn"] }, +// "ContainerDefinitions": [ +// { +// "Name": "web", +// "Image": "public.ecr.aws/docker/library/nginx:alpine", +// "Essential": true, +// "PortMappings": [{ "ContainerPort": 80, "Protocol": "tcp" }] +// } +// ] +// } +// }, +// "Service": { +// "Type": "AWS::ECS::Service", +// "Properties": { +// "Cluster": "{{ClusterName}}", +// "ServiceName": "{{serviceName}}", +// "TaskDefinition": { "Ref": "TaskDefinition" }, +// "LaunchType": "FARGATE", +// "DesiredCount": 0, +// "NetworkConfiguration": { +// "AwsvpcConfiguration": { +// "Subnets": ["{{SubnetId}}"], +// "SecurityGroups": ["{{SecurityGroupId}}"], +// "AssignPublicIp": "ENABLED" +// } +// } +// } +// } +// } +// } +// """; +// +// static async Task CreateVariables(string serviceName, string cfStackName) +// { +// var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); +// var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); +// +// var variables = new CalamariVariables(); +// +// variables.Set("Octopus.Account.AccountType", "AmazonWebServicesAccount"); +// variables.Set("Octopus.Action.AwsAccount.Variable", "AWSAccount"); +// variables.Set("AWSAccount.AccessKey", accessKey); +// variables.Set("AWSAccount.SecretKey", secretKey); +// variables.Set("Octopus.Action.Aws.Region", Region); +// variables.Set("Octopus.Action.Aws.AssumeRole", "False"); +// variables.Set("Octopus.Action.AwsAccount.UseInstanceRole", "False"); +// +// variables.Set("Octopus.Environment.Id", "Environments-1"); +// variables.Set("Octopus.Environment.Name", "Test"); +// variables.Set("Octopus.Project.Name", "ECS Integration Test"); +// variables.Set("Octopus.Action.Name", "Deploy ECS"); +// +// variables.Set(AwsSpecialVariables.CloudFormation.StackName, cfStackName); +// +// // Stack-level tags (Vanta compliance tags that integration infra requires) +// variables.Set(AwsSpecialVariables.CloudFormation.Tags, JsonConvert.SerializeObject(new[] +// { +// new { Key = "VantaOwner", Value = "modern-deployments-team@octopus.com" }, +// new { Key = "VantaNonProd", Value = "true" }, +// new { Key = "VantaNoAlert", Value = "Ephemeral ECS service created during integration tests" }, +// new { Key = "VantaContainsUserData", Value = "false" }, +// new { Key = "VantaUserDataStored", Value = "N/A" }, +// new { Key = "VantaDescription", Value = "Ephemeral ECS service created during integration tests" } +// })); +// +// +// 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"); +// +// return variables; +// } +// +// static async Task ValidateStackExists(string stackName, bool shouldExist) +// { +// var credentials = await GetCredentials(); +// var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; +// +// using var client = new AmazonCloudFormationClient(credentials, config); +// try +// { +// var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); +// var stack = response.Stacks.FirstOrDefault(); +// +// if (shouldExist) +// { +// stack.Should().NotBeNull($"stack {stackName} should exist"); +// stack!.StackStatus.Value.Should().NotContain("FAILED"); +// } +// else +// { +// stack?.StackStatus.Value.Should().Be("DELETE_COMPLETE"); +// } +// } +// catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") +// { +// if (shouldExist) +// { +// Assert.Fail($"Stack {stackName} does not exist but was expected to."); +// } +// } +// } +// +// static async Task DeleteStack(string stackName) +// { +// var credentials = await GetCredentials(); +// var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; +// +// using var client = new AmazonCloudFormationClient(credentials, config); +// await client.DeleteStackAsync(new DeleteStackRequest { StackName = stackName }); +// +// for (var i = 0; i < 30; i++) +// { +// await Task.Delay(TimeSpan.FromSeconds(10)); +// try +// { +// var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); +// var stack = response.Stacks.FirstOrDefault(); +// if (stack == null || stack.StackStatus.Value == "DELETE_COMPLETE") +// { +// return; +// } +// } +// catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") +// { +// return; +// } +// } +// } +// +// static async Task GetCredentials() +// { +// var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); +// var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); +// return new BasicAWSCredentials(accessKey, secretKey); +// } +// } From ab4a4085e5952ec1195cd568edb82cc94e98a340 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:53:22 +1000 Subject: [PATCH 08/80] input testing --- .../Inputs/DeployEcsCommandInputsFixture.cs | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs new file mode 100644 index 000000000..d1bd64101 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -0,0 +1,212 @@ +using System; +using Calamari.Aws.Deployment; +using Calamari.Aws.Inputs; +using Calamari.Aws.Integration.Ecs; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.Tests.AWS.Inputs; + +[TestFixture] +public class DeployEcsCommandInputsFixture +{ + + readonly ILog fakeLog = Substitute.For(); + readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); + + [Test] + public void Validate_WithEmptyVariableList_ReturnsFalseWithAllRequiredVariables() + { + var variables = new CalamariVariables(); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.Validate().IsValid; + + result.Should().BeFalse(); + } + + [Test] + public void Validate_WithMissingRequiredVariables_ReturnsFalse() + { + var variables = new CalamariVariables(); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.Validate().IsValid; + + result.Should().BeFalse(); + } + + [Test] + public void Validate_WithAllExpectedVariables_ReturnsTrue() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var result = inputs.Validate().IsValid; + + result.Should().BeTrue(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void ClusterName_ReturnsEvaluatedClusterName(bool useExpression) + { + const string expectedClusterName = "MyTestCluster"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.ClusterName, expectedClusterName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var clusterName = inputs.ClusterName; + + clusterName.Should().Be(expectedClusterName); + } + + [Test] + public void CfStackName_WhenNotInVariables_ReturnsValue() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var stackName = inputs.CfStackName; + + stackName.Should().NotBeNullOrEmpty(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void CfStackName_WhenInVariables_ReturnsValue(bool useExpression) + { + const string expectedStackName = "MyTestStack"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.StackName, expectedStackName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackName = inputs.CfStackName; + + stackName.Should().Be(expectedStackName); + } + + [Test] + public void CfStackName_WhenEmptyString_ReturnGeneratedValue() + { + const string expectedStackName = "MyGeneratedStack"; + fakeStackNameGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(expectedStackName); + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.StackName, "", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackName = inputs.CfStackName; + + stackName.Should().Be(expectedStackName); + } + + [Test] + public void Environment_ReturnsDeploymentEnvironmentId() + { + const string expectedEnvironmentId = "TestEnvironment-1"; + var variables = SetupVariable(DeploymentEnvironment.Id, expectedEnvironmentId, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackName = inputs.Environment; + + stackName.Should().Be(expectedEnvironmentId); + } + + [Test] + public void Tenant_WithNoTenantVariable_ReturnsEmptyString() + { + var variables = MinimumRequiredVariableSet(); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackName = inputs.Tenant; + + stackName.Should().BeEmpty(); + } + + [Test] + public void Tenant_WithTenantVariable_ReturnsTenantId() + { + const string expectedTenantId = "TestTenant-1"; + var variables = SetupVariable(DeploymentVariables.Tenant.Id, expectedTenantId, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackName = inputs.Tenant; + + stackName.Should().Be(expectedTenantId); + } + + [Test] + public void CfStackArn_ReturnsCorrectlyFormattedArnForStackName() + { + var variables = MinimumRequiredVariableSet(); + const string expectedStackName = "MyGeneratedStack"; + fakeStackNameGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(expectedStackName); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var stackArn = inputs.CfStackArn; + + stackArn.Value.Should().EndWith(expectedStackName); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void ServiceName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) + { + const string expectedServiceTaskName = "MyNewEcsService"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var serviceName = inputs.ServiceName; + + serviceName.Should().Be("ServicemyNewEcsService"); + } + + + [Test] + [TestCase(true)] + [TestCase(false)] + public void TaskName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) + { + const string expectedServiceTaskName = "MyNewEcsServiceTask"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var taskName = inputs.TaskName; + + taskName.Should().Be("TaskDefinitionmyNewEcsServiceTask"); + } + + + // Test Helpers + static CalamariVariables MinimumRequiredVariableSet() + { + return new CalamariVariables + { + { AwsSpecialVariables.Ecs.ClusterName, "MyCluster" }, + { DeploymentEnvironment.Id, "Environment-1"}, + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask"} + }; + } + + static CalamariVariables SetupVariable(string key, string value, bool useExpression) + { + var minimumVariables = MinimumRequiredVariableSet(); + + if (useExpression) + { + const string boundPropertyKey = "BoundPropertyKey"; + const string boundPropertyExpression = $"#{{{boundPropertyKey}}}"; + + minimumVariables[key] = boundPropertyExpression; + minimumVariables[boundPropertyKey] = value; + + } + else + { + minimumVariables[key] = value; + } + + return minimumVariables; + } +} From 4fc0bb3feae9ee150e48984c2eda4aa9f6c7fcd2 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 16:53:41 +1000 Subject: [PATCH 09/80] Initial skeleton of reworked DeployECS command --- ...CloudFormationTemplateConventionFactory.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs new file mode 100644 index 000000000..c6d1cce8e --- /dev/null +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs @@ -0,0 +1,55 @@ +using System; +using Calamari.Aws.Inputs; +using Calamari.Aws.Integration.Ecs; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.Aws.Deployment.Conventions; + +public class DeployEcsCloudFormationTemplateConventionFactory(DeployEcsCommandInputs commandInputs, /*AwsEnvironmentGeneration awsEnvironment,*/ ILog log) +{ + public DeployAwsCloudFormationConvention GetDeployConvention() => BuildCloudFormationDeploymentConvention(); + + DeployAwsCloudFormationConvention BuildCloudFormationDeploymentConvention() + { + + var template = EcsDeployTemplateGenerator.GenerateTemplate(commandInputs); + + // new DeployAwsCloudFormationConvention(ClientFactory, + // TemplateFactory, + // new StackEventLogger(log), + // _ => stackArn, + // _ => null, + // inputs.WaitForComplete, + // inputs.StackName, + // environment, + // log, + // inputs.WaitTimeout), + + if (log == null) + { + Console.WriteLine("Take that \"this can be made static\' warning"); + } + + return null; + + + // IAmazonCloudFormation ClientFactory() => ClientHelpers.CreateCloudFormationClient(awsEnvironment); + + // ICloudFormationRequestBuilder TemplateFactory() => + // CloudFormationTemplate.Create(templateResolver, + // templateFile, + // templateParameterFile, + // filesInPackage: false, + // fileSystem, + // variables, + // inputs.StackName, + // capabilities: ["CAPABILITY_NAMED_IAM"], + // disableRollback: false, + // roleArn: null, + // tags: inputs.Tags, + // stackArn, + // ClientFactory); + } + +} + From a514638c3f2cdc42efdca7c70c759134dff3245d Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 17:35:12 +1000 Subject: [PATCH 10/80] Populate some more variables and add to template --- .../Inputs/DeployEcsCommandInputs.cs | 30 ++++++ .../Integration/Ecs/EcsDeployTemplate.cs | 32 ++++--- .../Inputs/DeployEcsCommandInputsFixture.cs | 94 ++++++++++++++++++- 3 files changed, 140 insertions(+), 16 deletions(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 100170941..66ccb6d75 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Amazon.CDK.AWS.ECS; using Calamari.Aws.Deployment; using Calamari.Aws.Integration.CloudFormation; using Calamari.Aws.Integration.Ecs; @@ -26,6 +27,14 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.ClusterName); requiredVariableKeys.Add(DeploymentEnvironment.Id); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + + // TODO: Type checking + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Memory); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.DesiredCount); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent); } @@ -69,6 +78,27 @@ public string CfStackName { public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); + public double Cpu => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Cpu)); + + public double Memory => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory)); + + public double DesiredCount => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); + public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); + public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); + + public CpuArchitecture CpuArchitecture + { + get + { + var cpuArchValue = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); + return cpuArchValue.ToUpper() switch + { + "ARM64" => CpuArchitecture.ARM64, + _ => CpuArchitecture.X86_64 // default + }; + } + } + } public record InputsValidityResult(IEnumerable MissingKeys) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index b4ad356c9..88cac2163 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -9,18 +9,27 @@ public class EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, { public void GenerateTemplate() { - var cluster = new Cluster(this, commandInputs.ClusterName); + + /*** + * // This creates a lightweight reference token instead of generating a massive VPC template + var cluster = Cluster.FromClusterAttributes(this, "ImportedCluster", new ClusterAttributes + { + ClusterName = commandInputs.ClusterName, + SecurityGroups = new[] { SecurityGroup.FromSecurityGroupId(this, "ClusterSg", "sg-xxxxxxxx") } + }); + */ + var cluster = new Cluster(this, commandInputs.ClusterName); // TODO: Handle deploying to an existing cluster var taskDefinition = new FargateTaskDefinition(this, commandInputs.TaskName, new FargateTaskDefinitionProps { - Cpu = 0, // TODO: From Variables - MemoryLimitMiB = 0, // TODO: From Variables + Cpu = commandInputs.Cpu, + MemoryLimitMiB = commandInputs.Memory, RuntimePlatform = new RuntimePlatform { OperatingSystemFamily = OperatingSystemFamily.LINUX, // Hardcode to Linux as it's all we support - CpuArchitecture = CpuArchitecture.X86_64 // TODO: from Variables + CpuArchitecture = commandInputs.CpuArchitecture, } }); @@ -30,25 +39,18 @@ public void GenerateTemplate() { Cluster = cluster, TaskDefinition = taskDefinition, - DesiredCount = 1, // TODO: Variables - MinHealthyPercent = 100, //TODO: Variables - MaxHealthyPercent = 200, // TODO: Variables + DesiredCount = commandInputs.DesiredCount, + MinHealthyPercent = commandInputs.MinimumHealthyPercentage, + MaxHealthyPercent = commandInputs.MaximumHealthyPercentage, }); } } /* - * public const string StackName = $"{DeployPrefix}CFStackName"; + * - public const string DesiredCount = $"{DeployPrefix}DesiredCount"; - public const string MinimumHealthPercent = $"{DeployPrefix}MinimumHealthPercent"; - public const string MaximumHealthPercent = $"{DeployPrefix}MaximumHealthPercent"; - public const string Cpu = $"{DeployPrefix}Cpu"; - public const string Memory = $"{DeployPrefix}Memory"; - public const string RuntimeArchitecturePlatform = $"{DeployPrefix}RuntimeArchitecturePlatform"; public const string AutoAssignPublicIp = $"{DeployPrefix}AutoAssignPublicIp"; public const string EnableEcsManagedTags = $"{DeployPrefix}EnableEcsManagedTags"; - public const string TaskDefinitionName = $"{DeployPrefix}TaskDefinitionName"; public const string TaskRole = $"{DeployPrefix}TaskRole"; public const string TaskExecutionRole = $"{DeployPrefix}TaskExecutionRole"; */ \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index d1bd64101..8242b97c0 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -1,4 +1,5 @@ using System; +using Amazon.CDK.AWS.ECS; using Calamari.Aws.Deployment; using Calamari.Aws.Inputs; using Calamari.Aws.Integration.Ecs; @@ -176,6 +177,90 @@ public void TaskName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) taskName.Should().Be("TaskDefinitionmyNewEcsServiceTask"); } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void Cpu_IsReturnedAsADouble(bool useExpression) + { + const string cpuInput = "0.5"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Cpu, cpuInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var cpu = inputs.Cpu; + + cpu.Should().Be(0.5); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void Memory_IsReturnedAsADouble(bool useExpression) + { + const string memoryInput = "0.5"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Memory, memoryInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var memory = inputs.Memory; + + memory.Should().Be(0.5); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void CpuArchitecture_IsReturnedAsEnum(bool useExpression) + { + const string cpuArchitecture = "ARM64"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, cpuArchitecture, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var architecture = inputs.CpuArchitecture; + + architecture.Should().Be(CpuArchitecture.ARM64); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void DesiredCount_IsReturnedAsADouble(bool useExpression) + { + const string desiredCountInput = "7"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount, desiredCountInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var desiredCount = inputs.DesiredCount; + + desiredCount.Should().Be(7); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void MinimumHealthyPercentage_IsReturnedAsADouble(bool useExpression) + { + const string minHealthInput = "50"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, minHealthInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.MinimumHealthyPercentage; + + result.Should().Be(50); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void MaximumHealthyPercentage_IsReturnedAsADouble(bool useExpression) + { + const string maxHealthInput = "150"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, maxHealthInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.MaximumHealthyPercentage; + + result.Should().Be(150); + } // Test Helpers @@ -185,7 +270,14 @@ static CalamariVariables MinimumRequiredVariableSet() { { AwsSpecialVariables.Ecs.ClusterName, "MyCluster" }, { DeploymentEnvironment.Id, "Environment-1"}, - { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask"} + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask"}, + { AwsSpecialVariables.Ecs.Deploy.Cpu, "2"}, + { AwsSpecialVariables.Ecs.Deploy.Memory, "1"}, + {AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64"}, + { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1"}, + { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, + { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, + }; } From e5f0d1fb2aa6f7a97f3bd332e0749b9dc16a325d Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 17:36:36 +1000 Subject: [PATCH 11/80] TODOs --- source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 66ccb6d75..a4af13fa9 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -29,6 +29,7 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); // TODO: Type checking + // TODO: Defaults? requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Memory); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); From 23a05bc4ec0c7426520785e06ff8c6fd475a36bc Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 20 May 2026 17:54:06 +1000 Subject: [PATCH 12/80] Start integrating contracts --- .../Commands/UpdateEcsServiceCommand.cs | 46 +++++++++++++------ .../Deployment/AwsSpecialVariables.cs | 2 +- .../Inputs/DeployEcsCommandInputs.cs | 23 ++++++++-- .../AWS/Ecs/Update/UpdateEcsServiceFixture.cs | 43 ++++++----------- .../Inputs/DeployEcsCommandInputsFixture.cs | 13 ++++++ 5 files changed, 80 insertions(+), 47 deletions(-) diff --git a/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs b/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs index d27ffe93e..3688e6baf 100644 --- a/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs @@ -10,7 +10,8 @@ using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; using Calamari.Deployment; -using Octopus.Calamari.Contracts.Aws.Ecs; +using Calamari.Serialization; +using Newtonsoft.Json; namespace Calamari.Aws.Commands; @@ -46,7 +47,8 @@ public override int Execute(string[] commandLineArguments) inputs.TargetTaskDefinitionName, inputs.Containers, inputs.Tags, - inputs.WaitOption) + inputs.WaitOption, + inputs.WaitTimeout) ], log).RunConventions(); @@ -58,19 +60,20 @@ EcsUpdateServiceInputs ReadAndValidateInputs() var clusterName = variables.Get(AwsSpecialVariables.Ecs.ClusterName); Guard.NotNullOrWhiteSpace(clusterName, "Cluster name is required"); - var serviceName = variables.Get(AwsSpecialVariables.Ecs.Update.ServiceName); + var serviceName = variables.Get(AwsSpecialVariables.Ecs.ServiceName); Guard.NotNullOrWhiteSpace(serviceName, "Service name is required"); - var targetFamily = variables.Get(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName); + var targetFamily = variables.Get(AwsSpecialVariables.Ecs.TargetTaskDefinitionName); Guard.NotNullOrWhiteSpace(targetFamily, "Target task definition name is required"); - var templateFamily = variables.Get(AwsSpecialVariables.Ecs.Update.TemplateTaskDefinitionName); + var templateFamily = variables.Get(AwsSpecialVariables.Ecs.TemplateTaskDefinitionName); if (string.IsNullOrWhiteSpace(templateFamily)) { templateFamily = targetFamily; } - var containers = variables.GetValueDeserialisedAs>(AwsSpecialVariables.Ecs.Update.ContainerUpdates); + var containersJson = variables.Get(AwsSpecialVariables.Ecs.Containers) ?? "[]"; + var containers = JsonConvert.DeserializeObject>(containersJson, JsonSerialization.GetDefaultSerializerSettings()) ?? []; if (containers.Count == 0) { throw new CommandException("At least one container is required."); @@ -81,7 +84,8 @@ EcsUpdateServiceInputs ReadAndValidateInputs() Guard.NotNullOrWhiteSpace(c.ContainerName, "Container name is required"); } - var userTags = variables.GetValueDeserialisedAs>>(AwsSpecialVariables.ResourceTags); + var tagsJson = variables.Get(AwsSpecialVariables.CloudFormation.Tags) ?? "[]"; + var userTags = JsonConvert.DeserializeObject>>(tagsJson) ?? []; var seenTagKeys = new HashSet(StringComparer.Ordinal); foreach (var tag in userTags) { @@ -91,10 +95,24 @@ EcsUpdateServiceInputs ReadAndValidateInputs() } } - var waitOption = variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.WaitOption); - if (waitOption.Type == WaitType.WaitWithTimeout && waitOption.GetTimeoutSpan() is null) + var waitOptionRaw = variables.Get(AwsSpecialVariables.Ecs.WaitOptionLegacy.Type); + Guard.NotNullOrWhiteSpace(waitOptionRaw, "The wait option is required"); + if (!Enum.TryParse(waitOptionRaw, ignoreCase: true, out var waitOption)) { - throw new CommandException($"Wait option is '{nameof(WaitType.WaitWithTimeout)}' but got invalid timeout '{waitOption.TimeoutMinutes}'."); + 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.WaitOptionLegacy.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); } return new EcsUpdateServiceInputs( @@ -104,7 +122,8 @@ EcsUpdateServiceInputs ReadAndValidateInputs() templateFamily, containers, userTags, - waitOption); + waitOption, + timeout); } } @@ -113,6 +132,7 @@ public record EcsUpdateServiceInputs( string ServiceName, string TargetTaskDefinitionName, string TemplateTaskDefinitionName, - List Containers, + List Containers, List> Tags, - WaitOption WaitOption); + WaitOptionType WaitOption, + TimeSpan? WaitTimeout); diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 3edcc8c33..19def5a94 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -24,7 +24,6 @@ public static class Ecs { public static class Deploy { - const string DeployPrefix = "Octopus.Action.Aws.Ecs.Deploy."; // Not reusing CloudFormation variable here to make it easier to remove all traces of this when we migrate to native ECS API public const string StackName = "Octopus.Action.Aws.Ecs.Deploy.CFStackName"; @@ -44,6 +43,7 @@ public static class Deploy public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; + public const string WaitOption = "Octopus.Action.Aws.Ecs.WaitOption"; public static class Update diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index a4af13fa9..22642b7a6 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -1,13 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; -using Amazon.CDK.AWS.ECS; using Calamari.Aws.Deployment; using Calamari.Aws.Integration.CloudFormation; using Calamari.Aws.Integration.Ecs; using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; +using Octopus.Calamari.Contracts.Aws.Ecs; + +using AwsCpuArchitecture = Amazon.CDK.AWS.ECS.CpuArchitecture; namespace Calamari.Aws.Inputs; @@ -24,10 +26,12 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack this.stackNameGenerator = stackNameGenerator; this.log = log; + // strings requiredVariableKeys.Add(AwsSpecialVariables.Ecs.ClusterName); requiredVariableKeys.Add(DeploymentEnvironment.Id); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + // primitives // TODO: Type checking // TODO: Defaults? requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); @@ -36,6 +40,9 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.DesiredCount); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent); + + // Objects + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.WaitOption); } @@ -87,19 +94,27 @@ public string CfStackName { public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); - public CpuArchitecture CpuArchitecture + public AwsCpuArchitecture CpuArchitecture { get { var cpuArchValue = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); return cpuArchValue.ToUpper() switch { - "ARM64" => CpuArchitecture.ARM64, - _ => CpuArchitecture.X86_64 // default + "ARM64" => AwsCpuArchitecture.ARM64, + _ => AwsCpuArchitecture.X86_64 // default }; } } + public WaitOption WaitOption + { + get + { + var waitOptionString = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.WaitOption); + } + } + } public record InputsValidityResult(IEnumerable MissingKeys) diff --git a/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs index 4df52d940..1f906f5aa 100644 --- a/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs +++ b/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Amazon; @@ -9,14 +8,15 @@ 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.Update.TemplateTaskDefinitionName, TaskDefinitionFamily); - variables.Set(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName, missingTarget); + variables.Set(AwsSpecialVariables.Ecs.TemplateTaskDefinitionName, TaskDefinitionFamily); + variables.Set(AwsSpecialVariables.Ecs.TargetTaskDefinitionName, missingTarget); var log = new InMemoryLog(); var command = new UpdateEcsServiceCommand(log, variables); @@ -135,36 +135,21 @@ 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.Update.ServiceName, serviceName); - variables.Set(AwsSpecialVariables.Ecs.Update.TargetTaskDefinitionName, TaskDefinitionFamily); - - const string packageReference = "web"; - variables.Set(PackageVariables.IndexedImage(packageReference), newImage); + variables.Set(AwsSpecialVariables.Ecs.ServiceName, serviceName); + variables.Set(AwsSpecialVariables.Ecs.TargetTaskDefinitionName, TaskDefinitionFamily); + 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 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" } - ] - } - } + new EcsContainerUpdate("web", newImage, environment, null) }; - 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.Containers, JsonConvert.SerializeObject(containers, JsonSerialization.GetDefaultSerializerSettings())); - var waitOption = new WaitOption { Type = WaitType.DontWait }; - variables.Set(AwsSpecialVariables.Ecs.WaitOption, JsonConvert.SerializeObject(waitOption, CalamariContractSerializationSettings.Default)); + variables.Set(AwsSpecialVariables.Ecs.WaitOptionLegacy.Type, "dontWait"); return variables; } diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index 8242b97c0..b7b51d95a 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -261,6 +261,18 @@ public void MaximumHealthyPercentage_IsReturnedAsADouble(bool useExpression) result.Should().Be(150); } + + [Test] + public void WaitOption_IsDeserialisedAndReturned() + { + const string waitOptionInput = """{ "type": "waitUntilCompleted" }"""; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, waitOptionInput, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.WaitOption; + + result.Sh + } // Test Helpers @@ -277,6 +289,7 @@ static CalamariVariables MinimumRequiredVariableSet() { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1"}, { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, + { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""} }; } From 8dc97830203186247a09d0a43ce63e17a8747680 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 21 May 2026 13:07:46 +1000 Subject: [PATCH 13/80] Wire up some more variables --- .../Deployment/AwsSpecialVariables.cs | 3 + .../Inputs/DeployEcsCommandInputs.cs | 26 +++++--- .../Integration/Ecs/EcsDeployTemplate.cs | 15 ++++- .../VariablesDeserialisationExtensions.cs | 5 ++ .../Inputs/DeployEcsCommandInputsFixture.cs | 63 +++++++++++++++++-- 5 files changed, 97 insertions(+), 15 deletions(-) diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 19def5a94..28e44d14c 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -39,6 +39,9 @@ public static class Deploy public const string ServiceTaskName = "Octopus.Action.Aws.Ecs.Deploy.ServiceTaskName"; public const string TaskRole = "Octopus.Action.Aws.Ecs.Deploy.TaskRole"; public const string TaskExecutionRole = "Octopus.Action.Aws.Ecs.Deploy.TaskExecutionRole"; + + public const string SecurityGroupIds = "Octopus.Action.Aws.Ecs.Deploy.SecurityGroupIds"; + public const string SubnetIds = "Octopus.Action.Aws.Ecs.Deploy.SubnetIds"; } public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 22642b7a6..f70f56809 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -15,14 +15,14 @@ namespace Calamari.Aws.Inputs; public class DeployEcsCommandInputs { - readonly IVariables variables; + readonly CalamariVariables variables; readonly IEcsStackNameGenerator stackNameGenerator; readonly ILog log; - readonly List requiredVariableKeys = []; + readonly HashSet requiredVariableKeys = []; public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, ILog log) { - this.variables = variables; + this.variables = variables as CalamariVariables; this.stackNameGenerator = stackNameGenerator; this.log = log; @@ -40,6 +40,11 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.DesiredCount); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp); + + // collections + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SubnetIds); // Objects requiredVariableKeys.Add(AwsSpecialVariables.Ecs.WaitOption); @@ -94,6 +99,8 @@ public string CfStackName { public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); + public bool AutoAssignPublicIp => bool.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp)); + public AwsCpuArchitecture CpuArchitecture { get @@ -107,13 +114,12 @@ public AwsCpuArchitecture CpuArchitecture } } - public WaitOption WaitOption - { - get - { - var waitOptionString = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.WaitOption); - } - } + + public List NetworkSecurityGroupIds => variables.GetValueDeserilisedAs>(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); + public List SubnetIDs => variables.GetValueDeserilisedAs>(AwsSpecialVariables.Ecs.Deploy.SubnetIds); + + public WaitOption WaitOption => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.WaitOption); + } diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 88cac2163..4b2b291cf 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,4 +1,6 @@ +using System.Linq; using Amazon.CDK; +using Amazon.CDK.AWS.EC2; using Amazon.CDK.AWS.ECS; using Calamari.Aws.Inputs; @@ -20,6 +22,8 @@ public void GenerateTemplate() */ var cluster = new Cluster(this, commandInputs.ClusterName); // TODO: Handle deploying to an existing cluster + + var taskDefinition = new FargateTaskDefinition(this, commandInputs.TaskName, new FargateTaskDefinitionProps @@ -42,6 +46,16 @@ public void GenerateTemplate() DesiredCount = commandInputs.DesiredCount, MinHealthyPercent = commandInputs.MinimumHealthyPercentage, MaxHealthyPercent = commandInputs.MaximumHealthyPercentage, + AssignPublicIp = commandInputs.AutoAssignPublicIp, + VpcSubnets = new SubnetSelection + { + Subnets = commandInputs.SubnetIDs. + Select((id, index) => Subnet.FromSubnetId(this, $"Subnet-{index}", id)) + .ToArray() + }, + SecurityGroups = commandInputs.NetworkSecurityGroupIds + .Select((id, index) => SecurityGroup.FromSecurityGroupId(this, $"sg-{index}", id)) + .ToArray() }); } } @@ -49,7 +63,6 @@ public void GenerateTemplate() /* * - public const string AutoAssignPublicIp = $"{DeployPrefix}AutoAssignPublicIp"; public const string EnableEcsManagedTags = $"{DeployPrefix}EnableEcsManagedTags"; public const string TaskRole = $"{DeployPrefix}TaskRole"; public const string TaskExecutionRole = $"{DeployPrefix}TaskExecutionRole"; diff --git a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index d9de1f3f0..c4a923b9b 100644 --- a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs +++ b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs @@ -9,7 +9,12 @@ public static class VariablesDeserialisationExtensions public static T GetValueDeserialisedAs(this IVariables variables, string name) { + IVariables castVariables = variables; + return castVariables.GetValueDeserilisedAs(name); + } + public static T GetValueDeserilisedAs(this IVariables variables, string name) + { var variableJson = variables.Get(name); if (string.IsNullOrEmpty(variableJson)) diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index b7b51d95a..fdae82967 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -8,6 +8,7 @@ using FluentAssertions; using NSubstitute; using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Tests.AWS.Inputs; @@ -266,12 +267,59 @@ public void MaximumHealthyPercentage_IsReturnedAsADouble(bool useExpression) public void WaitOption_IsDeserialisedAndReturned() { const string waitOptionInput = """{ "type": "waitUntilCompleted" }"""; - var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, waitOptionInput, false); + var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, waitOptionInput, false); var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); var result = inputs.WaitOption; - result.Sh + result.Type.Should().Be(WaitType.WaitUntilCompleted); + result.Timeout.Should().BeNull(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void AutoAssignPublicIp_IsReturnedAsABool(bool useExpression) + { + const string enablePublicIpInput = "True"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, enablePublicIpInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.AutoAssignPublicIp; + + result.Should().BeTrue(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void NetworkSecurityGroupIds_IsReturnedAsAListOfString(bool useExpression) + { + const string securityGroupsInput = """" + ["sg-0123abcd456789fgh", "sg-abcd1234abcdef567"] + """"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, securityGroupsInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.NetworkSecurityGroupIds; + + result.Count.Should().Be(2); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void SubnetIds_IsReturnedAsAListOfString(bool useExpression) + { + const string subnetsInput = """" + ["subnet-0123abcd456789fgh", "subnet-abcd1234abcdef567", "subnet-xxxxxxxxxxxxxxxx"] + """"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.SubnetIds, subnetsInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.SubnetIDs; + + result.Count.Should().Be(3); } @@ -285,11 +333,18 @@ static CalamariVariables MinimumRequiredVariableSet() { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask"}, { AwsSpecialVariables.Ecs.Deploy.Cpu, "2"}, { AwsSpecialVariables.Ecs.Deploy.Memory, "1"}, - {AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64"}, + { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64"}, { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1"}, { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, - { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""} + { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "False"}, + { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""}, + { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """" + [sg-0d5e06a4bde84dabc"], + """"}, + { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ + [""subnet-0650cd8a2119e8abc"] + """} }; } From 4492772583caf8049aebe5cf7c2d123586e2320a Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 21 May 2026 14:57:10 +1000 Subject: [PATCH 14/80] Add EcsManagedFlag --- .../Calamari.Aws/Inputs/DeployEcsCommandInputs.cs | 3 +++ .../Integration/Ecs/EcsDeployTemplate.cs | 3 ++- .../AWS/Inputs/DeployEcsCommandInputsFixture.cs | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index f70f56809..e30691a39 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -41,6 +41,7 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags); // collections requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); @@ -100,6 +101,8 @@ public string CfStackName { public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); public bool AutoAssignPublicIp => bool.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp)); + + public bool EnableEcsManagedTags => bool.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags)); public AwsCpuArchitecture CpuArchitecture { diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 4b2b291cf..a63b60ba6 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -55,7 +55,8 @@ public void GenerateTemplate() }, SecurityGroups = commandInputs.NetworkSecurityGroupIds .Select((id, index) => SecurityGroup.FromSecurityGroupId(this, $"sg-{index}", id)) - .ToArray() + .ToArray(), + EnableECSManagedTags = commandInputs.EnableEcsManagedTags, }); } } diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index fdae82967..a16c61426 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -289,6 +289,20 @@ public void AutoAssignPublicIp_IsReturnedAsABool(bool useExpression) result.Should().BeTrue(); } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void EnableEcsManagedTags_IsReturnedAsABool(bool useExpression) + { + const string enableEcsManagedTagsInput = "True"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, enableEcsManagedTagsInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var result = inputs.EnableEcsManagedTags; + + result.Should().BeTrue(); + } [Test] [TestCase(true)] @@ -338,6 +352,7 @@ static CalamariVariables MinimumRequiredVariableSet() { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "False"}, + { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False"}, { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""}, { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """" [sg-0d5e06a4bde84dabc"], From a4cb062dcb43aa263ea0c441cb1e8cbb25c0d0a4 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 11:10:43 +1000 Subject: [PATCH 15/80] Get template gen into a runnable state for testing --- .../Inputs/DeployEcsCommandInputs.cs | 12 +- .../Integration/Ecs/EcsDeployTemplate.cs | 106 +++++++++++++++--- .../Ecs/EcsDeployTemplateGenerator.cs | 12 +- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index e30691a39..7afd14f53 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -70,6 +70,8 @@ public InputsValidityResult Validate() public string ServiceName => $"Service{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; public string TaskName => $"TaskDefinition{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string FallbackTaskExecutionRoleName => $"TaskExecutionRole{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; #pragma warning restore CS0618 // Type or member is obsolete @@ -100,9 +102,15 @@ public string CfStackName { public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); - public bool AutoAssignPublicIp => bool.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp)); + public bool AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp); + + public bool EnableEcsManagedTags => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags); + + public string TaskRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskRole, ""); + public string TaskExecutionRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, ""); + + - public bool EnableEcsManagedTags => bool.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags)); public AwsCpuArchitecture CpuArchitecture { diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index a63b60ba6..4a23cb2f7 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,16 +1,21 @@ +using System.Globalization; using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.EC2; using Amazon.CDK.AWS.ECS; +using Amazon.CDK.AWS.IAM; using Calamari.Aws.Inputs; + namespace Calamari.Aws.Integration.Ecs; -public class EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) - : Stack(scope, id, props) +public class EcsDeployTemplate : Stack { - public void GenerateTemplate() + readonly DeployEcsCommandInputs commandInputs; + + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null): base(scope, id, props) { + this.commandInputs = commandInputs; /*** * // This creates a lightweight reference token instead of generating a massive VPC template @@ -20,22 +25,48 @@ public void GenerateTemplate() SecurityGroups = new[] { SecurityGroup.FromSecurityGroupId(this, "ClusterSg", "sg-xxxxxxxx") } }); */ - var cluster = new Cluster(this, commandInputs.ClusterName); // TODO: Handle deploying to an existing cluster + // var cluster = new Cluster(this, commandInputs.ClusterName); // TODO: Handle deploying to an existing cluster + + var cluster = Cluster.FromClusterAttributes(this, + "ImportedCluster", + new ClusterAttributes + { + ClusterName = commandInputs.ClusterName, + + // We must have this fake VPC otherwise CDK goes 💥 + Vpc = Vpc.FromVpcAttributes(this, "ClusterVpcContext", new VpcAttributes + { + VpcId = "vpc-dummy", + AvailabilityZones = ["ap-southeast-2a", "ap-southeast-2b"] + }) + }); - var taskDefinition = new FargateTaskDefinition(this, - commandInputs.TaskName, - new FargateTaskDefinitionProps - { - Cpu = commandInputs.Cpu, - MemoryLimitMiB = commandInputs.Memory, - RuntimePlatform = new RuntimePlatform - { - OperatingSystemFamily = OperatingSystemFamily.LINUX, // Hardcode to Linux as it's all we support - CpuArchitecture = commandInputs.CpuArchitecture, - } - }); + var taskDefinition = new TaskDefinition(this, + commandInputs.TaskName, + new TaskDefinitionProps + { + Cpu = commandInputs.Cpu.ToString(CultureInfo.InvariantCulture), + MemoryMiB = commandInputs.Memory.ToString(CultureInfo.InvariantCulture), + RuntimePlatform = new RuntimePlatform + { + OperatingSystemFamily = OperatingSystemFamily.LINUX, // Hardcode to Linux as it's all we support + CpuArchitecture = commandInputs.CpuArchitecture, + }, + ExecutionRole = ProcessTaskExecutionRole(commandInputs), + TaskRole = string.IsNullOrEmpty(commandInputs.TaskExecutionRole) ? null : Role.FromRoleArn(this, "SuppliedTaskRole", commandInputs.TaskExecutionRole), + Volumes = [], //TODO: Read from Variables + + }); + + // TODO: Add Containers + + taskDefinition.AddContainer("id", new ContainerDefinitionProps + { + Essential = true, + Image = ContainerImage.FromRegistry("index.docker.io/nginx:latest", new RepositoryImageProps()) + }); var fargateService = new FargateService(this, commandInputs.ServiceName, @@ -57,14 +88,55 @@ public void GenerateTemplate() .Select((id, index) => SecurityGroup.FromSecurityGroupId(this, $"sg-{index}", id)) .ToArray(), EnableECSManagedTags = commandInputs.EnableEcsManagedTags, + VolumeConfigurations = [], //TODO: Read from variables }); + + // Amazon.CDK.Tags.Of(fargateService).Add("TagName", "TagValue"); + // fargateService.AddVolume(); + // fargateService.LoadBalancers = [ + // + // new CfnService.LoadBalancerProperty() + // { + // + // } + // ]; } + + IRole ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) + { + if (string.IsNullOrEmpty(inputs.TaskExecutionRole)) + { + var role = new Role(this, "DefaultTaskExecutionRole", new RoleProps + { + RoleName = inputs.FallbackTaskExecutionRoleName, + AssumedBy = new ServicePrincipal("ecs-tasks.amazonaws.com"), + Path = "/", + ManagedPolicies = [] //TODO: Populate + }); + + _ = new CfnParameter(this, + "AmazonECSTaskExecutionRolePolicyArn", + new CfnParameterProps + { + Type = "String", + Default = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + }); + + return role; + + + } + + return Role.FromRoleArn(this, "SuppliedTaskExecutionRole", commandInputs.TaskExecutionRole); + } + + } /* * - public const string EnableEcsManagedTags = $"{DeployPrefix}EnableEcsManagedTags"; + public const string TaskRole = $"{DeployPrefix}TaskRole"; public const string TaskExecutionRole = $"{DeployPrefix}TaskExecutionRole"; */ \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index 76509ac4d..fac7e454f 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -12,8 +12,18 @@ public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) var stackName = commandInputs.CfStackName; var app = new App(); + + var stackProps = new StackProps + { + Synthesizer = new DefaultStackSynthesizer(new DefaultStackSynthesizerProps + { + // This flag kills the Rules assertion section and the bootstrap version parameter completely + GenerateBootstrapVersionRule = false + }) + }; - _ = new EcsDeployTemplate(commandInputs, app, stackName); + _ = new EcsDeployTemplate(commandInputs, app, stackName, stackProps); + var assembly = app.Synth(); From b40fe1941e44d817aab48326541946088db1fd24 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 11:23:42 +1000 Subject: [PATCH 16/80] Switch to Cfn style constructs to make matching SPF easier --- .../Inputs/DeployEcsCommandInputs.cs | 22 +- .../Integration/Ecs/EcsDeployTemplate.cs | 212 ++++++++---------- 2 files changed, 105 insertions(+), 129 deletions(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 7afd14f53..6f7873356 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -9,8 +9,6 @@ using Calamari.Common.Plumbing.Variables; using Octopus.Calamari.Contracts.Aws.Ecs; -using AwsCpuArchitecture = Amazon.CDK.AWS.ECS.CpuArchitecture; - namespace Calamari.Aws.Inputs; public class DeployEcsCommandInputs @@ -30,12 +28,12 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.ClusterName); requiredVariableKeys.Add(DeploymentEnvironment.Id); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Memory); // primitives // TODO: Type checking // TODO: Defaults? - requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); - requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Memory); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.DesiredCount); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent); @@ -94,15 +92,15 @@ public string CfStackName { public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); - public double Cpu => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Cpu)); + public string Cpu => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Cpu); - public double Memory => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory)); + public string Memory => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory); public double DesiredCount => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); - public bool AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp); + public string AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp) ? "ENABLED" : "DISABLED"; public bool EnableEcsManagedTags => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags); @@ -112,22 +110,22 @@ public string CfStackName { - public AwsCpuArchitecture CpuArchitecture + public string CpuArchitecture { get { var cpuArchValue = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); return cpuArchValue.ToUpper() switch { - "ARM64" => AwsCpuArchitecture.ARM64, - _ => AwsCpuArchitecture.X86_64 // default + "ARM64" => "ARM64", + _ => "X86_64" // default }; } } - public List NetworkSecurityGroupIds => variables.GetValueDeserilisedAs>(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); - public List SubnetIDs => variables.GetValueDeserilisedAs>(AwsSpecialVariables.Ecs.Deploy.SubnetIds); + public string[] NetworkSecurityGroupIds => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); + public string[] SubnetIDs => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.Deploy.SubnetIds); public WaitOption WaitOption => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.WaitOption); diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 4a23cb2f7..435bdd23b 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,7 +1,8 @@ +using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using Amazon.CDK; -using Amazon.CDK.AWS.EC2; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.IAM; using Calamari.Aws.Inputs; @@ -11,132 +12,109 @@ namespace Calamari.Aws.Integration.Ecs; public class EcsDeployTemplate : Stack { + const string FargateLaunchType = "FARGATE"; + const string AwsVpcNetworkMode = "awsvpc"; + const string LinuxOperatingSystemFamily = "LINUX"; + const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; + readonly DeployEcsCommandInputs commandInputs; - public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null): base(scope, id, props) + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) : base(scope, id, props) { this.commandInputs = commandInputs; - - /*** - * // This creates a lightweight reference token instead of generating a massive VPC template - var cluster = Cluster.FromClusterAttributes(this, "ImportedCluster", new ClusterAttributes - { - ClusterName = commandInputs.ClusterName, - SecurityGroups = new[] { SecurityGroup.FromSecurityGroupId(this, "ClusterSg", "sg-xxxxxxxx") } - }); - */ - // var cluster = new Cluster(this, commandInputs.ClusterName); // TODO: Handle deploying to an existing cluster - - - var cluster = Cluster.FromClusterAttributes(this, - "ImportedCluster", - new ClusterAttributes - { - ClusterName = commandInputs.ClusterName, - - // We must have this fake VPC otherwise CDK goes 💥 - Vpc = Vpc.FromVpcAttributes(this, "ClusterVpcContext", new VpcAttributes - { - VpcId = "vpc-dummy", - AvailabilityZones = ["ap-southeast-2a", "ap-southeast-2b"] - }) - }); - - - var taskDefinition = new TaskDefinition(this, - commandInputs.TaskName, - new TaskDefinitionProps - { - Cpu = commandInputs.Cpu.ToString(CultureInfo.InvariantCulture), - MemoryMiB = commandInputs.Memory.ToString(CultureInfo.InvariantCulture), - RuntimePlatform = new RuntimePlatform - { - OperatingSystemFamily = OperatingSystemFamily.LINUX, // Hardcode to Linux as it's all we support - CpuArchitecture = commandInputs.CpuArchitecture, - }, - ExecutionRole = ProcessTaskExecutionRole(commandInputs), - TaskRole = string.IsNullOrEmpty(commandInputs.TaskExecutionRole) ? null : Role.FromRoleArn(this, "SuppliedTaskRole", commandInputs.TaskExecutionRole), - Volumes = [], //TODO: Read from Variables - - }); - // TODO: Add Containers - - taskDefinition.AddContainer("id", new ContainerDefinitionProps - { - Essential = true, - Image = ContainerImage.FromRegistry("index.docker.io/nginx:latest", new RepositoryImageProps()) - }); - - var fargateService = new FargateService(this, - commandInputs.ServiceName, - new FargateServiceProps - { - Cluster = cluster, - TaskDefinition = taskDefinition, - DesiredCount = commandInputs.DesiredCount, - MinHealthyPercent = commandInputs.MinimumHealthyPercentage, - MaxHealthyPercent = commandInputs.MaximumHealthyPercentage, - AssignPublicIp = commandInputs.AutoAssignPublicIp, - VpcSubnets = new SubnetSelection - { - Subnets = commandInputs.SubnetIDs. - Select((id, index) => Subnet.FromSubnetId(this, $"Subnet-{index}", id)) - .ToArray() - }, - SecurityGroups = commandInputs.NetworkSecurityGroupIds - .Select((id, index) => SecurityGroup.FromSecurityGroupId(this, $"sg-{index}", id)) - .ToArray(), - EnableECSManagedTags = commandInputs.EnableEcsManagedTags, - VolumeConfigurations = [], //TODO: Read from variables - }); - - // Amazon.CDK.Tags.Of(fargateService).Add("TagName", "TagValue"); - // fargateService.AddVolume(); - // fargateService.LoadBalancers = [ - // - // new CfnService.LoadBalancerProperty() - // { - // - // } - // ]; + var executionRoleArn = ProcessTaskExecutionRole(commandInputs); + + var taskDefinition = new CfnTaskDefinition(this, + commandInputs.TaskName, + new CfnTaskDefinitionProps + { + Family = commandInputs.TaskName, + Cpu = commandInputs.Cpu, + Memory = commandInputs.Memory, + NetworkMode = AwsVpcNetworkMode, + RequiresCompatibilities = [FargateLaunchType], + ExecutionRoleArn = executionRoleArn, + TaskRoleArn = string.IsNullOrEmpty(commandInputs.TaskRole) ? null : commandInputs.TaskRole, + RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty + { + OperatingSystemFamily = LinuxOperatingSystemFamily, + CpuArchitecture = commandInputs.CpuArchitecture + }, + ContainerDefinitions = new[] + { + // TODO: Read from variables + new CfnTaskDefinition.ContainerDefinitionProperty + { + Name = "placeholder", + Image = "index.docker.io/nginx:latest", + Essential = true + } + }, + Volumes = Array.Empty() // TODO: Read from variables + }); + + _ = new CfnService(this, + commandInputs.ServiceName, + new CfnServiceProps + { + ServiceName = commandInputs.ServiceName, + Cluster = commandInputs.ClusterName, + LaunchType = FargateLaunchType, + TaskDefinition = taskDefinition.Ref, + DesiredCount = commandInputs.DesiredCount, + DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty + { + MinimumHealthyPercent = commandInputs.MinimumHealthyPercentage, + MaximumPercent = commandInputs.MaximumHealthyPercentage + }, + NetworkConfiguration = new CfnService.NetworkConfigurationProperty + { + AwsvpcConfiguration = new CfnService.AwsVpcConfigurationProperty + { + AssignPublicIp = commandInputs.AutoAssignPublicIp, + Subnets = commandInputs.SubnetIDs, + SecurityGroups = commandInputs.NetworkSecurityGroupIds + } + }, + EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, + LoadBalancers = Array.Empty(), // TODO: Read from variables + VolumeConfigurations = Array.Empty() // TODO: Read from variables + }); } - IRole ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) + string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) { - if (string.IsNullOrEmpty(inputs.TaskExecutionRole)) + if (!string.IsNullOrEmpty(inputs.TaskExecutionRole)) { - var role = new Role(this, "DefaultTaskExecutionRole", new RoleProps - { - RoleName = inputs.FallbackTaskExecutionRoleName, - AssumedBy = new ServicePrincipal("ecs-tasks.amazonaws.com"), - Path = "/", - ManagedPolicies = [] //TODO: Populate - }); - - _ = new CfnParameter(this, - "AmazonECSTaskExecutionRolePolicyArn", - new CfnParameterProps - { - Type = "String", - Default = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", - }); - - return role; - - + return inputs.TaskExecutionRole; } - return Role.FromRoleArn(this, "SuppliedTaskExecutionRole", commandInputs.TaskExecutionRole); + var role = new CfnRole(this, + "DefaultTaskExecutionRole", + new CfnRoleProps + { + RoleName = inputs.FallbackTaskExecutionRoleName, + Path = "/", + AssumeRolePolicyDocument = new Dictionary + { + ["Version"] = "2012-10-17", + ["Statement"] = new[] + { + new Dictionary + { + ["Effect"] = "Allow", + ["Principal"] = new Dictionary + { + ["Service"] = "ecs-tasks.amazonaws.com" + }, + ["Action"] = "sts:AssumeRole" + } + } + }, + ManagedPolicyArns = new[] { DefaultTaskExecutionPolicyArn } + }); + + return role.AttrArn; } - - } - -/* - * - - - public const string TaskRole = $"{DeployPrefix}TaskRole"; - public const string TaskExecutionRole = $"{DeployPrefix}TaskExecutionRole"; -*/ \ No newline at end of file From 10e43fab44cb7fa3fa2b5cb1df8e19c4a9759e4e Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 11:24:10 +1000 Subject: [PATCH 17/80] Cleanup using --- source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 435bdd23b..307acd84a 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.IAM; From b7919a9bf79e267a258a41db86d94d9c5595bd66 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 12:43:53 +1000 Subject: [PATCH 18/80] Tweaks to align with SPF output --- .../Inputs/DeployEcsCommandInputs.cs | 2 + .../Integration/Ecs/EcsDeployTemplate.cs | 145 ++++++++++++------ .../Ecs/EcsDeployTemplateGenerator.cs | 21 +-- 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 6f7873356..4d04f1551 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -64,6 +64,8 @@ public InputsValidityResult Validate() public string ClusterName => variables.Get(AwsSpecialVariables.Ecs.ClusterName); + public string ServiceTaskName => variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + #pragma warning disable CS0618 // Type or member is obsolete temporary SPF deprecation public string ServiceName => $"Service{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 307acd84a..4d2243fb1 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -8,31 +8,61 @@ namespace Calamari.Aws.Integration.Ecs; -public class EcsDeployTemplate : Stack +public sealed class EcsDeployTemplate : Stack { const string FargateLaunchType = "FARGATE"; const string AwsVpcNetworkMode = "awsvpc"; const string LinuxOperatingSystemFamily = "LINUX"; const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; - readonly DeployEcsCommandInputs commandInputs; - public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) : base(scope, id, props) { - this.commandInputs = commandInputs; + TemplateOptions.TemplateFormatVersion = "2010-09-09"; + + var clusterNameParam = new CfnParameter(this, + "ClusterName", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.ClusterName + }); + + var taskFamilyParam = new CfnParameter(this, + "TaskDefinitionName", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.ServiceTaskName + }); + + var cpuParam = new CfnParameter(this, + "TaskDefinitionCPU", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.Cpu + }); + + var memoryParam = new CfnParameter(this, + "TaskDefinitionMemory", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.Memory + }); - var executionRoleArn = ProcessTaskExecutionRole(commandInputs); + var executionRoleRef = ProcessTaskExecutionRole(commandInputs); var taskDefinition = new CfnTaskDefinition(this, commandInputs.TaskName, new CfnTaskDefinitionProps { - Family = commandInputs.TaskName, - Cpu = commandInputs.Cpu, - Memory = commandInputs.Memory, + Family = taskFamilyParam.ValueAsString, + Cpu = cpuParam.ValueAsString, + Memory = memoryParam.ValueAsString, NetworkMode = AwsVpcNetworkMode, RequiresCompatibilities = [FargateLaunchType], - ExecutionRoleArn = executionRoleArn, + ExecutionRoleArn = executionRoleRef, TaskRoleArn = string.IsNullOrEmpty(commandInputs.TaskRole) ? null : commandInputs.TaskRole, RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty { @@ -44,41 +74,57 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string // TODO: Read from variables new CfnTaskDefinition.ContainerDefinitionProperty { - Name = "placeholder", - Image = "index.docker.io/nginx:latest", - Essential = true + Name = "sample-container", + Image = "index.docker.io/nginx:1.31", + Essential = true, + ResourceRequirements = Array.Empty(), + EnvironmentFiles = Array.Empty(), + DisableNetworking = false, + DnsServers = Array.Empty(), + DnsSearchDomains = Array.Empty(), + ExtraHosts = Array.Empty(), + PortMappings = new[] + { + new CfnTaskDefinition.PortMappingProperty + { + ContainerPort = 80, + HostPort = 80, + Protocol = "tcp" + } + } } }, - Volumes = Array.Empty() // TODO: Read from variables + Volumes = Array.Empty(), // TODO: Read from variables + Tags = Array.Empty() }); - _ = new CfnService(this, - commandInputs.ServiceName, - new CfnServiceProps - { - ServiceName = commandInputs.ServiceName, - Cluster = commandInputs.ClusterName, - LaunchType = FargateLaunchType, - TaskDefinition = taskDefinition.Ref, - DesiredCount = commandInputs.DesiredCount, - DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty - { - MinimumHealthyPercent = commandInputs.MinimumHealthyPercentage, - MaximumPercent = commandInputs.MaximumHealthyPercentage - }, - NetworkConfiguration = new CfnService.NetworkConfigurationProperty - { - AwsvpcConfiguration = new CfnService.AwsVpcConfigurationProperty - { - AssignPublicIp = commandInputs.AutoAssignPublicIp, - Subnets = commandInputs.SubnetIDs, - SecurityGroups = commandInputs.NetworkSecurityGroupIds - } - }, - EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, - LoadBalancers = Array.Empty(), // TODO: Read from variables - VolumeConfigurations = Array.Empty() // TODO: Read from variables - }); + var service = new CfnService(this, + commandInputs.ServiceName, + new CfnServiceProps + { + Cluster = clusterNameParam.ValueAsString, + LaunchType = FargateLaunchType, + TaskDefinition = taskDefinition.Ref, + DesiredCount = commandInputs.DesiredCount, + DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty + { + MinimumHealthyPercent = commandInputs.MinimumHealthyPercentage, + MaximumPercent = commandInputs.MaximumHealthyPercentage + }, + NetworkConfiguration = new CfnService.NetworkConfigurationProperty + { + AwsvpcConfiguration = new CfnService.AwsVpcConfigurationProperty + { + AssignPublicIp = commandInputs.AutoAssignPublicIp, + Subnets = commandInputs.SubnetIDs, + SecurityGroups = commandInputs.NetworkSecurityGroupIds + } + }, + EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, + Tags = Array.Empty() + }); + + service.AddDependency(taskDefinition); } string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) @@ -88,11 +134,18 @@ string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) return inputs.TaskExecutionRole; } + var policyArnParam = new CfnParameter(this, + "AmazonECSTaskExecutionRolePolicyArn", + new CfnParameterProps + { + Type = "String", + Default = DefaultTaskExecutionPolicyArn + }); + var role = new CfnRole(this, - "DefaultTaskExecutionRole", + inputs.FallbackTaskExecutionRoleName, new CfnRoleProps { - RoleName = inputs.FallbackTaskExecutionRoleName, Path = "/", AssumeRolePolicyDocument = new Dictionary { @@ -104,15 +157,15 @@ string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) ["Effect"] = "Allow", ["Principal"] = new Dictionary { - ["Service"] = "ecs-tasks.amazonaws.com" + ["Service"] = new[] { "ecs-tasks.amazonaws.com" } }, - ["Action"] = "sts:AssumeRole" + ["Action"] = new[] { "sts:AssumeRole" } } } }, - ManagedPolicyArns = new[] { DefaultTaskExecutionPolicyArn } + ManagedPolicyArns = new[] { policyArnParam.ValueAsString } }); - return role.AttrArn; + return role.Ref; } } diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index fac7e454f..04e0ab3f8 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -1,7 +1,6 @@ using Amazon.CDK; using Calamari.Aws.Inputs; using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; namespace Calamari.Aws.Integration.Ecs; @@ -10,34 +9,30 @@ public static class EcsDeployTemplateGenerator public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) { var stackName = commandInputs.CfStackName; - + var app = new App(); - + var stackProps = new StackProps { Synthesizer = new DefaultStackSynthesizer(new DefaultStackSynthesizerProps { // This flag kills the Rules assertion section and the bootstrap version parameter completely - GenerateBootstrapVersionRule = false + GenerateBootstrapVersionRule = false }) }; _ = new EcsDeployTemplate(commandInputs, app, stackName, stackProps); - var assembly = app.Synth(); - + var stackArtifact = assembly.GetStackByName(stackName); - + var settings = new JsonSerializerSettings { - Formatting = Formatting.Indented, - NullValueHandling = NullValueHandling.Ignore, - ContractResolver = new CamelCasePropertyNamesContractResolver() + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore }; - - var cloudFormationTemplateJson = JsonConvert.SerializeObject(stackArtifact.Template, settings); - return cloudFormationTemplateJson; + return JsonConvert.SerializeObject(stackArtifact.Template, settings); } } \ No newline at end of file From 20f75f06cff1c0090bb07a03ff0d1dca5c6d6b0c Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 14:01:09 +1000 Subject: [PATCH 19/80] Remove deserialize customisation --- .../Variables/VariablesDeserialisationExtensions.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index c4a923b9b..8abc22a56 100644 --- a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs +++ b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs @@ -6,13 +6,6 @@ namespace Calamari.Common.Plumbing.Variables; public static class VariablesDeserialisationExtensions { - - public static T GetValueDeserialisedAs(this IVariables variables, string name) - { - IVariables castVariables = variables; - return castVariables.GetValueDeserilisedAs(name); - } - public static T GetValueDeserilisedAs(this IVariables variables, string name) { var variableJson = variables.Get(name); From 22bcc437ef027f8a953998a97ad3635d2b939816 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 22 May 2026 14:09:33 +1000 Subject: [PATCH 20/80] revert extensions to main --- .../Plumbing/Variables/VariablesDeserialisationExtensions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index 8abc22a56..d9de1f3f0 100644 --- a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs +++ b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs @@ -6,8 +6,10 @@ namespace Calamari.Common.Plumbing.Variables; public static class VariablesDeserialisationExtensions { - public static T GetValueDeserilisedAs(this IVariables variables, string name) + + public static T GetValueDeserialisedAs(this IVariables variables, string name) { + var variableJson = variables.Get(name); if (string.IsNullOrEmpty(variableJson)) From dc1f9516762b9fa85189e3bb0f375dc211f82644 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 26 May 2026 13:10:23 +1000 Subject: [PATCH 21/80] Update variables and types --- .../Deployment/AwsSpecialVariables.cs | 6 +- .../Inputs/DeployEcsCommandInputsFixture.cs | 133 +++++++++++++++--- 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 28e44d14c..2ee7d3741 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -39,11 +39,15 @@ public static class Deploy public const string ServiceTaskName = "Octopus.Action.Aws.Ecs.Deploy.ServiceTaskName"; public const string TaskRole = "Octopus.Action.Aws.Ecs.Deploy.TaskRole"; public const string TaskExecutionRole = "Octopus.Action.Aws.Ecs.Deploy.TaskExecutionRole"; - public const string SecurityGroupIds = "Octopus.Action.Aws.Ecs.Deploy.SecurityGroupIds"; public const string SubnetIds = "Octopus.Action.Aws.Ecs.Deploy.SubnetIds"; + public const string LoadBalancerMappings = "Octopus.Action.Aws.Ecs.Deploy.LoadBalancerMappings"; + public const string Volumes = "Octopus.Action.Aws.Ecs.Deploy.Volumes"; } + // Not reusing CloudFormation variable here to make it easier to remove all traces of this when we migrate to native ECS API + public const string Tags = "Octopus.Action.Aws.Ecs.Tags"; + public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index a16c61426..6cf017b14 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -1,5 +1,4 @@ using System; -using Amazon.CDK.AWS.ECS; using Calamari.Aws.Deployment; using Calamari.Aws.Inputs; using Calamari.Aws.Integration.Ecs; @@ -182,7 +181,7 @@ public void TaskName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) [Test] [TestCase(true)] [TestCase(false)] - public void Cpu_IsReturnedAsADouble(bool useExpression) + public void Cpu_IsReturnedAsAString(bool useExpression) { const string cpuInput = "0.5"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Cpu, cpuInput, useExpression); @@ -190,13 +189,13 @@ public void Cpu_IsReturnedAsADouble(bool useExpression) var cpu = inputs.Cpu; - cpu.Should().Be(0.5); + cpu.Should().Be("0.5"); } [Test] [TestCase(true)] [TestCase(false)] - public void Memory_IsReturnedAsADouble(bool useExpression) + public void Memory_IsReturnedAsAString(bool useExpression) { const string memoryInput = "0.5"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Memory, memoryInput, useExpression); @@ -204,21 +203,21 @@ public void Memory_IsReturnedAsADouble(bool useExpression) var memory = inputs.Memory; - memory.Should().Be(0.5); + memory.Should().Be("0.5"); } [Test] [TestCase(true)] [TestCase(false)] - public void CpuArchitecture_IsReturnedAsEnum(bool useExpression) + public void CpuArchitecture_IsReturnedAsString(bool useExpression) { const string cpuArchitecture = "ARM64"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, cpuArchitecture, useExpression); var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - + var architecture = inputs.CpuArchitecture; - - architecture.Should().Be(CpuArchitecture.ARM64); + + architecture.Should().Be("ARM64"); } [Test] @@ -279,7 +278,7 @@ public void WaitOption_IsDeserialisedAndReturned() [Test] [TestCase(true)] [TestCase(false)] - public void AutoAssignPublicIp_IsReturnedAsABool(bool useExpression) + public void AutoAssignPublicIp_IsReturnedAsAString(bool useExpression) { const string enablePublicIpInput = "True"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, enablePublicIpInput, useExpression); @@ -287,7 +286,7 @@ public void AutoAssignPublicIp_IsReturnedAsABool(bool useExpression) var result = inputs.AutoAssignPublicIp; - result.Should().BeTrue(); + result.Should().Be("ENABLED"); } [Test] @@ -307,7 +306,7 @@ public void EnableEcsManagedTags_IsReturnedAsABool(bool useExpression) [Test] [TestCase(true)] [TestCase(false)] - public void NetworkSecurityGroupIds_IsReturnedAsAListOfString(bool useExpression) + public void NetworkSecurityGroupIds_IsReturnedAsAStringArray(bool useExpression) { const string securityGroupsInput = """" ["sg-0123abcd456789fgh", "sg-abcd1234abcdef567"] @@ -317,13 +316,15 @@ public void NetworkSecurityGroupIds_IsReturnedAsAListOfString(bool useExpression var result = inputs.NetworkSecurityGroupIds; - result.Count.Should().Be(2); + result.Length.Should().Be(2); + result.Should().Contain("sg-0123abcd456789fgh"); + result.Should().Contain("sg-abcd1234abcdef567"); } [Test] [TestCase(true)] [TestCase(false)] - public void SubnetIds_IsReturnedAsAListOfString(bool useExpression) + public void SubnetIds_IsReturnedAsAStringArray(bool useExpression) { const string subnetsInput = """" ["subnet-0123abcd456789fgh", "subnet-abcd1234abcdef567", "subnet-xxxxxxxxxxxxxxxx"] @@ -333,9 +334,103 @@ public void SubnetIds_IsReturnedAsAListOfString(bool useExpression) var result = inputs.SubnetIDs; - result.Count.Should().Be(3); + result.Length.Should().Be(3); + result.Should().Contain("subnet-0123abcd456789fgh"); + result.Should().Contain("subnet-abcd1234abcdef567"); + result.Should().Contain("subnet-xxxxxxxxxxxxxxxx"); + } + + [Test] + public void TaskRole_WithValueUnspecified_ReturnsEmptyString() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var roleId = inputs.TaskRole; + + roleId.Should().BeEmpty(); } + [Test] + public void TaskExecutionRole_WithValueUnspecified_ReturnsEmptyString() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var roleId = inputs.TaskExecutionRole; + + roleId.Should().BeEmpty(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void TaskRole_ReturnsSuppliedValue(bool useExpression) + { + var taskRoleArn = "arn:aws:iam::123456780912:role/ecsTaskRole"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.TaskRole, taskRoleArn, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var roleId = inputs.TaskRole; + + roleId.Should().Be(taskRoleArn); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void TaskExecutionRole_ReturnsSuppliedValue(bool useExpression) + { + // { AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, "arn:aws:iam::123456780912:role/ecsTaskRole"} + var taskExecRoleArn = "arn:aws:iam::123456780912:role/ecsExecTaskRole"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, taskExecRoleArn, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var roleId = inputs.TaskExecutionRole; + + roleId.Should().Be(taskExecRoleArn); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void FallbackTaskExecutionRoleName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) + { + const string serviceTaskName = "MyNewEcsServiceTask"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, serviceTaskName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var taskExecutionRoleName = inputs.FallbackTaskExecutionRoleName; + + taskExecutionRoleName.Should().Be("TaskExecutionRolemyNewEcsServiceTask"); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void ServiceTaskName_ReturnsRawNonPrefixedNameValue(bool useExpression) + { + const string expectedServiceTaskName = "ServiceTaskName"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var serviceTaskName = inputs.ServiceTaskName; + + serviceTaskName.Should().Be(expectedServiceTaskName); + } + + [Test] + public void Containers_ReturnsListOfMappedContainers() + { + const string containerJson = """ + [{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"nginx","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Containers, containerJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var containers = inputs.Containers; + + containers.Length.Should().Be(1); + } + // Test Helpers static CalamariVariables MinimumRequiredVariableSet() @@ -355,11 +450,13 @@ static CalamariVariables MinimumRequiredVariableSet() { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False"}, { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""}, { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """" - [sg-0d5e06a4bde84dabc"], + ["sg-0d5e06a4bde84dabc"], """"}, { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ - [""subnet-0650cd8a2119e8abc"] - """} + ["subnet-0650cd8a2119e8abc"] + """}, + + }; } From 71ac6778489b1ca67859799050fea1bc26412d11 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 26 May 2026 13:23:49 +1000 Subject: [PATCH 22/80] Fix some merge issues --- .../Commands/UpdateEcsServiceCommand.cs | 46 ++++++------------- .../Deployment/AwsSpecialVariables.cs | 9 +--- .../AWS/Ecs/Update/UpdateEcsServiceFixture.cs | 43 +++++++++++------ .../Inputs/DeployEcsCommandInputsFixture.cs | 4 +- 4 files changed, 46 insertions(+), 56 deletions(-) diff --git a/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs b/source/Calamari.Aws/Commands/UpdateEcsServiceCommand.cs index 3688e6baf..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.WaitOptionLegacy.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.WaitOptionLegacy.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 2ee7d3741..40bd62002 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -43,6 +43,7 @@ public static class Deploy public const string SubnetIds = "Octopus.Action.Aws.Ecs.Deploy.SubnetIds"; public const string LoadBalancerMappings = "Octopus.Action.Aws.Ecs.Deploy.LoadBalancerMappings"; public const string Volumes = "Octopus.Action.Aws.Ecs.Deploy.Volumes"; + public const string Containers = "Octopus.Action.Aws.Ecs.Deploy.Containers"; } // Not reusing CloudFormation variable here to make it easier to remove all traces of this when we migrate to native ECS API @@ -52,6 +53,7 @@ public static class Deploy public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; public const string WaitOption = "Octopus.Action.Aws.Ecs.WaitOption"; + public static class Update { @@ -60,13 +62,6 @@ public static class Update 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"; - } } public static class CloudFormation diff --git a/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/Update/UpdateEcsServiceFixture.cs index 1f906f5aa..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.WaitOptionLegacy.Type, "dontWait"); + var waitOption = new WaitOption { Type = WaitType.DontWait }; + variables.Set(AwsSpecialVariables.Ecs.WaitOption, JsonConvert.SerializeObject(waitOption, CalamariContractSerializationSettings.Default)); return variables; } diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs index 6cf017b14..9192e9b7a 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs @@ -272,7 +272,7 @@ public void WaitOption_IsDeserialisedAndReturned() var result = inputs.WaitOption; result.Type.Should().Be(WaitType.WaitUntilCompleted); - result.Timeout.Should().BeNull(); + result.TimeoutMinutes.Should().BeNull(); } [Test] @@ -423,7 +423,7 @@ public void Containers_ReturnsListOfMappedContainers() const string containerJson = """ [{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"nginx","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] """; - var variables = SetupVariable(AwsSpecialVariables.Ecs.Containers, containerJson, false); + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Containers, containerJson, false); var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); var containers = inputs.Containers; From 841b71940ef378f776996ae94b3a59f8b727bda6 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 26 May 2026 13:25:01 +1000 Subject: [PATCH 23/80] Clean up command construction --- source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index 0bc15c409..42b8765b8 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -20,8 +20,8 @@ namespace Calamari.Aws.Commands; -[Command("deploy-aws-ecs-service", Description = "Deploys a service to an Amazon ECS cluster")] -public class DeployEcsServiceCommand : Command +[Command(CommandName, Description = "Deploys a service to an Amazon ECS cluster")] +public class DeployEcsServiceCommand(ILog log, IVariables variables, IEcsStackNameGenerator stackNameGenerator) : Command { readonly ILog log; readonly IVariables variables; @@ -39,6 +39,7 @@ public DeployEcsServiceCommand(ILog log, IVariables variables, ICalamariFileSyst Options.Add("template=", "Path to the CloudFormation template file.", v => templateFile = v); Options.Add("templateParameters=", "Path to the CloudFormation template parameters JSON file.", v => templateParameterFile = v); } + const string CommandName = "deploy-aws-ecs-service"; public override int Execute(string[] commandLineArguments) { From d30a4f667cbf0812f9169a767567981112661ce8 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:20:34 +1000 Subject: [PATCH 24/80] Clean up variables --- source/Calamari.Aws/Deployment/AwsSpecialVariables.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 40bd62002..5f4da9ca3 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -48,10 +48,7 @@ public static class Deploy // Not reusing CloudFormation variable here to make it easier to remove all traces of this when we migrate to native ECS API public const string Tags = "Octopus.Action.Aws.Ecs.Tags"; - public const string ClusterName = "Octopus.Action.Aws.Ecs.ClusterName"; - public const string ServiceName = "Octopus.Action.Aws.Ecs.ServiceName"; - public const string WaitOption = "Octopus.Action.Aws.Ecs.WaitOption"; From 475a6400ed1c653064cbce475f12fda794cbb27b Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:25:10 +1000 Subject: [PATCH 25/80] Add helpers for transforming inputs to the Cf types --- .../Inputs/ContainerSpecExtensions.cs | 228 ++++++ .../Inputs/ContainerSpecExtensionsTests.cs | 701 ++++++++++++++++++ 2 files changed, 929 insertions(+) create mode 100644 source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs create mode 100644 source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs diff --git a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs new file mode 100644 index 000000000..ec9a2aced --- /dev/null +++ b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs @@ -0,0 +1,228 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Amazon.CDK.AWS.ECS; +using Amazon.ECS; +using Octopus.Calamari.Contracts.Aws.Ecs; +using LogDriver = Octopus.Calamari.Contracts.Aws.Ecs.LogDriver; + +namespace Calamari.Aws.Inputs; + +public static class ContainerSpecExtensions +{ + + public static T ConvertedOrDefault(this string value, Func converter, Func defaultOverride = null) + { + return defaultOverride != null ? string.IsNullOrEmpty(value) ? defaultOverride() : converter(value) : string.IsNullOrEmpty(value) ? default : converter(value); + } + + public static CfnTaskDefinition.HealthCheckProperty ParseHealthCheck(this ContainerSpec containerSpec) + { + if (containerSpec.HealthCheck.Command.Count > 0) + { + return new CfnTaskDefinition.HealthCheckProperty + { + Command = containerSpec.HealthCheck.Command.ToArray(), + Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s)), + Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s)), + StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => double.Parse(s)), + Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s)), + }; + } + return null; + } + + public static Dictionary ParseDockerLabels(this ContainerSpec containerSpec) + { + // Grouping Handle potential duplicates + return containerSpec.DockerLabels + .GroupBy(kvp => kvp.Key).ToDictionary(g => g.Key, g => g.Last().Value); + + } + + public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this ContainerSpec containerSpec) + { + return containerSpec.ContainerPortMappings.Select(pm => new CfnTaskDefinition.PortMappingProperty + { + ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), + HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), + Protocol = pm.Protocol.ToString() + + }) + .ToArray(); + } + + public static CfnTaskDefinition.HostEntryProperty[] ParseExtraHosts(this ContainerSpec containerSpec) + { + return containerSpec.NetworkSettings.ExtraHosts.Select(eh => new CfnTaskDefinition.HostEntryProperty + { + Hostname = string.IsNullOrEmpty(eh.Hostname) ? null : eh.Hostname, + IpAddress = string.IsNullOrEmpty(eh.IpAddress) ? null : eh.IpAddress, + }).ToArray(); + } + + public static CfnTaskDefinition.RepositoryCredentialsProperty ParseRepositoryCredentials(this ContainerSpec containerSpec) + { + return containerSpec.RepositoryAuthentication.Type switch + { + RepositoryAuthenticationType.Default => null, + _ => new CfnTaskDefinition.RepositoryCredentialsProperty + { + CredentialsParameter = containerSpec.RepositoryAuthentication.SecretName + } + }; + } + + public static CfnTaskDefinition.ResourceRequirementProperty[] ParseResourceRequirements(this ContainerSpec containerSpec) + { + return string.IsNullOrEmpty(containerSpec.Gpus) + ? [] + : [new CfnTaskDefinition.ResourceRequirementProperty + { + Type = ResourceType.GPU + }]; + } + + public static CfnTaskDefinition.UlimitProperty[] ParseULimits(this ContainerSpec containerSpec) + { + if (containerSpec.Ulimits.Count > 0) + { + return containerSpec.Ulimits.Select(ul => new CfnTaskDefinition.UlimitProperty + { + Name = new Amazon.ECS.UlimitName(ul.LimitName), + HardLimit = double.Parse(ul.HardLimit), + SoftLimit = double.Parse(ul.SoftLimit), + + }).ToArray(); + } + + return []; + } + + public static CfnTaskDefinition.MountPointProperty[] ParseMountPoints(this ContainerSpec containerSpec) + { + if (containerSpec.ContainerStorage.MountPoints.Count > 0) + { + return containerSpec.ContainerStorage.MountPoints.Select(mp => new CfnTaskDefinition.MountPointProperty + { + SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, + ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, + ReadOnly = mp.Readonly.ConvertedOrDefault(bool.Parse) + }) + .ToArray(); + } + + return []; + } + + public static CfnTaskDefinition.ContainerDependencyProperty[] ParseDependencies(this ContainerSpec containerSpec) + { + if (containerSpec.Dependencies.Count > 0) + { + return containerSpec.Dependencies.Select(d => new CfnTaskDefinition.ContainerDependencyProperty + { + ContainerName = string.IsNullOrEmpty(d.ContainerName) ? null : d.ContainerName, + Condition = d.Condition.ToString().ToUpperInvariant(), + }).ToArray(); + } + + return []; + } + + public static CfnTaskDefinition.VolumeFromProperty[] ParseVolumesFrom(this ContainerSpec containerSpec) + { + if (containerSpec.ContainerStorage.VolumeFrom.Count > 0) + { + return containerSpec.ContainerStorage.VolumeFrom.Select(vf => new CfnTaskDefinition.VolumeFromProperty + { + SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, + ReadOnly = vf.Readonly.ConvertedOrDefault(bool.Parse) + }).ToArray(); + } + + return []; + } + + public static CfnTaskDefinition.EnvironmentFileProperty[] ParseEnvironmentFiles(this ContainerSpec containerSpec) + { + if (containerSpec.EnvironmentFiles.Count > 0) + { + return containerSpec.EnvironmentFiles.Select(ef => new CfnTaskDefinition.EnvironmentFileProperty + { + Type = "s3", // Hardcoded to always be S3 until we support other options + Value = ef + + }).ToArray(); + } + + return []; + } + + public static CfnTaskDefinition.LogConfigurationProperty ParseLogConfiguration(this ContainerSpec containerSpec) + { + if (containerSpec.ContainerLogging.LogDriver.HasValue) + { + if (containerSpec.ContainerLogging.LogDriver is LogDriver.None) + { + return null; + } + + var logDriver = LogDriver.None; + switch (containerSpec.ContainerLogging.Type) + { + case ContainerLoggingType.Auto: + logDriver = LogDriver.AwsLogs; + break; + case ContainerLoggingType.Manual: + default: + { + if(containerSpec.ContainerLogging.LogDriver.HasValue) + { + logDriver = containerSpec.ContainerLogging.LogDriver.Value; + } + break; + } + } + return new CfnTaskDefinition.LogConfigurationProperty + { + LogDriver = logDriver.ToString().ToLowerInvariant(), + Options = containerSpec.ContainerLogging.LogOptions + .Where(lo => lo.Type == KeyValueType.Plain) + .ToDictionary(opt => opt.Key, opt => opt.Value), + SecretOptions = containerSpec.ContainerLogging.LogOptions + .Where(lo => lo.Type == KeyValueType.Secret) + .ToDictionary(opt => opt.Key, opt => opt.Value), + + + }; + } + + return null; + } + + public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfiguration(this ContainerSpec containerSpec) + { + if (containerSpec.FirelensConfiguration.Type == FireLensConfigurationType.Disabled) + { + return null; + } + + var options = new Dictionary + { + { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata } + }; + if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None }) + { + options.Add("config-file-type", containerSpec.FirelensConfiguration.CustomConfigSource.Type.ToString().ToLowerInvariant()); + options.Add("config-file-value", containerSpec.FirelensConfiguration.CustomConfigSource.FilePath); + } + var fireLensConfig = new CfnTaskDefinition.FirelensConfigurationProperty + { + Type = containerSpec.FirelensConfiguration.FirelensType.ToString()?.ToLowerInvariant(), + Options = options + + }; + + return fireLensConfig; + } +} \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs new file mode 100644 index 000000000..31fee4e0d --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs @@ -0,0 +1,701 @@ +using System.Collections.Generic; +using Calamari.Aws.Inputs; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Tests.AWS.Inputs; + +[TestFixture] +public class ContainerSpecExtensionsTests +{ + [Test] + public void ParseMountPoints_WhenNoMountPoints_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseMountPoints(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseMountPoints_WithMountPoints_MapsAllProperties() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + MountPoints = + [ + new ContainerMountPoint + { + SourceVolume = "my-volume", + ContainerPath = "/data", + Readonly = "true" + } + ] + } + }; + + var result = spec.ParseMountPoints(); + + result.Should().HaveCount(1); + result[0].SourceVolume.Should().Be("my-volume"); + result[0].ContainerPath.Should().Be("/data"); + result[0].ReadOnly.Should().Be(true); + } + + [Test] + public void ParseMountPoints_WithEmptyReadonly_DefaultsToFalse() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + MountPoints = + [ + new ContainerMountPoint + { + SourceVolume = "v", + ContainerPath = "/p", + Readonly = string.Empty + } + ] + } + }; + + var result = spec.ParseMountPoints(); + + result[0].ReadOnly.Should().Be(false); + } + + [Test] + public void ParseMountPoints_WithEmptySourceAndPath_ReturnsNulls() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + MountPoints = + [ + new ContainerMountPoint + { + SourceVolume = string.Empty, + ContainerPath = string.Empty, + Readonly = "false" + } + ] + } + }; + + var result = spec.ParseMountPoints(); + + result[0].SourceVolume.Should().BeNull(); + result[0].ContainerPath.Should().BeNull(); + } + + [Test] + public void ParseDependencies_WhenNoDependencies_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseDependencies(); + + result.Should().BeEmpty(); + } + + [Test] + [TestCase(ContainerDependencyCondition.Start, "START")] + [TestCase(ContainerDependencyCondition.Complete, "COMPLETE")] + [TestCase(ContainerDependencyCondition.Success, "SUCCESS")] + [TestCase(ContainerDependencyCondition.Healthy, "HEALTHY")] + public void ParseDependencies_MapsConditionToUpperInvariant(ContainerDependencyCondition condition, string expected) + { + var spec = new ContainerSpec + { + Dependencies = + [ + new ContainerDependency + { + ContainerName = "sidecar", + Condition = condition + } + ] + }; + + var result = spec.ParseDependencies(); + + result.Should().HaveCount(1); + result[0].ContainerName.Should().Be("sidecar"); + result[0].Condition.Should().Be(expected); + } + + [Test] + public void ParseDependencies_WithEmptyContainerName_ReturnsNull() + { + var spec = new ContainerSpec + { + Dependencies = + [ + new ContainerDependency + { + ContainerName = string.Empty, + Condition = ContainerDependencyCondition.Start + } + ] + }; + + var result = spec.ParseDependencies(); + + result[0].ContainerName.Should().BeNull(); + } + + [Test] + public void ParseDependencies_WithMultipleDependencies_PreservesOrder() + { + var spec = new ContainerSpec + { + Dependencies = + [ + new ContainerDependency { ContainerName = "a", Condition = ContainerDependencyCondition.Start }, + new ContainerDependency { ContainerName = "b", Condition = ContainerDependencyCondition.Healthy } + ] + }; + + var result = spec.ParseDependencies(); + + result.Should().HaveCount(2); + result[0].ContainerName.Should().Be("a"); + result[1].ContainerName.Should().Be("b"); + } + + [Test] + public void ParseVolumesFrom_WhenNoVolumesFrom_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseVolumesFrom(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseVolumesFrom_WithVolumeFrom_MapsAllProperties() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + VolumeFrom = + [ + new ContainerVolumeFrom + { + SourceContainer = "shared-container", + Readonly = "true" + } + ] + } + }; + + var result = spec.ParseVolumesFrom(); + + result.Should().HaveCount(1); + result[0].SourceContainer.Should().Be("shared-container"); + result[0].ReadOnly.Should().Be(true); + } + + [Test] + public void ParseVolumesFrom_WithEmptySourceContainer_ReturnsNull() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + VolumeFrom = + [ + new ContainerVolumeFrom + { + SourceContainer = string.Empty, + Readonly = "false" + } + ] + } + }; + + var result = spec.ParseVolumesFrom(); + + result[0].SourceContainer.Should().BeNull(); + result[0].ReadOnly.Should().Be(false); + } + + [Test] + public void ParseVolumesFrom_WithEmptyReadonly_DefaultsToFalse() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + VolumeFrom = + [ + new ContainerVolumeFrom + { + SourceContainer = "c", + Readonly = string.Empty + } + ] + } + }; + + var result = spec.ParseVolumesFrom(); + + result[0].ReadOnly.Should().Be(false); + } + + [Test] + public void ParseDockerLabels_WithDuplicateKeys_LastValueWins() + { + var spec = new ContainerSpec + { + DockerLabels = + [ + new KeyValuePair("env", "dev"), + new KeyValuePair("env", "prod"), + new KeyValuePair("owner", "team-a") + ] + }; + + var result = spec.ParseDockerLabels(); + + result.Should().HaveCount(2); + result["env"].Should().Be("prod"); + result["owner"].Should().Be("team-a"); + } + + [Test] + public void ParseHealthCheck_WhenCommandEmpty_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseHealthCheck(); + + result.Should().BeNull(); + } + + [Test] + public void ParseHealthCheck_WhenCommandPresent_MapsAllProperties() + { + var spec = new ContainerSpec + { + HealthCheck = new HealthCheck + { + Command = ["CMD-SHELL", "curl -f http://localhost"], + Interval = "30", + Retries = "3", + StartPeriod = "10", + Timeout = "5" + } + }; + + var result = spec.ParseHealthCheck(); + + result.Should().NotBeNull(); + result!.Command.Should().BeEquivalentTo("CMD-SHELL", "curl -f http://localhost"); + result.Interval.Should().Be(30); + result.Retries.Should().Be(3); + result.StartPeriod.Should().Be(10); + result.Timeout.Should().Be(5); + } + + [Test] + public void ParsePortMappings_MapsContainerPortAndProtocol() + { + var spec = new ContainerSpec + { + ContainerPortMappings = + [ + new ContainerPortMapping { ContainerPort = "8080", Protocol = PortProtocol.Tcp } + ] + }; + + var result = spec.ParsePortMappings(); + + result.Should().HaveCount(1); + result[0].ContainerPort.Should().Be(8080); + result[0].HostPort.Should().Be(8080); + result[0].Protocol.Should().Be("Tcp"); + } + + [Test] + public void ParseRepositoryCredentials_WhenDefault_ReturnsNull() + { + var spec = new ContainerSpec + { + RepositoryAuthentication = new RepositoryAuthentication { Type = RepositoryAuthenticationType.Default } + }; + + var result = spec.ParseRepositoryCredentials(); + + result.Should().BeNull(); + } + + [Test] + public void ParseRepositoryCredentials_WhenSecretsManager_PopulatesCredentialsParameter() + { + var spec = new ContainerSpec + { + RepositoryAuthentication = new RepositoryAuthentication + { + Type = RepositoryAuthenticationType.SecretsManager, + SecretName = "arn:aws:secretsmanager:us-east-1:123:secret:foo" + } + }; + + var result = spec.ParseRepositoryCredentials(); + + result.CredentialsParameter.Should().Be("arn:aws:secretsmanager:us-east-1:123:secret:foo"); + } + + [Test] + public void ParseResourceRequirements_WhenGpusEmpty_ReturnsEmptyArray() + { + var spec = new ContainerSpec { Gpus = string.Empty }; + + var result = spec.ParseResourceRequirements(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseResourceRequirements_WhenGpusPresent_ReturnsGpuRequirement() + { + var spec = new ContainerSpec { Gpus = "1" }; + + var result = spec.ParseResourceRequirements(); + + result.Should().HaveCount(1); + } + + [Test] + public void ParseULimits_WhenNone_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseULimits(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseULimits_MapsLimitNameAndValues() + { + var spec = new ContainerSpec + { + Ulimits = + [ + new Ulimit { LimitName = "nofile", HardLimit = "65536", SoftLimit = "1024" } + ] + }; + + var result = spec.ParseULimits(); + + result.Should().HaveCount(1); + result[0].HardLimit.Should().Be(65536); + result[0].SoftLimit.Should().Be(1024); + } + + [Test] + public void ParseExtraHosts_MapsHostnameAndIp() + { + var spec = new ContainerSpec + { + NetworkSettings = new ContainerNetworkSettings + { + ExtraHosts = + [ + new ExtraHost { Hostname = "example.local", IpAddress = "10.0.0.1" } + ] + } + }; + + var result = spec.ParseExtraHosts(); + + result.Should().HaveCount(1); + result[0].Hostname.Should().Be("example.local"); + result[0].IpAddress.Should().Be("10.0.0.1"); + } + + [Test] + public void ConvertedOrDefault_WhenEmpty_ReturnsDefault() + { + var result = string.Empty.ConvertedOrDefault(int.Parse); + + result.Should().Be(0); + } + + [Test] + public void ConvertedOrDefault_WhenNonEmpty_AppliesConverter() + { + var result = "42".ConvertedOrDefault(int.Parse); + + result.Should().Be(42); + } + + [Test] + public void ConvertedOrDefault_WhenEmptyWithDefaultOverride_UsesOverride() + { + var result = string.Empty.ConvertedOrDefault(int.Parse, () => 99); + + result.Should().Be(99); + } + + [Test] + public void ConvertedOrDefault_WhenNonEmptyWithDefaultOverride_StillUsesConverter() + { + var result = "42".ConvertedOrDefault(int.Parse, () => 99); + + result.Should().Be(42); + } + + [Test] + public void ParseEnvironmentFiles_WhenNone_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseEnvironmentFiles(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseEnvironmentFiles_WithFiles_MapsToS3TypeWithValue() + { + var spec = new ContainerSpec + { + EnvironmentFiles = + [ + "arn:aws:s3:::my-bucket/env-one", + "arn:aws:s3:::my-bucket/env-two" + ] + }; + + var result = spec.ParseEnvironmentFiles(); + + result.Should().HaveCount(2); + result[0].Type.Should().Be("s3"); + result[0].Value.Should().Be("arn:aws:s3:::my-bucket/env-one"); + result[1].Type.Should().Be("s3"); + result[1].Value.Should().Be("arn:aws:s3:::my-bucket/env-two"); + } + + [Test] + public void ParseLogConfiguration_WhenLogDriverNull_ReturnsNull() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging { LogDriver = null } + }; + + var result = spec.ParseLogConfiguration(); + + result.Should().BeNull(); + } + + [Test] + public void ParseLogConfiguration_WhenLogDriverNone_ReturnsNull() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + LogDriver = LogDriver.None, + Type = ContainerLoggingType.Manual + } + }; + + var result = spec.ParseLogConfiguration(); + + result.Should().BeNull(); + } + + [Test] + public void ParseLogConfiguration_WhenAuto_ForcesAwsLogsDriver() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Auto, + LogDriver = LogDriver.Splunk + } + }; + + var result = spec.ParseLogConfiguration(); + + result.Should().NotBeNull(); + result!.LogDriver.Should().Be("awslogs"); + } + + [Test] + public void ParseLogConfiguration_WhenManual_UsesProvidedLogDriver() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Manual, + LogDriver = LogDriver.Splunk + } + }; + + var result = spec.ParseLogConfiguration(); + + result!.LogDriver.Should().Be("splunk"); + } + + [Test] + public void ParseLogConfiguration_SplitsPlainAndSecretOptions() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Manual, + LogDriver = LogDriver.AwsLogs, + LogOptions = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "awslogs-region", Value = "us-east-1" }, + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "awslogs-group", Value = "my-group" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "secret-token", Value = "arn:secret" } + ] + } + }; + + var result = spec.ParseLogConfiguration(); + + result!.Options.Should().BeOfType>() + .Which.Should().BeEquivalentTo(new Dictionary + { + { "awslogs-region", "us-east-1" }, + { "awslogs-group", "my-group" } + }); + result.SecretOptions.Should().BeOfType>() + .Which.Should().BeEquivalentTo(new Dictionary + { + { "secret-token", "arn:secret" } + }); + } + + [Test] + public void ParseFireLensConfiguration_WhenDisabled_ReturnsNull() + { + var spec = new ContainerSpec + { + FirelensConfiguration = new ContainerFireLensConfiguration { Type = FireLensConfigurationType.Disabled } + }; + + var result = spec.ParseFireLensConfiguration(); + + result.Should().BeNull(); + } + + [Test] + public void ParseFireLensConfiguration_WhenDefaultSpec_ReturnsNull() + { + // FireLensConfigurationType.Disabled is the default enum value + var spec = new ContainerSpec(); + + var result = spec.ParseFireLensConfiguration(); + + result.Should().BeNull(); + } + + [Test] + [TestCase(FireLensType.Fluentd, "fluentd")] + [TestCase(FireLensType.Fluentbit, "fluentbit")] + public void ParseFireLensConfiguration_WhenEnabled_MapsTypeLowercase(FireLensType firelensType, string expected) + { + var spec = new ContainerSpec + { + FirelensConfiguration = new ContainerFireLensConfiguration + { + Type = FireLensConfigurationType.Enabled, + FirelensType = firelensType, + EnableEcsLogMetadata = "true" + } + }; + + var result = spec.ParseFireLensConfiguration(); + + result.Should().NotBeNull(); + result!.Type.Should().Be(expected); + } + + [Test] + public void ParseFireLensConfiguration_WhenEnabled_AlwaysIncludesEnableEcsLogMetadata() + { + var spec = new ContainerSpec + { + FirelensConfiguration = new ContainerFireLensConfiguration + { + Type = FireLensConfigurationType.Enabled, + FirelensType = FireLensType.Fluentbit, + EnableEcsLogMetadata = "false" + } + }; + + var result = spec.ParseFireLensConfiguration(); + + result!.Options.Should().BeOfType>() + .Which.Should().ContainKey("enable-ecs-log-metadata") + .WhoseValue.Should().Be("false"); + } + + [Test] + public void ParseFireLensConfiguration_WithCustomConfigSourceNone_OmitsConfigFileOptions() + { + var spec = new ContainerSpec + { + FirelensConfiguration = new ContainerFireLensConfiguration + { + Type = FireLensConfigurationType.Enabled, + FirelensType = FireLensType.Fluentbit, + EnableEcsLogMetadata = "true", + CustomConfigSource = new FireLensCustomConfigSource { Type = FireLensCustomConfigSourceType.None } + } + }; + + var result = spec.ParseFireLensConfiguration(); + + var options = (Dictionary)result!.Options; + options.Should().NotContainKey("config-file-type"); + options.Should().NotContainKey("config-file-value"); + } + + [Test] + [TestCase(FireLensCustomConfigSourceType.File, "file")] + [TestCase(FireLensCustomConfigSourceType.S3, "s3")] + public void ParseFireLensConfiguration_WithCustomConfigSource_AddsConfigFileOptions(FireLensCustomConfigSourceType sourceType, string expected) + { + var spec = new ContainerSpec + { + FirelensConfiguration = new ContainerFireLensConfiguration + { + Type = FireLensConfigurationType.Enabled, + FirelensType = FireLensType.Fluentbit, + EnableEcsLogMetadata = "true", + CustomConfigSource = new FireLensCustomConfigSource + { + Type = sourceType, + FilePath = "/etc/fluent.conf" + } + } + }; + + var result = spec.ParseFireLensConfiguration(); + + var options = (Dictionary)result!.Options; + options.Should().Contain("config-file-type", expected); + options.Should().Contain("config-file-value", "/etc/fluent.conf"); + } +} From 0a058851736a143836b1f7d2ffe171a491bd6519 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:27:55 +1000 Subject: [PATCH 26/80] Update inputs structure --- source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs index 4d04f1551..4c0f51245 100644 --- a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs @@ -47,6 +47,7 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack // Objects requiredVariableKeys.Add(AwsSpecialVariables.Ecs.WaitOption); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Containers); } @@ -109,9 +110,6 @@ public string CfStackName { public string TaskRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskRole, ""); public string TaskExecutionRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, ""); - - - public string CpuArchitecture { get @@ -126,12 +124,12 @@ public string CpuArchitecture } - public string[] NetworkSecurityGroupIds => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); - public string[] SubnetIDs => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.Deploy.SubnetIds); - - public WaitOption WaitOption => variables.GetValueDeserilisedAs(AwsSpecialVariables.Ecs.WaitOption); + public string[] NetworkSecurityGroupIds => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); + public string[] SubnetIDs => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.SubnetIds); + public WaitOption WaitOption => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.WaitOption); + public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); } public record InputsValidityResult(IEnumerable MissingKeys) From 287c413045f59ea96d32c3309507e1b3206aab5e Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:28:31 +1000 Subject: [PATCH 27/80] next pass at deploy template --- .../Integration/Ecs/EcsDeployTemplate.cs | 103 +++++++++++------- 1 file changed, 62 insertions(+), 41 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 4d2243fb1..8919c5ae4 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.IAM; @@ -53,6 +53,53 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string var executionRoleRef = ProcessTaskExecutionRole(commandInputs); + var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty + { + Name = c.ContainerName, + Image = c.ContainerImageReference.ImageName, + Essential = c.Essential.ConvertedOrDefault(bool.Parse), + DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(bool.Parse), + WorkingDirectory = c.WorkingDirectory, + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.Parse(s)), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.Parse(s)), + Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s)), + User = c.User, + StartTimeout = c.StartTimeout.ConvertedOrDefault( s => int.Parse(s)), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => int.Parse(s)), + DnsServers = c.NetworkSettings.DnsServers.ToArray(), + DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), + ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), + + ResourceRequirements = c.ParseResourceRequirements(), + DockerLabels = c.ParseDockerLabels(), + PortMappings = c.ParsePortMappings(), + HealthCheck = c.ParseHealthCheck(), + ExtraHosts = c.ParseExtraHosts(), + RepositoryCredentials = c.ParseRepositoryCredentials(), + Ulimits = c.ParseULimits(), + + MountPoints = c.ParseMountPoints(), + DependsOn = c.ParseDependencies(), + VolumesFrom = c.ParseVolumesFrom(), + + LogConfiguration = c.ParseLogConfiguration(), + EnvironmentFiles = c.ParseEnvironmentFiles(), + FirelensConfiguration = c.ParseFireLensConfiguration(), + + Command = c.Command.ConvertedOrDefault(s => [s], () => []), + EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => []), + + + // TODO + // Secrets = + // Environment Variables + + Privileged = false, // SPF never set value for this property, so we use default + Links = [], // SPF never set value for this property + DockerSecurityOptions = [] // SPF never set value for this property + + }).ToArray(); + var taskDefinition = new CfnTaskDefinition(this, commandInputs.TaskName, new CfnTaskDefinitionProps @@ -69,31 +116,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string OperatingSystemFamily = LinuxOperatingSystemFamily, CpuArchitecture = commandInputs.CpuArchitecture }, - ContainerDefinitions = new[] - { - // TODO: Read from variables - new CfnTaskDefinition.ContainerDefinitionProperty - { - Name = "sample-container", - Image = "index.docker.io/nginx:1.31", - Essential = true, - ResourceRequirements = Array.Empty(), - EnvironmentFiles = Array.Empty(), - DisableNetworking = false, - DnsServers = Array.Empty(), - DnsSearchDomains = Array.Empty(), - ExtraHosts = Array.Empty(), - PortMappings = new[] - { - new CfnTaskDefinition.PortMappingProperty - { - ContainerPort = 80, - HostPort = 80, - Protocol = "tcp" - } - } - } - }, + ContainerDefinitions = containers, Volumes = Array.Empty(), // TODO: Read from variables Tags = Array.Empty() }); @@ -147,24 +170,22 @@ string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) new CfnRoleProps { Path = "/", - AssumeRolePolicyDocument = new Dictionary + ManagedPolicyArns = [policyArnParam.ValueAsString], + AssumeRolePolicyDocument = new PolicyDocument(new PolicyDocumentProps { - ["Version"] = "2012-10-17", - ["Statement"] = new[] - { - new Dictionary + Statements = + [ + new PolicyStatement(new PolicyStatementProps { - ["Effect"] = "Allow", - ["Principal"] = new Dictionary - { - ["Service"] = new[] { "ecs-tasks.amazonaws.com" } - }, - ["Action"] = new[] { "sts:AssumeRole" } - } - } - }, - ManagedPolicyArns = new[] { policyArnParam.ValueAsString } + Effect = Effect.ALLOW, + Principals = [new ServicePrincipal("ecs-tasks.amazonaws.com")], + Actions = ["sts:AssumeRole"] + + }) + ] + }) }); + return role.Ref; } From 17244dda79c93eb6c654a60fd9737b32e9be7c6b Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:55:22 +1000 Subject: [PATCH 28/80] Clean up some code structure to make SPF comparisons easier --- .../Integration/Ecs/EcsDeployTemplate.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 8919c5ae4..69dfa6941 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -104,21 +104,21 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string commandInputs.TaskName, new CfnTaskDefinitionProps { + ContainerDefinitions = containers, Family = taskFamilyParam.ValueAsString, Cpu = cpuParam.ValueAsString, Memory = memoryParam.ValueAsString, - NetworkMode = AwsVpcNetworkMode, - RequiresCompatibilities = [FargateLaunchType], ExecutionRoleArn = executionRoleRef, TaskRoleArn = string.IsNullOrEmpty(commandInputs.TaskRole) ? null : commandInputs.TaskRole, + RequiresCompatibilities = [FargateLaunchType], + NetworkMode = AwsVpcNetworkMode, RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty { OperatingSystemFamily = LinuxOperatingSystemFamily, CpuArchitecture = commandInputs.CpuArchitecture }, - ContainerDefinitions = containers, Volumes = Array.Empty(), // TODO: Read from variables - Tags = Array.Empty() + Tags = Array.Empty() // TODO: Read From Varaibles }); var service = new CfnService(this, @@ -129,6 +129,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string LaunchType = FargateLaunchType, TaskDefinition = taskDefinition.Ref, DesiredCount = commandInputs.DesiredCount, + EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty { MinimumHealthyPercent = commandInputs.MinimumHealthyPercentage, @@ -143,10 +144,11 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string SecurityGroups = commandInputs.NetworkSecurityGroupIds } }, - EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, - Tags = Array.Empty() + LoadBalancers = null, // TODO: read from variables + Tags = Array.Empty() // TODO: Read from Variables }); - + + // TODO: Add depdency on Load Balancer if require service.AddDependency(taskDefinition); } From 151d21071da6032a8000e37873909dc5b7e76176 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 12:55:42 +1000 Subject: [PATCH 29/80] Add formatting for whole doubles to make SPF comparisons easier --- .../Ecs/EcsDeployTemplateGenerator.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index 04e0ab3f8..99de6351d 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -1,3 +1,4 @@ +using System; using Amazon.CDK; using Calamari.Aws.Inputs; using Newtonsoft.Json; @@ -32,7 +33,31 @@ public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }; + + settings.Converters.Add(new WholeDoubleConverter()); return JsonConvert.SerializeObject(stackArtifact.Template, settings); } + + class WholeDoubleConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, double? value, JsonSerializer serializer) + { + if (value == null) + writer.WriteNull(); + else if (Math.Abs(value.Value - Math.Floor(value.Value)) < double.Epsilon) + writer.WriteValue((long)value.Value); + else + writer.WriteValue(value.Value); + } + + public override double? ReadJson(JsonReader reader, + Type objectType, + double? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + return reader.Value == null ? null : Convert.ToDouble(reader.Value); + } + } } \ No newline at end of file From bc08b7d6670c4e9f7ef02bcc44f2f1d5a460f280 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:06:50 +1000 Subject: [PATCH 30/80] Fix doubles --- .../Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 69dfa6941..18d9b6f98 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -60,12 +60,12 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Essential = c.Essential.ConvertedOrDefault(bool.Parse), DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(bool.Parse), WorkingDirectory = c.WorkingDirectory, - Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.Parse(s)), - MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.Parse(s)), - Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s)), + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s)), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s)), + Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s)), User = c.User, - StartTimeout = c.StartTimeout.ConvertedOrDefault( s => int.Parse(s)), - StopTimeout = c.StopTimeout.ConvertedOrDefault(s => int.Parse(s)), + StartTimeout = c.StartTimeout.ConvertedOrDefault( s => double.Parse(s)), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s)), DnsServers = c.NetworkSettings.DnsServers.ToArray(), DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), From 31319c025270ae1b23851559c4ce192abe81f63f Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:07:21 +1000 Subject: [PATCH 31/80] Parse Environment Variables --- .../Inputs/ContainerSpecExtensions.cs | 7 ++ .../Integration/Ecs/EcsDeployTemplate.cs | 2 +- .../Inputs/ContainerSpecExtensionsTests.cs | 70 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs index ec9a2aced..e254baaa3 100644 --- a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs +++ b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs @@ -40,6 +40,13 @@ public static Dictionary ParseDockerLabels(this ContainerSpec co } + public static Dictionary ParseEnvironmentVariables(this ContainerSpec containerSpec) + { + return containerSpec.EnvironmentVariables + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.Last().Value); + } + public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this ContainerSpec containerSpec) { return containerSpec.ContainerPortMappings.Select(pm => new CfnTaskDefinition.PortMappingProperty diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 18d9b6f98..c523b4ff1 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -89,10 +89,10 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Command = c.Command.ConvertedOrDefault(s => [s], () => []), EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => []), + Environment = c.ParseEnvironmentVariables(), // TODO // Secrets = - // Environment Variables Privileged = false, // SPF never set value for this property, so we use default Links = [], // SPF never set value for this property diff --git a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs index 31fee4e0d..c2b062ca7 100644 --- a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs @@ -252,6 +252,76 @@ public void ParseVolumesFrom_WithEmptyReadonly_DefaultsToFalse() result[0].ReadOnly.Should().Be(false); } + [Test] + public void ParseEnvironmentVariables_WhenNone_ReturnsEmptyDictionary() + { + var spec = new ContainerSpec(); + + var result = spec.ParseEnvironmentVariables(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseEnvironmentVariables_MapsKeysToValues() + { + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "LOG_LEVEL", Value = "INFO" }, + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "REGION", Value = "us-east-1" } + ] + }; + + var result = spec.ParseEnvironmentVariables(); + + result.Should().HaveCount(2); + result["LOG_LEVEL"].Should().Be("INFO"); + result["REGION"].Should().Be("us-east-1"); + } + + [Test] + public void ParseEnvironmentVariables_WithDuplicateKeys_LastValueWins() + { + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "LOG_LEVEL", Value = "DEBUG" }, + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "LOG_LEVEL", Value = "INFO" }, + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "REGION", Value = "us-east-1" } + ] + }; + + var result = spec.ParseEnvironmentVariables(); + + result.Should().HaveCount(2); + result["LOG_LEVEL"].Should().Be("INFO"); + result["REGION"].Should().Be("us-east-1"); + } + + [Test] + public void ParseEnvironmentVariables_IncludesSecretAndPlainEntriesAlike() + { + // The current implementation does not distinguish Plain vs Secret entries — + // both end up in the same dictionary. Lock that behaviour in. + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "PLAIN_KEY", Value = "plain-value" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "SECRET_KEY", Value = "arn:secret" } + ] + }; + + var result = spec.ParseEnvironmentVariables(); + + result.Should().HaveCount(2); + result["PLAIN_KEY"].Should().Be("plain-value"); + result["SECRET_KEY"].Should().Be("arn:secret"); + } + [Test] public void ParseDockerLabels_WithDuplicateKeys_LastValueWins() { From 0c1a637a9d0702f64fb8687106b36a0463ef73b6 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:17:02 +1000 Subject: [PATCH 32/80] Map Secrets and Environment Variables --- .../Inputs/ContainerSpecExtensions.cs | 14 +++ .../Integration/Ecs/EcsDeployTemplate.cs | 12 +-- .../Inputs/ContainerSpecExtensionsTests.cs | 97 +++++++++++++++++-- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs index e254baaa3..ceb4d6352 100644 --- a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs +++ b/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs @@ -43,6 +43,7 @@ public static Dictionary ParseDockerLabels(this ContainerSpec co public static Dictionary ParseEnvironmentVariables(this ContainerSpec containerSpec) { return containerSpec.EnvironmentVariables + .Where(tkp => tkp.Type == KeyValueType.Plain) .GroupBy(kvp => kvp.Key) .ToDictionary(g => g.Key, g => g.Last().Value); } @@ -232,4 +233,17 @@ public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfi return fireLensConfig; } + + public static CfnTaskDefinition.SecretProperty[] ParseSecrets(this ContainerSpec containerSpec) + { + return containerSpec.EnvironmentVariables + .Where(tkp => tkp.Type == KeyValueType.Secret) + .GroupBy(kvp => kvp.Key) // Dedupe + .Select(g => new CfnTaskDefinition.SecretProperty() + { + Name = g.Key, + ValueFrom = g.Last().Value + }) + .ToArray(); + } } \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index c523b4ff1..55a96f4f1 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -70,6 +70,9 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), + Command = c.Command.ConvertedOrDefault(s => [s], () => []), + EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => []), + ResourceRequirements = c.ParseResourceRequirements(), DockerLabels = c.ParseDockerLabels(), PortMappings = c.ParsePortMappings(), @@ -77,22 +80,15 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string ExtraHosts = c.ParseExtraHosts(), RepositoryCredentials = c.ParseRepositoryCredentials(), Ulimits = c.ParseULimits(), - MountPoints = c.ParseMountPoints(), DependsOn = c.ParseDependencies(), VolumesFrom = c.ParseVolumesFrom(), - LogConfiguration = c.ParseLogConfiguration(), EnvironmentFiles = c.ParseEnvironmentFiles(), FirelensConfiguration = c.ParseFireLensConfiguration(), - - Command = c.Command.ConvertedOrDefault(s => [s], () => []), - EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => []), Environment = c.ParseEnvironmentVariables(), - - // TODO - // Secrets = + Secrets = c.ParseSecrets(), Privileged = false, // SPF never set value for this property, so we use default Links = [], // SPF never set value for this property diff --git a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs index c2b062ca7..03de31047 100644 --- a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs @@ -302,10 +302,8 @@ public void ParseEnvironmentVariables_WithDuplicateKeys_LastValueWins() } [Test] - public void ParseEnvironmentVariables_IncludesSecretAndPlainEntriesAlike() + public void ParseEnvironmentVariables_ExcludesSecretEntries() { - // The current implementation does not distinguish Plain vs Secret entries — - // both end up in the same dictionary. Lock that behaviour in. var spec = new ContainerSpec { EnvironmentVariables = @@ -317,9 +315,96 @@ public void ParseEnvironmentVariables_IncludesSecretAndPlainEntriesAlike() var result = spec.ParseEnvironmentVariables(); - result.Should().HaveCount(2); - result["PLAIN_KEY"].Should().Be("plain-value"); - result["SECRET_KEY"].Should().Be("arn:secret"); + result.Should().HaveCount(1); + result.Should().ContainKey("PLAIN_KEY").WhoseValue.Should().Be("plain-value"); + result.Should().NotContainKey("SECRET_KEY"); + } + + [Test] + public void ParseEnvironmentVariables_DedupeAppliesAfterFilteringSecrets() + { + // A Secret entry with the same key as a Plain entry must not displace the Plain value. + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "TOKEN", Value = "plain-token" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "TOKEN", Value = "arn:secret" } + ] + }; + + var result = spec.ParseEnvironmentVariables(); + + result.Should().HaveCount(1); + result["TOKEN"].Should().Be("plain-token"); + } + + [Test] + public void ParseSecrets_WhenNone_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseSecrets(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseSecrets_OnlyIncludesSecretTypedEntries() + { + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "PLAIN_KEY", Value = "plain-value" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "SECRET_KEY", Value = "arn:secret" } + ] + }; + + var result = spec.ParseSecrets(); + + result.Should().HaveCount(1); + result[0].Name.Should().Be("SECRET_KEY"); + result[0].ValueFrom.Should().Be("arn:secret"); + } + + [Test] + public void ParseSecrets_WithDuplicateSecretKeys_LastValueWins() + { + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "TOKEN", Value = "arn:first" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "TOKEN", Value = "arn:second" } + ] + }; + + var result = spec.ParseSecrets(); + + result.Should().HaveCount(1); + result[0].Name.Should().Be("TOKEN"); + result[0].ValueFrom.Should().Be("arn:second"); + } + + [Test] + public void ParseSecrets_DoesNotConsiderPlainEntriesForDedupe() + { + // A Plain entry with the same key as a Secret must not displace or merge with the Secret value. + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "TOKEN", Value = "plain-token" }, + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "TOKEN", Value = "arn:secret" } + ] + }; + + var result = spec.ParseSecrets(); + + result.Should().HaveCount(1); + result[0].Name.Should().Be("TOKEN"); + result[0].ValueFrom.Should().Be("arn:secret"); } [Test] From 3fc251ddf1b837adb7099c87978119e9437e31ec Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:51:45 +1000 Subject: [PATCH 33/80] move files --- source/Calamari.Aws/Inputs/{ => Ecs}/ContainerSpecExtensions.cs | 0 source/Calamari.Aws/Inputs/{ => Ecs}/DeployEcsCommandInputs.cs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename source/Calamari.Aws/Inputs/{ => Ecs}/ContainerSpecExtensions.cs (100%) rename source/Calamari.Aws/Inputs/{ => Ecs}/DeployEcsCommandInputs.cs (100%) diff --git a/source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs similarity index 100% rename from source/Calamari.Aws/Inputs/ContainerSpecExtensions.cs rename to source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs diff --git a/source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs similarity index 100% rename from source/Calamari.Aws/Inputs/DeployEcsCommandInputs.cs rename to source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs From e2cc34a73e4a856a8af513e35211743947614591 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:52:45 +1000 Subject: [PATCH 34/80] fix namespaces --- source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs | 4 ++-- source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 2 +- source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 3 +-- .../Integration/Ecs/EcsDeployTemplateGenerator.cs | 1 + .../Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs index ceb4d6352..618668423 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs @@ -1,12 +1,12 @@ using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; using Amazon.CDK.AWS.ECS; using Amazon.ECS; using Octopus.Calamari.Contracts.Aws.Ecs; using LogDriver = Octopus.Calamari.Contracts.Aws.Ecs.LogDriver; -namespace Calamari.Aws.Inputs; +namespace Calamari.Aws.Inputs.Ecs; public static class ContainerSpecExtensions { diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 4c0f51245..c17926a61 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -9,7 +9,7 @@ using Calamari.Common.Plumbing.Variables; using Octopus.Calamari.Contracts.Aws.Ecs; -namespace Calamari.Aws.Inputs; +namespace Calamari.Aws.Inputs.Ecs; public class DeployEcsCommandInputs { diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 55a96f4f1..bdb8bc67c 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -3,8 +3,7 @@ using Amazon.CDK; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.IAM; -using Calamari.Aws.Inputs; - +using Calamari.Aws.Inputs.Ecs; namespace Calamari.Aws.Integration.Ecs; diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index 99de6351d..94d5f2007 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -1,6 +1,7 @@ using System; using Amazon.CDK; using Calamari.Aws.Inputs; +using Calamari.Aws.Inputs.Ecs; using Newtonsoft.Json; namespace Calamari.Aws.Integration.Ecs; diff --git a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs index 03de31047..5e5b2aac6 100644 --- a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Calamari.Aws.Inputs; +using Calamari.Aws.Inputs.Ecs; using FluentAssertions; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; From c03ec4baeaf20536bd14daf7a27b9de88f5d69c0 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:53:22 +1000 Subject: [PATCH 35/80] Move files --- .../{ => Ecs}/ContainerSpecExtensionsTests.cs | 0 .../DeployEcsCommandInputsFixture.cs | 19 +++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) rename source/Calamari.Tests/AWS/Inputs/{ => Ecs}/ContainerSpecExtensionsTests.cs (100%) rename source/Calamari.Tests/AWS/Inputs/{ => Ecs}/DeployEcsCommandInputsFixture.cs (93%) diff --git a/source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs similarity index 100% rename from source/Calamari.Tests/AWS/Inputs/ContainerSpecExtensionsTests.cs rename to source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs diff --git a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs similarity index 93% rename from source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs rename to source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index 9192e9b7a..cbfbdf0e6 100644 --- a/source/Calamari.Tests/AWS/Inputs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -1,10 +1,12 @@ using System; using Calamari.Aws.Deployment; using Calamari.Aws.Inputs; +using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; using FluentAssertions; +using FluentAssertions.Execution; using NSubstitute; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; @@ -420,15 +422,28 @@ public void ServiceTaskName_ReturnsRawNonPrefixedNameValue(bool useExpression) [Test] public void Containers_ReturnsListOfMappedContainers() { + const string containerJson = """ - [{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"nginx","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] + [{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"#{Octopus.Action.Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Containers, containerJson, false); + variables["Octopus.Action.Package[nginx].Image"] = "docker.io/nginx:1.29.1"; var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); var containers = inputs.Containers; - containers.Length.Should().Be(1); + + using (new AssertionScope()) + { + var container = containers[0]; + container.ContainerName.Should().Be("sample-container"); + container.ContainerImageReference.ImageName.Should().Be("docker.io/nginx:1.29.1"); + container.ContainerPortMappings[0].ContainerPort.Should().Be("80"); + container.ContainerPortMappings[0].Protocol.Should().Be(PortProtocol.Tcp); + container.Essential.Should().Be(true.ToString()); + + } + } From 86a2df94793ef2f20fb115aa2cc63896de722fae Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 13:53:58 +1000 Subject: [PATCH 36/80] fix namespaces --- .../AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs | 4 ++-- .../AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs index 5e5b2aac6..a3f138fe5 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs @@ -1,11 +1,11 @@ +using System; using System.Collections.Generic; -using Calamari.Aws.Inputs; using Calamari.Aws.Inputs.Ecs; using FluentAssertions; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; -namespace Calamari.Tests.AWS.Inputs; +namespace Calamari.Tests.AWS.Inputs.Ecs; [TestFixture] public class ContainerSpecExtensionsTests diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index cbfbdf0e6..66b28a610 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -1,6 +1,5 @@ using System; using Calamari.Aws.Deployment; -using Calamari.Aws.Inputs; using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; using Calamari.Common.Plumbing.Logging; @@ -11,7 +10,7 @@ using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; -namespace Calamari.Tests.AWS.Inputs; +namespace Calamari.Tests.AWS.Inputs.Ecs; [TestFixture] public class DeployEcsCommandInputsFixture From d2750a3912360af48dba79787a19ee0bbf1b6746 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 15:09:21 +1000 Subject: [PATCH 37/80] Handles CfnTags --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 + .../Calamari.Aws/Inputs/Ecs/TagExtensions.cs | 18 +++++++ .../Integration/Ecs/EcsDeployTemplate.cs | 4 +- .../Ecs/DeployEcsCommandInputsFixture.cs | 53 ++++++++++++++++++- .../AWS/Inputs/Ecs/TagExtensionTests.cs | 14 +++++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs create mode 100644 source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index c17926a61..957611f3e 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -130,6 +130,8 @@ public string CpuArchitecture public WaitOption WaitOption => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.WaitOption); public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); + + public KeyValuePair[] Tags => variables.GetValueDeserialisedAs[]>(AwsSpecialVariables.Ecs.Tags); } public record InputsValidityResult(IEnumerable MissingKeys) diff --git a/source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs new file mode 100644 index 000000000..a6c2317de --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; +using Amazon.CDK; + +namespace Calamari.Aws.Inputs.Ecs; + +public static class TagExtensions +{ + public static ICfnTag[] ToCloudFormationTags(this IEnumerable> tags) + { + return tags.Select(t => new CfnTag + { + Key = t.Key, + Value = t.Value + }) + .ToArray(); + } +} \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index bdb8bc67c..ffbe05b24 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -113,7 +113,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string CpuArchitecture = commandInputs.CpuArchitecture }, Volumes = Array.Empty(), // TODO: Read from variables - Tags = Array.Empty() // TODO: Read From Varaibles + Tags = commandInputs.Tags.ToCloudFormationTags() }); var service = new CfnService(this, @@ -140,7 +140,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string } }, LoadBalancers = null, // TODO: read from variables - Tags = Array.Empty() // TODO: Read from Variables + Tags = commandInputs.Tags.ToCloudFormationTags() }); // TODO: Add depdency on Load Balancer if require diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index 66b28a610..f2f4e5f20 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using Calamari.Aws.Deployment; using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; @@ -445,7 +447,56 @@ public void Containers_ReturnsListOfMappedContainers() } - + [Test] + public void Tags_WithSingleTag_ReturnsDeserialisedList() + { + const string tagsJson = """[{"key":"Environment","value":"Test"}]"""; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, tagsJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var tags = inputs.Tags; + + tags.Should().HaveCount(1); + tags[0].Key.Should().Be("Environment"); + tags[0].Value.Should().Be("Test"); + } + + [Test] + public void Tags_WithMultipleTags_PreservesAllEntries() + { + const string tagsJson = """ + [ + {"key":"Environment","value":"Production"}, + {"key":"Owner","value":"team-a"}, + {"key":"CostCenter","value":"1234"} + ] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, tagsJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var tags = inputs.Tags; + + tags.Should().HaveCount(3); + tags.Select(t => t.Key).Should().BeEquivalentTo("Environment", "Owner", "CostCenter"); + tags.Single(t => t.Key == "Environment").Value.Should().Be("Production"); + tags.Single(t => t.Key == "Owner").Value.Should().Be("team-a"); + tags.Single(t => t.Key == "CostCenter").Value.Should().Be("1234"); + } + + [Test] + public void Tags_WithEmptyArray_ReturnsEmptyList() + { + var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, "[]", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var tags = inputs.Tags; + + tags.Should().NotBeNull(); + tags.Should().BeEmpty(); + } + + + // Test Helpers static CalamariVariables MinimumRequiredVariableSet() { diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs new file mode 100644 index 000000000..cb7504f62 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class TagExtensionTests +{ + [Test] + public void ToCloudFormationTags() + { + Assert.IsFalse(false); + } +} \ No newline at end of file From a5b60ffead102fc9748ad4de003c6c30df9da527 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 15:15:52 +1000 Subject: [PATCH 38/80] test: fix --- .../AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index f2f4e5f20..17981cf79 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using Calamari.Aws.Deployment; using Calamari.Aws.Inputs.Ecs; @@ -520,6 +519,8 @@ static CalamariVariables MinimumRequiredVariableSet() { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ ["subnet-0650cd8a2119e8abc"] """}, + + {AwsSpecialVariables.Ecs.Deploy.Containers, """[{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"#{Octopus.Action.Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}]"""} From c6104672cebc474721c02e22f5880f8cc310c9c7 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 15:46:35 +1000 Subject: [PATCH 39/80] rework cfn mapping approacj --- ...s.cs => ContainerSpecMappingExtensions.cs} | 2 +- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 + .../Ecs/LoadBalancerMappingExtensions.cs | 20 +++ ...gExtensions.cs => TagMappingExtensions.cs} | 2 +- .../Ecs/TaskExecutionRoleMappingExtensions.cs | 51 ++++++++ .../Integration/Ecs/EcsDeployTemplate.cs | 51 ++------ ...=> ContainerSpecMappingExtensionsTests.cs} | 2 +- .../Ecs/DeployEcsCommandInputsFixture.cs | 53 ++++++++ .../Ecs/LoadBalancerMappingExtensionsTests.cs | 99 +++++++++++++++ ...onTests.cs => TagExtensionMappingTests.cs} | 3 +- ...TaskExecutionRoleMappingExtensionsTests.cs | 114 ++++++++++++++++++ 11 files changed, 351 insertions(+), 48 deletions(-) rename source/Calamari.Aws/Inputs/Ecs/{ContainerSpecExtensions.cs => ContainerSpecMappingExtensions.cs} (99%) create mode 100644 source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs rename source/Calamari.Aws/Inputs/Ecs/{TagExtensions.cs => TagMappingExtensions.cs} (90%) create mode 100644 source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs rename source/Calamari.Tests/AWS/Inputs/Ecs/{ContainerSpecExtensionsTests.cs => ContainerSpecMappingExtensionsTests.cs} (99%) create mode 100644 source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs rename source/Calamari.Tests/AWS/Inputs/Ecs/{TagExtensionTests.cs => TagExtensionMappingTests.cs} (73%) create mode 100644 source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs similarity index 99% rename from source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs rename to source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 618668423..c2206ba4f 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -8,7 +8,7 @@ namespace Calamari.Aws.Inputs.Ecs; -public static class ContainerSpecExtensions +public static class ContainerSpecMappingExtensions { public static T ConvertedOrDefault(this string value, Func converter, Func defaultOverride = null) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 957611f3e..af48afe2e 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -132,6 +132,8 @@ public string CpuArchitecture public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); public KeyValuePair[] Tags => variables.GetValueDeserialisedAs[]>(AwsSpecialVariables.Ecs.Tags); + + public LoadBalancerMapping[] LoadBalancerMappings => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings); } public record InputsValidityResult(IEnumerable MissingKeys) diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs new file mode 100644 index 000000000..a3e590625 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using Amazon.CDK.AWS.ECS; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Aws.Inputs.Ecs; + +public static class LoadBalancerMappingExtensions +{ + public static CfnService.LoadBalancerProperty[] ToLoadBalancerProperties(this IEnumerable loadBalancerMappings) + { + return loadBalancerMappings.Select(lbm => new CfnService.LoadBalancerProperty + { + ContainerName = lbm.ContainerName, + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), + TargetGroupArn = lbm.TargetGroupArn, + }) + .ToArray(); + } +} \ No newline at end of file diff --git a/source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs similarity index 90% rename from source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs rename to source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs index a6c2317de..4951b26bc 100644 --- a/source/Calamari.Aws/Inputs/Ecs/TagExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs @@ -4,7 +4,7 @@ namespace Calamari.Aws.Inputs.Ecs; -public static class TagExtensions +public static class TagMappingExtensions { public static ICfnTag[] ToCloudFormationTags(this IEnumerable> tags) { diff --git a/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs new file mode 100644 index 000000000..02d2787a9 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs @@ -0,0 +1,51 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.IAM; +using Constructs; + +namespace Calamari.Aws.Inputs.Ecs; + +public static class TaskExecutionRoleMappingExtensions +{ + const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; + + public static string MapTaskExecutionRoleArn(this DeployEcsCommandInputs inputs, Construct scope) + { + if (!string.IsNullOrEmpty(inputs.TaskExecutionRole)) + { + return inputs.TaskExecutionRole; + } + + var policyArnParam = new CfnParameter(scope, + "AmazonECSTaskExecutionRolePolicyArn", + new CfnParameterProps + { + Type = "String", + Default = DefaultTaskExecutionPolicyArn + }); + + var role = new CfnRole(scope, + inputs.FallbackTaskExecutionRoleName, + new CfnRoleProps + { + Path = "/", + ManagedPolicyArns = [policyArnParam.ValueAsString], + AssumeRolePolicyDocument = new PolicyDocument(new PolicyDocumentProps + { + Statements = + [ + new PolicyStatement(new PolicyStatementProps + { + Effect = Effect.ALLOW, + Principals = [new ServicePrincipal("ecs-tasks.amazonaws.com")], + Actions = ["sts:AssumeRole"] + + }) + ] + }) + }); + + + return role.Ref; + + } +} \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index ffbe05b24..b29215b8a 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -12,7 +12,7 @@ public sealed class EcsDeployTemplate : Stack const string FargateLaunchType = "FARGATE"; const string AwsVpcNetworkMode = "awsvpc"; const string LinuxOperatingSystemFamily = "LINUX"; - const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) : base(scope, id, props) { @@ -50,7 +50,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Default = commandInputs.Memory }); - var executionRoleRef = ProcessTaskExecutionRole(commandInputs); + var executionRoleRef = commandInputs.MapTaskExecutionRoleArn(this); var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty { @@ -139,51 +139,16 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string SecurityGroups = commandInputs.NetworkSecurityGroupIds } }, - LoadBalancers = null, // TODO: read from variables + LoadBalancers = commandInputs.LoadBalancerMappings.ToLoadBalancerProperties(), Tags = commandInputs.Tags.ToCloudFormationTags() }); // TODO: Add depdency on Load Balancer if require + // if (commandInputs.LoadBalancerMappings.Length > 0) + // { + // service.AddDependency(); + // } service.AddDependency(taskDefinition); } - - string ProcessTaskExecutionRole(DeployEcsCommandInputs inputs) - { - if (!string.IsNullOrEmpty(inputs.TaskExecutionRole)) - { - return inputs.TaskExecutionRole; - } - - var policyArnParam = new CfnParameter(this, - "AmazonECSTaskExecutionRolePolicyArn", - new CfnParameterProps - { - Type = "String", - Default = DefaultTaskExecutionPolicyArn - }); - - var role = new CfnRole(this, - inputs.FallbackTaskExecutionRoleName, - new CfnRoleProps - { - Path = "/", - ManagedPolicyArns = [policyArnParam.ValueAsString], - AssumeRolePolicyDocument = new PolicyDocument(new PolicyDocumentProps - { - Statements = - [ - new PolicyStatement(new PolicyStatementProps - { - Effect = Effect.ALLOW, - Principals = [new ServicePrincipal("ecs-tasks.amazonaws.com")], - Actions = ["sts:AssumeRole"] - - }) - ] - }) - }); - - - return role.Ref; - } + } diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs similarity index 99% rename from source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs rename to source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index a3f138fe5..4177b8353 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -8,7 +8,7 @@ namespace Calamari.Tests.AWS.Inputs.Ecs; [TestFixture] -public class ContainerSpecExtensionsTests +public class ContainerSpecMappingExtensionsTests { [Test] public void ParseMountPoints_WhenNoMountPoints_ReturnsEmptyArray() diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index 17981cf79..1d2dbc91a 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -494,7 +494,60 @@ public void Tags_WithEmptyArray_ReturnsEmptyList() tags.Should().BeEmpty(); } + [Test] + public void LoadBalancerMappings_WithSingleMapping_ReturnsDeserialisedArray() + { + const string mappingsJson = """ + [ + { + "containerName":"web", + "containerPort":"80", + "targetGroupArn":"arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/web/abc" + } + ] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, mappingsJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var mappings = inputs.LoadBalancerMappings; + + mappings.Should().HaveCount(1); + mappings[0].ContainerName.Should().Be("web"); + mappings[0].ContainerPort.Should().Be("80"); + mappings[0].TargetGroupArn.Should().Be("arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/web/abc"); + } + + [Test] + public void LoadBalancerMappings_WithMultipleMappings_PreservesAllEntries() + { + const string mappingsJson = """ + [ + {"containerName":"web","containerPort":"80","targetGroupArn":"arn:web"}, + {"containerName":"api","containerPort":"8080","targetGroupArn":"arn:api"} + ] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, mappingsJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var mappings = inputs.LoadBalancerMappings; + + mappings.Should().HaveCount(2); + mappings.Select(m => m.ContainerName).Should().BeEquivalentTo("web", "api"); + mappings.Single(m => m.ContainerName == "web").ContainerPort.Should().Be("80"); + mappings.Single(m => m.ContainerName == "api").TargetGroupArn.Should().Be("arn:api"); + } + + [Test] + public void LoadBalancerMappings_WithEmptyArray_ReturnsEmpty() + { + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, "[]", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var mappings = inputs.LoadBalancerMappings; + + mappings.Should().NotBeNull(); + mappings.Should().BeEmpty(); + } // Test Helpers static CalamariVariables MinimumRequiredVariableSet() diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs new file mode 100644 index 000000000..68e518975 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs @@ -0,0 +1,99 @@ +using System; +using Calamari.Aws.Inputs.Ecs; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class LoadBalancerMappingExtensionsTests +{ + [Test] + public void ToLoadBalancerProperties_WhenEmpty_ReturnsEmptyArray() + { + var result = Array.Empty().ToLoadBalancerProperties(); + + result.Should().BeEmpty(); + } + + [Test] + public void ToLoadBalancerProperties_MapsAllFields() + { + var mappings = new[] + { + new LoadBalancerMapping + { + ContainerName = "web", + ContainerPort = "80", + TargetGroupArn = "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/web/abc" + } + }; + + var result = mappings.ToLoadBalancerProperties(); + + result.Should().HaveCount(1); + result[0].ContainerName.Should().Be("web"); + result[0].ContainerPort.Should().Be(80); + result[0].TargetGroupArn.Should().Be("arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/web/abc"); + } + + [Test] + public void ToLoadBalancerProperties_WithEmptyContainerPort_ReturnsNullPort() + { + var mappings = new[] + { + new LoadBalancerMapping + { + ContainerName = "web", + ContainerPort = string.Empty, + TargetGroupArn = "arn:tg" + } + }; + + var result = mappings.ToLoadBalancerProperties(); + + result[0].ContainerPort.Should().BeNull(); + } + + [Test] + public void ToLoadBalancerProperties_PreservesOrderAcrossMultipleMappings() + { + var mappings = new[] + { + new LoadBalancerMapping { ContainerName = "web", ContainerPort = "80", TargetGroupArn = "arn:web" }, + new LoadBalancerMapping { ContainerName = "api", ContainerPort = "8080", TargetGroupArn = "arn:api" }, + new LoadBalancerMapping { ContainerName = "admin", ContainerPort = "9090", TargetGroupArn = "arn:admin" } + }; + + var result = mappings.ToLoadBalancerProperties(); + + result.Should().HaveCount(3); + result[0].ContainerName.Should().Be("web"); + result[0].ContainerPort.Should().Be(80); + result[1].ContainerName.Should().Be("api"); + result[1].ContainerPort.Should().Be(8080); + result[2].ContainerName.Should().Be("admin"); + result[2].ContainerPort.Should().Be(9090); + } + + [Test] + public void ToLoadBalancerProperties_PassesThroughContainerNameAndArnVerbatim() + { + // Empty/whitespace strings are mapped through unchanged — no normalisation. + var mappings = new[] + { + new LoadBalancerMapping + { + ContainerName = string.Empty, + ContainerPort = "80", + TargetGroupArn = string.Empty + } + }; + + var result = mappings.ToLoadBalancerProperties(); + + result[0].ContainerName.Should().BeEmpty(); + result[0].TargetGroupArn.Should().BeEmpty(); + } +} diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionMappingTests.cs similarity index 73% rename from source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs rename to source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionMappingTests.cs index cb7504f62..16e8d07aa 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionMappingTests.cs @@ -1,10 +1,9 @@ -using System.Collections.Generic; using NUnit.Framework; namespace Calamari.Tests.AWS.Inputs.Ecs; [TestFixture] -public class TagExtensionTests +public class TagExtensionMappingTests { [Test] public void ToCloudFormationTags() diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs new file mode 100644 index 000000000..1c9a0b274 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs @@ -0,0 +1,114 @@ +using Amazon.CDK; +using Calamari.Aws.Deployment; +using Calamari.Aws.Inputs.Ecs; +using Calamari.Aws.Integration.Ecs; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class TaskExecutionRoleMappingExtensionsTests +{ + readonly ILog fakeLog = Substitute.For(); + readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); + + [Test] + public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleSupplied_ReturnsSuppliedArnVerbatim() + { + const string suppliedArn = "arn:aws:iam::123456789012:role/MyCustomExecutionRole"; + var variables = MinimumRequiredVariableSet(); + variables[AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole] = suppliedArn; + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var app = new App(); + var stack = new Stack(app, "TestStack"); + + var result = inputs.MapTaskExecutionRoleArn(stack); + + result.Should().Be(suppliedArn); + } + + [Test] + public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleSupplied_DoesNotCreateRoleOrPolicyArnParameter() + { + var variables = MinimumRequiredVariableSet(); + variables[AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole] = "arn:aws:iam::123:role/foo"; + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var app = new App(); + var stack = new Stack(app, "TestStack"); + + inputs.MapTaskExecutionRoleArn(stack); + + // CDK always injects a BootstrapVersion parameter, so we can't assert Parameters is empty. + // Instead, assert our specific parameter and resource are absent. + var template = SynthTemplate(app, "TestStack"); + template["Parameters"]?["AmazonECSTaskExecutionRolePolicyArn"].Should().BeNull(); + template["Resources"]?[inputs.FallbackTaskExecutionRoleName].Should().BeNull(); + } + + [Test] + public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleEmpty_ReturnsCfnReferenceToken() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var app = new App(); + var stack = new Stack(app, "TestStack"); + + var result = inputs.MapTaskExecutionRoleArn(stack); + + result.Should().NotBeNullOrEmpty(); + // CDK Ref tokens are unresolved at this point — they start with "${Token[" until synthesis. + result.Should().StartWith("${Token["); + } + + [Test] + public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleEmpty_AddsRoleAndPolicyArnParameterToScope() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + + var app = new App(); + var stack = new Stack(app, "TestStack"); + + inputs.MapTaskExecutionRoleArn(stack); + + var template = SynthTemplate(app, "TestStack"); + template["Parameters"]?["AmazonECSTaskExecutionRolePolicyArn"].Should().NotBeNull(); + template["Resources"]?[inputs.FallbackTaskExecutionRoleName].Should().NotBeNull(); + template["Resources"]?[inputs.FallbackTaskExecutionRoleName]?["Type"]?.Value() + .Should().Be("AWS::IAM::Role"); + } + + static JObject SynthTemplate(App app, string stackName) + { + var template = app.Synth().GetStackByName(stackName).Template; + return JObject.Parse(JsonConvert.SerializeObject(template)); + } + + static CalamariVariables MinimumRequiredVariableSet() + { + return new CalamariVariables + { + { AwsSpecialVariables.Ecs.ClusterName, "MyCluster" }, + { DeploymentEnvironment.Id, "Environment-1" }, + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask" }, + { AwsSpecialVariables.Ecs.Deploy.Cpu, "2" }, + { AwsSpecialVariables.Ecs.Deploy.Memory, "1" }, + { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64" }, + { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1" }, + { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100" }, + { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200" }, + { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "False" }, + { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False" }, + { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }""" }, + { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """["sg-0d5e06a4bde84dabc"]""" }, + { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """["subnet-0650cd8a2119e8abc"]""" } + }; + } +} From fcecb5f41d3cb6cf35576175f0f57e3a46cbeb94 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 27 May 2026 15:50:44 +1000 Subject: [PATCH 40/80] fix namesspace --- .../DeployEcsCloudFormationTemplateConventionFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs index c6d1cce8e..a1ef1658c 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs @@ -1,5 +1,5 @@ using System; -using Calamari.Aws.Inputs; +using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; using Calamari.Common.Plumbing.Logging; From 0192871bd0942acebd2fa04a950ec338899c624f Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 09:41:22 +1000 Subject: [PATCH 41/80] Updated Deploy command implementation. --- .../Commands/DeployEcsServiceCommand.cs | 123 ++---------------- 1 file changed, 13 insertions(+), 110 deletions(-) diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index 42b8765b8..6fb5b3539 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -1,143 +1,46 @@ using System; -using System.Collections.Generic; -using Amazon.CloudFormation; -using Calamari.Aws.Deployment; using Calamari.Aws.Deployment.Conventions; -using Calamari.Aws.Integration.CloudFormation; -using Calamari.Aws.Integration.CloudFormation.Templates; +using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; -using Calamari.Aws.Util; using Calamari.CloudAccounts; using Calamari.Commands.Support; using Calamari.Common.Commands; -using Calamari.Common.Plumbing; -using Calamari.Common.Plumbing.FileSystem; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; -using Calamari.Common.Util; using Calamari.Deployment; -using Newtonsoft.Json; namespace Calamari.Aws.Commands; [Command(CommandName, Description = "Deploys a service to an Amazon ECS cluster")] public class DeployEcsServiceCommand(ILog log, IVariables variables, IEcsStackNameGenerator stackNameGenerator) : Command { - readonly ILog log; - readonly IVariables variables; - readonly ICalamariFileSystem fileSystem; - readonly IEcsStackNameGenerator stackNameGenerator; - string templateFile; - string templateParameterFile; - - public DeployEcsServiceCommand(ILog log, IVariables variables, ICalamariFileSystem fileSystem, IEcsStackNameGenerator stackNameGenerator) - { - this.log = log; - this.variables = variables; - this.fileSystem = fileSystem; - this.stackNameGenerator = stackNameGenerator; - Options.Add("template=", "Path to the CloudFormation template file.", v => templateFile = v); - Options.Add("templateParameters=", "Path to the CloudFormation template parameters JSON file.", v => templateParameterFile = v); - } const string CommandName = "deploy-aws-ecs-service"; public override int Execute(string[] commandLineArguments) { - Options.Parse(commandLineArguments); - - Guard.NotNullOrWhiteSpace(templateFile, "The --template argument is required."); - var environment = AwsEnvironmentGeneration.Create(log, variables).GetAwaiter().GetResult(); - var inputs = ReadAndValidateInputs(); + var inputs = new DeployEcsCommandInputs(variables, stackNameGenerator, log); + var inputValidity = inputs.Validate(); + if (!inputValidity.IsValid) + { + // TODO: Better implementation + throw new CommandException($"Invalid inputs provided to {CommandName}"); + } - var stackArn = new StackArn(inputs.StackName); - var templateResolver = new TemplateResolver(fileSystem); + var cloudFormationDeploymentConvention = new DeployEcsCloudFormationTemplateConventionFactory(inputs, log).GetDeployConvention(); new ConventionProcessor(new RunningDeployment(variables), [ new LogAwsUserInfoConvention(environment), - new DeployAwsCloudFormationConvention(ClientFactory, - TemplateFactory, - new StackEventLogger(log), - _ => stackArn, - inputs.WaitForComplete, - inputs.StackName, - environment, - log, - inputs.WaitTimeout), + cloudFormationDeploymentConvention, new SetEcsOutputVariablesConvention(environment, - inputs.StackName, + inputs.CfStackName, inputs.ClusterName, - inputs.ServiceName, + inputs.ServiceName, // TODO: Check with Sathvik about implementation log) ], log).RunConventions(); return 0; - - IAmazonCloudFormation ClientFactory() => ClientHelpers.CreateCloudFormationClient(environment); - - ICloudFormationRequestBuilder TemplateFactory() => - CloudFormationTemplate.Create(templateResolver, - templateFile, - templateParameterFile, - filesInPackage: false, - fileSystem, - variables, - inputs.StackName, - capabilities: ["CAPABILITY_NAMED_IAM"], - disableRollback: false, - roleArn: null, - tags: inputs.Tags, - stackArn, - ClientFactory); } - - EcsCommandInputs ReadAndValidateInputs() - { - var clusterName = variables.Get(AwsSpecialVariables.Ecs.ClusterName); - Guard.NotNullOrWhiteSpace(clusterName, "Cluster name is required"); - - var serviceName = variables.Get(AwsSpecialVariables.Ecs.ServiceName); - Guard.NotNullOrWhiteSpace(serviceName, "Service name is required"); - - var stackName = variables.Get(AwsSpecialVariables.CloudFormation.StackName); - if (string.IsNullOrWhiteSpace(stackName)) - { - stackName = stackNameGenerator.Generate(variables, clusterName, serviceName); - log.Verbose($"No stack name supplied; generated \"{stackName}\"."); - } - - var userTags = JsonConvert.DeserializeObject>>(variables.Get(AwsSpecialVariables.CloudFormation.Tags) ?? "[]") ?? []; - var tags = EcsDefaultTags.Merge(variables, userTags); - - 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.WaitOptionLegacy.Timeout); - if (waitOptionType == "waitWithTimeout" && !waitOptionTimeoutMs.HasValue) - { - throw new CommandException("Wait option is 'waitWithTimeout' but timeout value is not set."); - } - - return new EcsCommandInputs( - StackName: stackName, - ClusterName: clusterName, - ServiceName: serviceName, - Tags: tags, - WaitForComplete: waitOptionType != "dontWait", - WaitTimeout: waitOptionType == "waitWithTimeout" ? TimeSpan.FromMilliseconds(waitOptionTimeoutMs!.Value) : null); - } - - record EcsCommandInputs( - string StackName, - string ClusterName, - string ServiceName, - List> Tags, - bool WaitForComplete, - TimeSpan? WaitTimeout); -} +} \ No newline at end of file From 9c119f5c75616b4b4191dca5e794d2a8410c6c80 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 12:29:14 +1000 Subject: [PATCH 42/80] Tidy up output variables --- source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index 6fb5b3539..1c8d5ac1f 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -36,7 +36,7 @@ public override int Execute(string[] commandLineArguments) new SetEcsOutputVariablesConvention(environment, inputs.CfStackName, inputs.ClusterName, - inputs.ServiceName, // TODO: Check with Sathvik about implementation + inputs.ServiceTaskName, log) ], log).RunConventions(); From 13116f6a9bf6421f060ddbb5f7a6693eb9a4b55c Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 15:17:15 +1000 Subject: [PATCH 43/80] Minor tweaks to match SPF functionality --- .../Ecs/ContainerSpecMappingExtensions.cs | 19 +++++++--- .../Integration/Ecs/EcsDeployTemplate.cs | 37 ++++++++++++++----- .../ContainerSpecMappingExtensionsTests.cs | 37 +++++++++++-------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index c2206ba4f..77dbb6981 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -40,12 +40,17 @@ public static Dictionary ParseDockerLabels(this ContainerSpec co } - public static Dictionary ParseEnvironmentVariables(this ContainerSpec containerSpec) + public static CfnTaskDefinition.KeyValuePairProperty[] ParseEnvironmentVariables(this ContainerSpec containerSpec) { return containerSpec.EnvironmentVariables .Where(tkp => tkp.Type == KeyValueType.Plain) .GroupBy(kvp => kvp.Key) - .ToDictionary(g => g.Key, g => g.Last().Value); + .Select(g => new CfnTaskDefinition.KeyValuePairProperty + { + Name = g.Key, + Value = g.Last().Value, + }) + .ToArray(); } public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this ContainerSpec containerSpec) @@ -104,7 +109,7 @@ public static CfnTaskDefinition.UlimitProperty[] ParseULimits(this ContainerSpe }).ToArray(); } - return []; + return null; } public static CfnTaskDefinition.MountPointProperty[] ParseMountPoints(this ContainerSpec containerSpec) @@ -120,7 +125,7 @@ public static CfnTaskDefinition.MountPointProperty[] ParseMountPoints(this Conta .ToArray(); } - return []; + return null; } public static CfnTaskDefinition.ContainerDependencyProperty[] ParseDependencies(this ContainerSpec containerSpec) @@ -134,7 +139,7 @@ public static CfnTaskDefinition.ContainerDependencyProperty[] ParseDependencies( }).ToArray(); } - return []; + return null; } public static CfnTaskDefinition.VolumeFromProperty[] ParseVolumesFrom(this ContainerSpec containerSpec) @@ -236,7 +241,7 @@ public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfi public static CfnTaskDefinition.SecretProperty[] ParseSecrets(this ContainerSpec containerSpec) { - return containerSpec.EnvironmentVariables + var secrets = containerSpec.EnvironmentVariables .Where(tkp => tkp.Type == KeyValueType.Secret) .GroupBy(kvp => kvp.Key) // Dedupe .Select(g => new CfnTaskDefinition.SecretProperty() @@ -245,5 +250,7 @@ public static CfnTaskDefinition.SecretProperty[] ParseSecrets(this ContainerSpec ValueFrom = g.Last().Value }) .ToArray(); + + return secrets.Length > 0 ? secrets : null; } } \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index b29215b8a..1c1917280 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -2,7 +2,6 @@ using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; -using Amazon.CDK.AWS.IAM; using Calamari.Aws.Inputs.Ecs; namespace Calamari.Aws.Integration.Ecs; @@ -50,7 +49,21 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Default = commandInputs.Memory }); - var executionRoleRef = commandInputs.MapTaskExecutionRoleArn(this); + var executionRoleArnParam = new CfnParameter(this, + "ExecutionRoleArn", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.MapTaskExecutionRoleArn(this) + }); + + var taskRoleArnParam = new CfnParameter(this, + "TaskRole", + new CfnParameterProps + { + Type = "String", + Default = commandInputs.TaskRole + }); var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty { @@ -69,8 +82,8 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), - Command = c.Command.ConvertedOrDefault(s => [s], () => []), - EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => []), + Command = c.Command.ConvertedOrDefault(s => [s], () => null), + EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => null), ResourceRequirements = c.ParseResourceRequirements(), DockerLabels = c.ParseDockerLabels(), @@ -85,13 +98,17 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string LogConfiguration = c.ParseLogConfiguration(), EnvironmentFiles = c.ParseEnvironmentFiles(), FirelensConfiguration = c.ParseFireLensConfiguration(), - + Environment = c.ParseEnvironmentVariables(), Secrets = c.ParseSecrets(), - Privileged = false, // SPF never set value for this property, so we use default - Links = [], // SPF never set value for this property - DockerSecurityOptions = [] // SPF never set value for this property + // SPF referenced these properties but never set them. + // Due to TS vs. CS SDK differences, we don't even mention them, + // so they won't appear in the final template at all. + // They appear here for consistency + // Privileged = null, + // Links = null, + // DockerSecurityOptions = null }).ToArray(); @@ -103,8 +120,8 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Family = taskFamilyParam.ValueAsString, Cpu = cpuParam.ValueAsString, Memory = memoryParam.ValueAsString, - ExecutionRoleArn = executionRoleRef, - TaskRoleArn = string.IsNullOrEmpty(commandInputs.TaskRole) ? null : commandInputs.TaskRole, + ExecutionRoleArn = executionRoleArnParam.ValueAsString, + TaskRoleArn = taskRoleArnParam.ValueAsString, RequiresCompatibilities = [FargateLaunchType], NetworkMode = AwsVpcNetworkMode, RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index 4177b8353..d69dc8fb9 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -1,9 +1,16 @@ using System; using System.Collections.Generic; +using Amazon.CDK.AWS.ECS; using Calamari.Aws.Inputs.Ecs; using FluentAssertions; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; +using ContainerDependency = Octopus.Calamari.Contracts.Aws.Ecs.ContainerDependency; +using ContainerDependencyCondition = Octopus.Calamari.Contracts.Aws.Ecs.ContainerDependencyCondition; +using ContainerMountPoint = Octopus.Calamari.Contracts.Aws.Ecs.ContainerMountPoint; +using HealthCheck = Octopus.Calamari.Contracts.Aws.Ecs.HealthCheck; +using LogDriver = Octopus.Calamari.Contracts.Aws.Ecs.LogDriver; +using Ulimit = Octopus.Calamari.Contracts.Aws.Ecs.Ulimit; namespace Calamari.Tests.AWS.Inputs.Ecs; @@ -11,13 +18,13 @@ namespace Calamari.Tests.AWS.Inputs.Ecs; public class ContainerSpecMappingExtensionsTests { [Test] - public void ParseMountPoints_WhenNoMountPoints_ReturnsEmptyArray() + public void ParseMountPoints_WhenNoMountPoints_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseMountPoints(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] @@ -97,13 +104,13 @@ public void ParseMountPoints_WithEmptySourceAndPath_ReturnsNulls() } [Test] - public void ParseDependencies_WhenNoDependencies_ReturnsEmptyArray() + public void ParseDependencies_WhenNoDependencies_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseDependencies(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] @@ -278,8 +285,8 @@ public void ParseEnvironmentVariables_MapsKeysToValues() var result = spec.ParseEnvironmentVariables(); result.Should().HaveCount(2); - result["LOG_LEVEL"].Should().Be("INFO"); - result["REGION"].Should().Be("us-east-1"); + result.Should().Contain(x => x.Name == "LOG_LEVEL" && x.Value == "INFO"); + result.Should().Contain(x => x.Name == "REGION" && x.Value == "us-east-1"); } [Test] @@ -298,8 +305,8 @@ public void ParseEnvironmentVariables_WithDuplicateKeys_LastValueWins() var result = spec.ParseEnvironmentVariables(); result.Should().HaveCount(2); - result["LOG_LEVEL"].Should().Be("INFO"); - result["REGION"].Should().Be("us-east-1"); + result.Should().Contain(x => x.Name == "LOG_LEVEL" && x.Value == "INFO"); + result.Should().Contain(x => x.Name == "REGION" && x.Value == "us-east-1"); } [Test] @@ -317,8 +324,8 @@ public void ParseEnvironmentVariables_ExcludesSecretEntries() var result = spec.ParseEnvironmentVariables(); result.Should().HaveCount(1); - result.Should().ContainKey("PLAIN_KEY").WhoseValue.Should().Be("plain-value"); - result.Should().NotContainKey("SECRET_KEY"); + result.Should().Contain(x => x.Name == "PLAIN_KEY" && x.Value == "plain-value"); + result.Should().NotContain(x => x.Name =="SECRET_KEY"); } [Test] @@ -337,17 +344,17 @@ public void ParseEnvironmentVariables_DedupeAppliesAfterFilteringSecrets() var result = spec.ParseEnvironmentVariables(); result.Should().HaveCount(1); - result["TOKEN"].Should().Be("plain-token"); + result.Should().Contain(x => x.Name == "TOKEN" && x.Value == "plain-token"); } [Test] - public void ParseSecrets_WhenNone_ReturnsEmptyArray() + public void ParseSecrets_WhenNone_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseSecrets(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] @@ -533,13 +540,13 @@ public void ParseResourceRequirements_WhenGpusPresent_ReturnsGpuRequirement() } [Test] - public void ParseULimits_WhenNone_ReturnsEmptyArray() + public void ParseULimits_WhenNone_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseULimits(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] From 88b8eabca68506c55bd076876b38ca9398c7acc9 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 16:04:50 +1000 Subject: [PATCH 44/80] more consistency tweaks --- .../Inputs/Ecs/ContainerSpecMappingExtensions.cs | 7 ++++--- .../Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 11 ++++------- .../Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 77dbb6981..45c0cd4d1 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -59,7 +59,7 @@ public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this Con { ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), - Protocol = pm.Protocol.ToString() + Protocol = pm.Protocol.ToString().ToLower(), }) .ToArray(); @@ -92,7 +92,8 @@ public static CfnTaskDefinition.ResourceRequirementProperty[] ParseResourceRequi ? [] : [new CfnTaskDefinition.ResourceRequirementProperty { - Type = ResourceType.GPU + Type = ResourceType.GPU, + Value = containerSpec.Gpus, }]; } @@ -153,7 +154,7 @@ public static CfnTaskDefinition.VolumeFromProperty[] ParseVolumesFrom(this Conta }).ToArray(); } - return []; + return null; } public static CfnTaskDefinition.EnvironmentFileProperty[] ParseEnvironmentFiles(this ContainerSpec containerSpec) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 1c1917280..045dd6d44 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -50,7 +50,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string }); var executionRoleArnParam = new CfnParameter(this, - "ExecutionRoleArn", + "TaskExecutionRole", new CfnParameterProps { Type = "String", @@ -102,6 +102,8 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Environment = c.ParseEnvironmentVariables(), Secrets = c.ParseSecrets(), + + // SPF referenced these properties but never set them. // Due to TS vs. CS SDK differences, we don't even mention them, // so they won't appear in the final template at all. @@ -157,14 +159,9 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string } }, LoadBalancers = commandInputs.LoadBalancerMappings.ToLoadBalancerProperties(), - Tags = commandInputs.Tags.ToCloudFormationTags() + Tags = commandInputs.Tags.ToCloudFormationTags(), }); - // TODO: Add depdency on Load Balancer if require - // if (commandInputs.LoadBalancerMappings.Length > 0) - // { - // service.AddDependency(); - // } service.AddDependency(taskDefinition); } diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index d69dc8fb9..ec4560484 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -179,13 +179,13 @@ public void ParseDependencies_WithMultipleDependencies_PreservesOrder() } [Test] - public void ParseVolumesFrom_WhenNoVolumesFrom_ReturnsEmptyArray() + public void ParseVolumesFrom_WhenNoVolumesFrom_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseVolumesFrom(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] From 4ba3fe6b9a5adb314e3992add6d810238c1f9747 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 16:41:08 +1000 Subject: [PATCH 45/80] Add volumes to template --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 + .../Calamari.Aws/Inputs/Ecs/VolumeMapper.cs | 43 ++++++ .../Integration/Ecs/EcsDeployTemplate.cs | 2 +- .../Ecs/DeployEcsCommandInputsFixture.cs | 80 ++++++++++ .../Ecs/VolumeMappingExtensionsTests.cs | 141 ++++++++++++++++++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs create mode 100644 source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index af48afe2e..42d821eff 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -134,6 +134,8 @@ public string CpuArchitecture public KeyValuePair[] Tags => variables.GetValueDeserialisedAs[]>(AwsSpecialVariables.Ecs.Tags); public LoadBalancerMapping[] LoadBalancerMappings => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings); + + public Volume[] Volumes => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Volumes); } public record InputsValidityResult(IEnumerable MissingKeys) diff --git a/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs new file mode 100644 index 000000000..e3097d292 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs @@ -0,0 +1,43 @@ +using System.Linq; +using Amazon.CDK.AWS.ECS; +using Octopus.Calamari.Contracts.Aws.Ecs; +using Volume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; + +namespace Calamari.Aws.Inputs.Ecs; + +public static class VolumeMappingExtensions +{ + public static CfnTaskDefinition.VolumeProperty[] ParseVolumes(this Volume[] volumes) + { + if (volumes.Length > 0) + { + var boundVolumes = volumes + .Where(v => v.Type == VolumeType.Bind).Select(v => new CfnTaskDefinition.VolumeProperty() + { + Name = v.Name, + + }).ToArray(); + var efsVolumes = volumes + .Where(v => v.Type == VolumeType.Efs) + .Select(v => new CfnTaskDefinition.VolumeProperty + { + Name = v.Name, + EfsVolumeConfiguration = new CfnTaskDefinition.EFSVolumeConfigurationProperty + { + AuthorizationConfig = new CfnTaskDefinition.AuthorizationConfigProperty + { + Iam = v.EfsIamAuthorization.Equals(true.ToString()) ? "ENABLED" : "DISABLED", + AccessPointId = v.AccessPointId + }, + FilesystemId = v.FileSystemId!, + RootDirectory = v.RootDirectory, + TransitEncryption = v.EncryptionInTransit.Equals(true.ToString()) ? "ENABLED" : "DISABLED", + } + }) + .ToArray(); + return boundVolumes.Concat(efsVolumes).ToArray(); + } + + return null; + } +} \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 045dd6d44..f07fff9c9 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -131,7 +131,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string OperatingSystemFamily = LinuxOperatingSystemFamily, CpuArchitecture = commandInputs.CpuArchitecture }, - Volumes = Array.Empty(), // TODO: Read from variables + Volumes = commandInputs.Volumes.ParseVolumes(), Tags = commandInputs.Tags.ToCloudFormationTags() }); diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index 1d2dbc91a..d7782aa91 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -549,6 +549,86 @@ public void LoadBalancerMappings_WithEmptyArray_ReturnsEmpty() mappings.Should().BeEmpty(); } + [Test] + public void Volumes_WithSingleEfsVolume_ReturnsDeserialisedArray() + { + const string volumesJson = """ + [ + { + "type":"efs", + "name":"shared-data", + "fileSystemId":"fs-0123abcd", + "accessPointId":"fsap-0123abcd", + "rootDirectory":"/data", + "encryptionInTransit":"true", + "efsIamAuthorization":"enabled" + } + ] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var volumes = inputs.Volumes; + + volumes.Should().HaveCount(1); + volumes[0].Type.Should().Be(VolumeType.Efs); + volumes[0].Name.Should().Be("shared-data"); + volumes[0].FileSystemId.Should().Be("fs-0123abcd"); + volumes[0].AccessPointId.Should().Be("fsap-0123abcd"); + volumes[0].RootDirectory.Should().Be("/data"); + volumes[0].EncryptionInTransit.Should().Be("true"); + volumes[0].EfsIamAuthorization.Should().Be("enabled"); + } + + [Test] + public void Volumes_WithBindVolume_DeserialisesType() + { + const string volumesJson = """[{"type":"bind","name":"scratch"}]"""; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var volumes = inputs.Volumes; + + volumes.Should().HaveCount(1); + volumes[0].Type.Should().Be(VolumeType.Bind); + volumes[0].Name.Should().Be("scratch"); + volumes[0].FileSystemId.Should().BeNull(); + volumes[0].AccessPointId.Should().BeNull(); + volumes[0].RootDirectory.Should().BeNull(); + } + + [Test] + public void Volumes_WithMultipleVolumes_PreservesAllEntries() + { + const string volumesJson = """ + [ + {"type":"bind","name":"v1"}, + {"type":"efs","name":"v2","fileSystemId":"fs-2"}, + {"type":"bind","name":"v3"} + ] + """; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var volumes = inputs.Volumes; + + volumes.Should().HaveCount(3); + volumes.Select(v => v.Name).Should().BeEquivalentTo("v1", "v2", "v3"); + volumes.Single(v => v.Name == "v2").FileSystemId.Should().Be("fs-2"); + } + + [Test] + public void Volumes_WithEmptyArray_ReturnsEmpty() + { + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, "[]", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var volumes = inputs.Volumes; + + volumes.Should().NotBeNull(); + volumes.Should().BeEmpty(); + } + // Test Helpers static CalamariVariables MinimumRequiredVariableSet() { diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs new file mode 100644 index 000000000..2f8b964b7 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs @@ -0,0 +1,141 @@ +using System; +using Amazon.CDK.AWS.ECS; +using Calamari.Aws.Inputs.Ecs; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; +using Volume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class VolumeMappingExtensionsTests +{ + [Test] + public void ParseVolumes_WhenEmpty_ReturnsNull() + { + var result = Array.Empty().ParseVolumes(); + + result.Should().BeNull(); + } + + [Test] + public void ParseVolumes_WithBindVolume_MapsNameOnly() + { + var volumes = new[] + { + new Volume { Type = VolumeType.Bind, Name = "scratch" } + }; + + var result = volumes.ParseVolumes(); + + result.Should().HaveCount(1); + result[0].Name.Should().Be("scratch"); + result[0].EfsVolumeConfiguration.Should().BeNull(); + } + + [Test] + public void ParseVolumes_WithEfsVolume_MapsFullEfsConfiguration() + { + var volumes = new[] + { + new Volume + { + Type = VolumeType.Efs, + Name = "shared-data", + FileSystemId = "fs-0123abcd", + AccessPointId = "fsap-0123abcd", + RootDirectory = "/data", + EncryptionInTransit = "True", + EfsIamAuthorization = "True" + } + }; + + var result = volumes.ParseVolumes(); + + result.Should().HaveCount(1); + result![0].Name.Should().Be("shared-data"); + + var efs = result[0].EfsVolumeConfiguration.Should() + .BeOfType().Subject; + efs.FilesystemId.Should().Be("fs-0123abcd"); + efs.RootDirectory.Should().Be("/data"); + efs.TransitEncryption.Should().Be("ENABLED"); + + var auth = efs.AuthorizationConfig.Should() + .BeOfType().Subject; + auth.Iam.Should().Be("ENABLED"); + auth.AccessPointId.Should().Be("fsap-0123abcd"); + } + + [Test] + public void ParseVolumes_EfsVolume_DefaultsTransitEncryptionAndIamToDisabled() + { + var volumes = new[] + { + new Volume + { + Type = VolumeType.Efs, + Name = "shared-data", + FileSystemId = "fs-0123abcd", + EncryptionInTransit = string.Empty, + EfsIamAuthorization = string.Empty + } + }; + + var result = volumes.ParseVolumes(); + + var efs = result![0].EfsVolumeConfiguration.Should() + .BeOfType().Subject; + efs.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig.Should() + .BeOfType() + .Which.Iam.Should().Be("DISABLED"); + } + + [Test] + public void ParseVolumes_EfsVolume_TransitEncryptionAndIamAreCaseSensitive() + { + // The implementation compares to true.ToString() == "True" — lowercase "true" should not enable. + var volumes = new[] + { + new Volume + { + Type = VolumeType.Efs, + Name = "shared-data", + FileSystemId = "fs-0123abcd", + EncryptionInTransit = "true", + EfsIamAuthorization = "true" + } + }; + + var result = volumes.ParseVolumes(); + + var efs = result![0].EfsVolumeConfiguration.Should() + .BeOfType().Subject; + efs.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig.Should() + .BeOfType() + .Which.Iam.Should().Be("DISABLED"); + } + + [Test] + public void ParseVolumes_OrdersBindVolumesBeforeEfsVolumes() + { + var volumes = new[] + { + new Volume { Type = VolumeType.Efs, Name = "efs-1", FileSystemId = "fs-1", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, + new Volume { Type = VolumeType.Bind, Name = "bind-1" }, + new Volume { Type = VolumeType.Efs, Name = "efs-2", FileSystemId = "fs-2", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, + new Volume { Type = VolumeType.Bind, Name = "bind-2" } + }; + + var result = volumes.ParseVolumes(); + + result.Should().HaveCount(4); + result[0].Name.Should().Be("bind-1"); + result[1].Name.Should().Be("bind-2"); + result[2].Name.Should().Be("efs-1"); + result[3].Name.Should().Be("efs-2"); + } +} From 372a13bcb689e44160ddeaefc197a078159e4cbb Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 16:48:36 +1000 Subject: [PATCH 46/80] Fix entry point --- source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index f07fff9c9..817fbb82d 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -83,7 +83,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), Command = c.Command.ConvertedOrDefault(s => [s], () => null), - EntryPoint = c.EntryPoint.ConvertedOrDefault(s => [s], () => null), + EntryPoint = c.EntryPoint.ConvertedOrDefault(input => input.Split(',').Select(s => s.Trim()).ToArray(), () => null), ResourceRequirements = c.ParseResourceRequirements(), DockerLabels = c.ParseDockerLabels(), From 190d8326f357b378f2dbfdaa3776948e707be080 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 16:51:02 +1000 Subject: [PATCH 47/80] Comments about Access point --- source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs index e3097d292..c5c7250b7 100644 --- a/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs +++ b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs @@ -27,7 +27,9 @@ public static CfnTaskDefinition.VolumeProperty[] ParseVolumes(this Volume[] volu AuthorizationConfig = new CfnTaskDefinition.AuthorizationConfigProperty { Iam = v.EfsIamAuthorization.Equals(true.ToString()) ? "ENABLED" : "DISABLED", - AccessPointId = v.AccessPointId + //SPF didn't appear to be outputting this, we're adding here because it seems correct to + // No one ever complained, so we may not have customers leveraging Efs IAM Auth? + AccessPointId = v.AccessPointId }, FilesystemId = v.FileSystemId!, RootDirectory = v.RootDirectory, From e77f8cbf8db8ad68311c03aaca6e41a0109dc162 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 17:07:00 +1000 Subject: [PATCH 48/80] style: cleanup --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 42d821eff..012148288 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -30,7 +30,7 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Cpu); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Memory); - + // primitives // TODO: Type checking // TODO: Defaults? @@ -40,43 +40,22 @@ public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stack requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags); - + // collections requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SubnetIds); - + // Objects requiredVariableKeys.Add(AwsSpecialVariables.Ecs.WaitOption); requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Containers); - - - } - - public InputsValidityResult Validate() - { - var variableNames = variables.GetNames(); - var missingKeys = requiredVariableKeys.Except(variableNames); - - // TODO: Validation of input values - - - return new InputsValidityResult(missingKeys); } public string ClusterName => variables.Get(AwsSpecialVariables.Ecs.ClusterName); public string ServiceTaskName => variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); -#pragma warning disable CS0618 // Type or member is obsolete temporary SPF deprecation - public string ServiceName => $"Service{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; - - public string TaskName => $"TaskDefinition{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; - - public string FallbackTaskExecutionRoleName => $"TaskExecutionRole{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; -#pragma warning restore CS0618 // Type or member is obsolete - - - public string CfStackName { + public string CfStackName + { get { var stackNameValue = variables.Get(AwsSpecialVariables.Ecs.Deploy.StackName); @@ -85,14 +64,13 @@ public string CfStackName { stackNameValue = stackNameGenerator.Generate(ClusterName, ServiceName, Environment, Tenant); log.Verbose($"No stack name supplied; generated \"{stackNameValue}\"."); } + return stackNameValue; } } - - public StackArn CfStackArn => new(CfStackName); //Look at why we even need this? public string Environment => variables.GetMandatoryVariable(DeploymentEnvironment.Id); - + public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); public string Cpu => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Cpu); @@ -102,28 +80,27 @@ public string CfStackName { public double DesiredCount => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); - + public string AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp) ? "ENABLED" : "DISABLED"; public bool EnableEcsManagedTags => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags); public string TaskRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskRole, ""); public string TaskExecutionRole => variables.Get(AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, ""); - + public string CpuArchitecture { get { var cpuArchValue = variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform); return cpuArchValue.ToUpper() switch - { - "ARM64" => "ARM64", - _ => "X86_64" // default - }; + { + "ARM64" => "ARM64", + _ => "X86_64" // default + }; } } - public string[] NetworkSecurityGroupIds => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds); public string[] SubnetIDs => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.SubnetIds); @@ -132,13 +109,33 @@ public string CpuArchitecture public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); public KeyValuePair[] Tags => variables.GetValueDeserialisedAs[]>(AwsSpecialVariables.Ecs.Tags); - + public LoadBalancerMapping[] LoadBalancerMappings => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings); public Volume[] Volumes => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Volumes); + + public bool RequiresLogGroup => Containers.Any(c => c.ContainerLogging.Type == ContainerLoggingType.Auto); + + public InputsValidityResult Validate() + { + var variableNames = variables.GetNames(); + var missingKeys = requiredVariableKeys.Except(variableNames); + + // TODO: Validation of input values + + return new InputsValidityResult(missingKeys); + } + +#pragma warning disable CS0618 // Type or member is obsolete temporary SPF deprecation + public string ServiceName => $"Service{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string TaskName => $"TaskDefinition{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string FallbackTaskExecutionRoleName => $"TaskExecutionRole{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; +#pragma warning restore CS0618 // Type or member is obsolete } public record InputsValidityResult(IEnumerable MissingKeys) { public bool IsValid => !MissingKeys.Any(); -} +} \ No newline at end of file From 5dc525206909de29fd3f463b292a0db00d39acbf Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 28 May 2026 17:24:44 +1000 Subject: [PATCH 49/80] Enable logging --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 ++ .../Integration/Ecs/EcsDeployTemplate.cs | 26 ++++++++++++++-- .../Ecs/DeployEcsCommandInputsFixture.cs | 31 ++++++++++--------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 012148288..90fa857ea 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -132,6 +132,8 @@ public InputsValidityResult Validate() public string TaskName => $"TaskDefinition{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; public string FallbackTaskExecutionRoleName => $"TaskExecutionRole{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string LogGroupName => $"AwsLogGroup{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 817fbb82d..6831c0fa6 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -2,7 +2,10 @@ using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; +using Amazon.CDK.AWS.Logs; +using Calamari.Aws.Deployment; using Calamari.Aws.Inputs.Ecs; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Aws.Integration.Ecs; @@ -65,6 +68,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string Default = commandInputs.TaskRole }); + var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty { Name = c.ContainerName, @@ -100,9 +104,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string FirelensConfiguration = c.ParseFireLensConfiguration(), Environment = c.ParseEnvironmentVariables(), - Secrets = c.ParseSecrets(), - - + Secrets = c.ParseSecrets() // SPF referenced these properties but never set them. // Due to TS vs. CS SDK differences, we don't even mention them, @@ -114,6 +116,24 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string }).ToArray(); + if (commandInputs.RequiresLogGroup) + { + var logGroupNameParam = new CfnParameter(this, + "LogGroupName", + new CfnParameterProps + { + Type = "String", + Default = $"/ecs/{commandInputs.ServiceTaskName}" + }); + + _ = new CfnLogGroup(this, + commandInputs.LogGroupName, + new CfnLogGroupProps + { + LogGroupName = logGroupNameParam.ValueAsString + }); + } + var taskDefinition = new CfnTaskDefinition(this, commandInputs.TaskName, new CfnTaskDefinitionProps diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index d7782aa91..359dd2df4 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -138,19 +138,6 @@ public void Tenant_WithTenantVariable_ReturnsTenantId() stackName.Should().Be(expectedTenantId); } - [Test] - public void CfStackArn_ReturnsCorrectlyFormattedArnForStackName() - { - var variables = MinimumRequiredVariableSet(); - const string expectedStackName = "MyGeneratedStack"; - fakeStackNameGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(expectedStackName); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - - var stackArn = inputs.CfStackArn; - - stackArn.Value.Should().EndWith(expectedStackName); - } - [Test] [TestCase(true)] [TestCase(false)] @@ -174,12 +161,26 @@ public void TaskName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) const string expectedServiceTaskName = "MyNewEcsServiceTask"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - + var taskName = inputs.TaskName; - + taskName.Should().Be("TaskDefinitionmyNewEcsServiceTask"); } + [Test] + [TestCase(true)] + [TestCase(false)] + public void LogGroupName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) + { + const string expectedServiceTaskName = "MyNewEcsServiceTask"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var logGroupName = inputs.LogGroupName; + + logGroupName.Should().Be("AwsLogGroupmyNewEcsServiceTask"); + } + [Test] [TestCase(true)] [TestCase(false)] From 39ecb950625b3f43868c19fdc69f5cef07021e77 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 10:50:41 +1000 Subject: [PATCH 50/80] style: grammar --- .../Deployment/Conventions/DeployAwsCloudFormationConvention.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployAwsCloudFormationConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployAwsCloudFormationConvention.cs index 9a56d6fa9..2ae4c62bc 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployAwsCloudFormationConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployAwsCloudFormationConvention.cs @@ -219,7 +219,7 @@ async Task UpdateCloudFormation( /// /// Not all exceptions are bad. Some just mean there is nothing to do, which is fine. - /// This method will ignore expected exceptions, and rethrow any that are really issues. + /// This method will ignore expected exceptions and rethrow any that are really issues. /// /// The exception we need to deal with /// The supplied exception if it really is an error From 34a897e8633051e0bfe9362c49f64fda1b2a7647 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 10:51:25 +1000 Subject: [PATCH 51/80] Add ShouldWaitForDeploymentCompletion property --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 ++ .../Ecs/DeployEcsCommandInputsFixture.cs | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 90fa857ea..058471e81 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -116,6 +116,8 @@ public string CpuArchitecture public bool RequiresLogGroup => Containers.Any(c => c.ContainerLogging.Type == ContainerLoggingType.Auto); + public bool ShouldWaitForDeploymentCompletion => WaitOption.Type == WaitType.WaitUntilCompleted || WaitOption.Type == WaitType.WaitWithTimeout; + public InputsValidityResult Validate() { var variableNames = variables.GetNames(); diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index 359dd2df4..ebaa55fcc 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -277,6 +277,26 @@ public void WaitOption_IsDeserialisedAndReturned() result.Type.Should().Be(WaitType.WaitUntilCompleted); result.TimeoutMinutes.Should().BeNull(); } + + [Test] + public void ShouldWaitForDeploymentCompletion_WhenDontWait_ReturnsFalse() + { + var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, """{ "type": "dontWait" }""", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + inputs.ShouldWaitForDeploymentCompletion.Should().BeFalse(); + } + + [Test] + [TestCase("waitUntilCompleted")] + [TestCase("waitWithTimeout")] + public void ShouldWaitForDeploymentCompletion_WhenWaiting_ReturnsTrue(string waitType) + { + var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, $$"""{ "type": "{{waitType}}", "timeoutMinutes": "30" }""", false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + inputs.ShouldWaitForDeploymentCompletion.Should().BeTrue(); + } [Test] [TestCase(true)] From 2efff4bb6f7023f0a6c9db3b6d4c7766ebd86249 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 11:56:50 +1000 Subject: [PATCH 52/80] rename and wireup --- .../Commands/DeployEcsServiceCommand.cs | 3 +- ...CloudFormationTemplateConventionFactory.cs | 55 ---------------- .../Conventions/DeployEcsServiceConvention.cs | 65 +++++++++++++++++++ .../Inputs/Ecs/DeployEcsCommandInputs.cs | 2 + 4 files changed, 68 insertions(+), 57 deletions(-) delete mode 100644 source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs create mode 100644 source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index 1c8d5ac1f..bc3a2c63a 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -27,12 +27,11 @@ public override int Execute(string[] commandLineArguments) throw new CommandException($"Invalid inputs provided to {CommandName}"); } - var cloudFormationDeploymentConvention = new DeployEcsCloudFormationTemplateConventionFactory(inputs, log).GetDeployConvention(); new ConventionProcessor(new RunningDeployment(variables), [ new LogAwsUserInfoConvention(environment), - cloudFormationDeploymentConvention, + new DeployEcsServiceConvention(inputs, environment, log, variables), new SetEcsOutputVariablesConvention(environment, inputs.CfStackName, inputs.ClusterName, diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs deleted file mode 100644 index a1ef1658c..000000000 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsCloudFormationTemplateConventionFactory.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using Calamari.Aws.Inputs.Ecs; -using Calamari.Aws.Integration.Ecs; -using Calamari.Common.Plumbing.Logging; - -namespace Calamari.Aws.Deployment.Conventions; - -public class DeployEcsCloudFormationTemplateConventionFactory(DeployEcsCommandInputs commandInputs, /*AwsEnvironmentGeneration awsEnvironment,*/ ILog log) -{ - public DeployAwsCloudFormationConvention GetDeployConvention() => BuildCloudFormationDeploymentConvention(); - - DeployAwsCloudFormationConvention BuildCloudFormationDeploymentConvention() - { - - var template = EcsDeployTemplateGenerator.GenerateTemplate(commandInputs); - - // new DeployAwsCloudFormationConvention(ClientFactory, - // TemplateFactory, - // new StackEventLogger(log), - // _ => stackArn, - // _ => null, - // inputs.WaitForComplete, - // inputs.StackName, - // environment, - // log, - // inputs.WaitTimeout), - - if (log == null) - { - Console.WriteLine("Take that \"this can be made static\' warning"); - } - - return null; - - - // IAmazonCloudFormation ClientFactory() => ClientHelpers.CreateCloudFormationClient(awsEnvironment); - - // ICloudFormationRequestBuilder TemplateFactory() => - // CloudFormationTemplate.Create(templateResolver, - // templateFile, - // templateParameterFile, - // filesInPackage: false, - // fileSystem, - // variables, - // inputs.StackName, - // capabilities: ["CAPABILITY_NAMED_IAM"], - // disableRollback: false, - // roleArn: null, - // tags: inputs.Tags, - // stackArn, - // ClientFactory); - } - -} - diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs new file mode 100644 index 000000000..1e7f7093f --- /dev/null +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; +using Calamari.Aws.Inputs.Ecs; +using Calamari.Aws.Integration.CloudFormation; +using Calamari.Aws.Integration.CloudFormation.Templates; +using Calamari.Aws.Integration.Ecs; +using Calamari.Aws.Util; +using Calamari.CloudAccounts; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; +using Calamari.Common.Util; +using Calamari.Deployment.Conventions; + +namespace Calamari.Aws.Deployment.Conventions; + +// Currently a thin wrapper over existing template deployment process with the goal to swapping it out for native ECS API solution in the future. +public class DeployEcsServiceConvention: IInstallConvention +{ + readonly DeployAwsCloudFormationConvention encapsulatedConvention; + + public DeployEcsServiceConvention(DeployEcsCommandInputs commandInputs, AwsEnvironmentGeneration awsEnvironment, ILog log, IVariables variables) + { + var stackEventLogger = new StackEventLogger(log); + + encapsulatedConvention = new DeployAwsCloudFormationConvention(ClientFactory, + TemplateFactory, + stackEventLogger, + StackProvider, + commandInputs.ShouldWaitForDeploymentCompletion, + commandInputs.CfStackName, + awsEnvironment, + log, + commandInputs.WaitOption.GetTimeoutSpan() + ); + return; + + StackArn StackProvider(RunningDeployment _) => commandInputs.CfStackArn; + IAmazonCloudFormation ClientFactory() => ClientHelpers.CreateCloudFormationClient(awsEnvironment); + + ICloudFormationRequestBuilder TemplateFactory() + { + return new CloudFormationTemplate(() => EcsDeployTemplateGenerator.GenerateTemplate(commandInputs), + new EmptyTemplateInputs(), + commandInputs.CfStackName, + ["CAPABILITY_NAMED_IAM"], + true, + null, + commandInputs.Tags, + commandInputs.CfStackArn, + ClientFactory, + variables); + } + + } + + public void Install(RunningDeployment deployment) + { + encapsulatedConvention.Install(deployment); + } + +} + diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 058471e81..6b2ed86c6 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -69,6 +69,8 @@ public string CfStackName } } + public StackArn CfStackArn => new StackArn(CfStackName); + public string Environment => variables.GetMandatoryVariable(DeploymentEnvironment.Id); public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); From fe16a2257a5cf18b6d183111ddc9be4388625b44 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 12:08:25 +1000 Subject: [PATCH 53/80] Refactor convention --- .../Conventions/DeployEcsServiceConvention.cs | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs index 1e7f7093f..6a389906e 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Amazon.CloudFormation; using Amazon.CloudFormation.Model; using Calamari.Aws.Inputs.Ecs; @@ -17,49 +16,42 @@ namespace Calamari.Aws.Deployment.Conventions; // Currently a thin wrapper over existing template deployment process with the goal to swapping it out for native ECS API solution in the future. -public class DeployEcsServiceConvention: IInstallConvention +public class DeployEcsServiceConvention(DeployEcsCommandInputs commandInputs, AwsEnvironmentGeneration awsEnvironment, ILog log, IVariables variables) + : IInstallConvention { - readonly DeployAwsCloudFormationConvention encapsulatedConvention; - - public DeployEcsServiceConvention(DeployEcsCommandInputs commandInputs, AwsEnvironmentGeneration awsEnvironment, ILog log, IVariables variables) + public void Install(RunningDeployment deployment) { + var template = EcsDeployTemplateGenerator.GenerateTemplate(commandInputs); var stackEventLogger = new StackEventLogger(log); - encapsulatedConvention = new DeployAwsCloudFormationConvention(ClientFactory, - TemplateFactory, - stackEventLogger, - StackProvider, + var deployCloudFormationConvention = new DeployAwsCloudFormationConvention(ClientFactory, + TemplateFactory, + stackEventLogger, + StackProvider, commandInputs.ShouldWaitForDeploymentCompletion, commandInputs.CfStackName, awsEnvironment, log, commandInputs.WaitOption.GetTimeoutSpan() - ); + ); + deployCloudFormationConvention.Install(deployment); return; StackArn StackProvider(RunningDeployment _) => commandInputs.CfStackArn; IAmazonCloudFormation ClientFactory() => ClientHelpers.CreateCloudFormationClient(awsEnvironment); - + ICloudFormationRequestBuilder TemplateFactory() { - return new CloudFormationTemplate(() => EcsDeployTemplateGenerator.GenerateTemplate(commandInputs), + return new CloudFormationTemplate(() => template, new EmptyTemplateInputs(), commandInputs.CfStackName, ["CAPABILITY_NAMED_IAM"], - true, + false, null, commandInputs.Tags, commandInputs.CfStackArn, ClientFactory, variables); } - - } - - public void Install(RunningDeployment deployment) - { - encapsulatedConvention.Install(deployment); } - -} - +} \ No newline at end of file From 0fe1c99101afdb0eb3b3955801c7002f8a85bf1c Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 12:28:01 +1000 Subject: [PATCH 54/80] Make template generator non static --- .../Conventions/DeployEcsServiceConvention.cs | 5 +++- .../Ecs/EcsDeployTemplateGenerator.cs | 30 +++++++++---------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs index 6a389906e..675f372f7 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -19,9 +19,12 @@ namespace Calamari.Aws.Deployment.Conventions; public class DeployEcsServiceConvention(DeployEcsCommandInputs commandInputs, AwsEnvironmentGeneration awsEnvironment, ILog log, IVariables variables) : IInstallConvention { + readonly EcsDeployTemplateGenerator templateGenerator = new(commandInputs); + + public void Install(RunningDeployment deployment) { - var template = EcsDeployTemplateGenerator.GenerateTemplate(commandInputs); + var template = templateGenerator.GenerateTemplate(); var stackEventLogger = new StackEventLogger(log); var deployCloudFormationConvention = new DeployAwsCloudFormationConvention(ClientFactory, diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index 94d5f2007..e1a904259 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -6,28 +6,25 @@ namespace Calamari.Aws.Integration.Ecs; -public static class EcsDeployTemplateGenerator +public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) { - public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) + readonly App app = new(); + readonly IStackProps stackProps = new StackProps { - var stackName = commandInputs.CfStackName; - - var app = new App(); - - var stackProps = new StackProps + Synthesizer = new DefaultStackSynthesizer(new DefaultStackSynthesizerProps { - Synthesizer = new DefaultStackSynthesizer(new DefaultStackSynthesizerProps - { - // This flag kills the Rules assertion section and the bootstrap version parameter completely - GenerateBootstrapVersionRule = false - }) - }; - - _ = new EcsDeployTemplate(commandInputs, app, stackName, stackProps); + // This flag kills the Rules assertion section and the bootstrap version parameter completely + GenerateBootstrapVersionRule = false + }) + }; + + public string GenerateTemplate() + { + _ = new EcsDeployTemplate(commandInputs, app, commandInputs.CfStackName, stackProps); var assembly = app.Synth(); - var stackArtifact = assembly.GetStackByName(stackName); + var stackArtifact = assembly.GetStackByName(commandInputs.CfStackName); var settings = new JsonSerializerSettings { @@ -39,6 +36,7 @@ public static string GenerateTemplate(DeployEcsCommandInputs commandInputs) return JsonConvert.SerializeObject(stackArtifact.Template, settings); } + class WholeDoubleConverter : JsonConverter { From 3377a1c5b47bf555d0e1dd3e03c3e90ce8119ce4 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 12:52:08 +1000 Subject: [PATCH 55/80] Map log group path --- source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 2 ++ source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 6b2ed86c6..6f6719589 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -138,6 +138,8 @@ public InputsValidityResult Validate() public string FallbackTaskExecutionRoleName => $"TaskExecutionRole{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; public string LogGroupName => $"AwsLogGroup{variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName).CamelCase()}"; + + public string DefaultLogGroupPath => $"/ecs/{ServiceTaskName}"; #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 6831c0fa6..387974e4e 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -123,7 +123,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string new CfnParameterProps { Type = "String", - Default = $"/ecs/{commandInputs.ServiceTaskName}" + Default = commandInputs.DefaultLogGroupPath }); _ = new CfnLogGroup(this, From 63f9880f28b380991ed14f3eaebc2303bcbb000f Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 29 May 2026 12:53:44 +1000 Subject: [PATCH 56/80] Handle parameter passing --- .../Conventions/DeployEcsServiceConvention.cs | 7 +- .../Inputs/Ecs/EcsTemplateParameterNames.cs | 15 +++ .../Integration/Ecs/EcsDeployTemplate.cs | 92 ++++++------------- .../Ecs/EcsDeployTemplateGenerator.cs | 59 ++++++++++-- .../Ecs/EcsDeployTemplateGeneratorTests.cs | 61 ++++++++++++ 5 files changed, 157 insertions(+), 77 deletions(-) create mode 100644 source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs create mode 100644 source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs index 675f372f7..570f39fa3 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -10,7 +10,6 @@ using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; -using Calamari.Common.Util; using Calamari.Deployment.Conventions; namespace Calamari.Aws.Deployment.Conventions; @@ -24,7 +23,7 @@ public class DeployEcsServiceConvention(DeployEcsCommandInputs commandInputs, Aw public void Install(RunningDeployment deployment) { - var template = templateGenerator.GenerateTemplate(); + var generated = templateGenerator.Generate(); var stackEventLogger = new StackEventLogger(log); var deployCloudFormationConvention = new DeployAwsCloudFormationConvention(ClientFactory, @@ -45,8 +44,8 @@ public void Install(RunningDeployment deployment) ICloudFormationRequestBuilder TemplateFactory() { - return new CloudFormationTemplate(() => template, - new EmptyTemplateInputs(), + return new CloudFormationTemplate(() => generated.Body, + new ListTemplateInputs(generated.Parameters), commandInputs.CfStackName, ["CAPABILITY_NAMED_IAM"], false, diff --git a/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs b/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs new file mode 100644 index 000000000..3036fc807 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs @@ -0,0 +1,15 @@ +namespace Calamari.Aws.Inputs.Ecs; + +// CloudFormation parameter names used by EcsDeployTemplate. Shared between the +// generator (which sets Defaults and supplies override values at deploy time) +// and the template (which looks them up by name when wiring CDK Refs). +public static class EcsTemplateParameterNames +{ + public const string ClusterName = "ClusterName"; + public const string TaskDefinitionName = "TaskDefinitionName"; + public const string TaskDefinitionCpu = "TaskDefinitionCPU"; + public const string TaskDefinitionMemory = "TaskDefinitionMemory"; + public const string TaskRole = "TaskRole"; + public const string TaskExecutionRole = "TaskExecutionRole"; + public const string LogGroupName = "LogGroupName"; +} diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs index 387974e4e..3de88c724 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs @@ -1,9 +1,8 @@ -using System; +using System.Collections.Generic; using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.Logs; -using Calamari.Aws.Deployment; using Calamari.Aws.Inputs.Ecs; using Octopus.Calamari.Contracts.Aws.Ecs; @@ -16,59 +15,32 @@ public sealed class EcsDeployTemplate : Stack const string LinuxOperatingSystemFamily = "LINUX"; - public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string id, IStackProps props = null) : base(scope, id, props) + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, + IReadOnlyList<(string Name, string Value)> parameters, + App scope, + string id, + IStackProps props = null) : base(scope, id, props) { TemplateOptions.TemplateFormatVersion = "2010-09-09"; - var clusterNameParam = new CfnParameter(this, - "ClusterName", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.ClusterName - }); + var paramRefs = parameters.ToDictionary( + p => p.Name, + p => new CfnParameter(this, + p.Name, + new CfnParameterProps + { + Type = "String", + Default = p.Value + })); - var taskFamilyParam = new CfnParameter(this, - "TaskDefinitionName", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.ServiceTaskName - }); + // ExecutionRoleArn: parameter when user-supplied (in `paramRefs`), in-template + // role otherwise. The role can't be known at request time because CFN creates + // it during the same deploy, so it can't sit behind a parameter override. + var executionRoleArnRef = paramRefs.TryGetValue(EcsTemplateParameterNames.TaskExecutionRole, out var execRoleParam) + ? execRoleParam.ValueAsString + : commandInputs.MapTaskExecutionRoleArn(this); - var cpuParam = new CfnParameter(this, - "TaskDefinitionCPU", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.Cpu - }); - var memoryParam = new CfnParameter(this, - "TaskDefinitionMemory", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.Memory - }); - - var executionRoleArnParam = new CfnParameter(this, - "TaskExecutionRole", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.MapTaskExecutionRoleArn(this) - }); - - var taskRoleArnParam = new CfnParameter(this, - "TaskRole", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.TaskRole - }); - - var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty { Name = c.ContainerName, @@ -118,19 +90,11 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string if (commandInputs.RequiresLogGroup) { - var logGroupNameParam = new CfnParameter(this, - "LogGroupName", - new CfnParameterProps - { - Type = "String", - Default = commandInputs.DefaultLogGroupPath - }); - _ = new CfnLogGroup(this, commandInputs.LogGroupName, new CfnLogGroupProps { - LogGroupName = logGroupNameParam.ValueAsString + LogGroupName = paramRefs[EcsTemplateParameterNames.LogGroupName].ValueAsString }); } @@ -139,11 +103,11 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string new CfnTaskDefinitionProps { ContainerDefinitions = containers, - Family = taskFamilyParam.ValueAsString, - Cpu = cpuParam.ValueAsString, - Memory = memoryParam.ValueAsString, - ExecutionRoleArn = executionRoleArnParam.ValueAsString, - TaskRoleArn = taskRoleArnParam.ValueAsString, + Family = paramRefs[EcsTemplateParameterNames.TaskDefinitionName].ValueAsString, + Cpu = paramRefs[EcsTemplateParameterNames.TaskDefinitionCpu].ValueAsString, + Memory = paramRefs[EcsTemplateParameterNames.TaskDefinitionMemory].ValueAsString, + ExecutionRoleArn = executionRoleArnRef, + TaskRoleArn = paramRefs[EcsTemplateParameterNames.TaskRole].ValueAsString, RequiresCompatibilities = [FargateLaunchType], NetworkMode = AwsVpcNetworkMode, RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty @@ -159,7 +123,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, App scope, string commandInputs.ServiceName, new CfnServiceProps { - Cluster = clusterNameParam.ValueAsString, + Cluster = paramRefs[EcsTemplateParameterNames.ClusterName].ValueAsString, LaunchType = FargateLaunchType, TaskDefinition = taskDefinition.Ref, DesiredCount = commandInputs.DesiredCount, diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs index e1a904259..402c2d165 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs @@ -1,11 +1,21 @@ using System; +using System.Collections.Generic; +using System.Linq; using Amazon.CDK; -using Calamari.Aws.Inputs; +using Amazon.CloudFormation.Model; using Calamari.Aws.Inputs.Ecs; +using Calamari.Common.Util; using Newtonsoft.Json; namespace Calamari.Aws.Integration.Ecs; +public record GeneratedTemplate(string Body, IReadOnlyList Parameters); + +public class ListTemplateInputs(IEnumerable inputs) : ITemplateInputs +{ + public IEnumerable Inputs { get; } = inputs.ToList(); +} + public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) { readonly App app = new(); @@ -17,13 +27,14 @@ public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) GenerateBootstrapVersionRule = false }) }; - - public string GenerateTemplate() + + public GeneratedTemplate Generate() { - _ = new EcsDeployTemplate(commandInputs, app, commandInputs.CfStackName, stackProps); + var parameters = BuildParameters(); - var assembly = app.Synth(); + _ = new EcsDeployTemplate(commandInputs, parameters, app, commandInputs.CfStackName, stackProps); + var assembly = app.Synth(); var stackArtifact = assembly.GetStackByName(commandInputs.CfStackName); var settings = new JsonSerializerSettings @@ -31,12 +42,42 @@ public string GenerateTemplate() Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }; - settings.Converters.Add(new WholeDoubleConverter()); - return JsonConvert.SerializeObject(stackArtifact.Template, settings); + var body = JsonConvert.SerializeObject(stackArtifact.Template, settings); + + return new GeneratedTemplate( + body, + parameters.Select(p => new Parameter { ParameterKey = p.Name, ParameterValue = p.Value }).ToList()); + } + + List<(string Name, string Value)> BuildParameters() + { + var list = new List<(string Name, string Value)> + { + (EcsTemplateParameterNames.ClusterName, commandInputs.ClusterName), + (EcsTemplateParameterNames.TaskDefinitionName, commandInputs.ServiceTaskName), + (EcsTemplateParameterNames.TaskDefinitionCpu, commandInputs.Cpu), + (EcsTemplateParameterNames.TaskDefinitionMemory, commandInputs.Memory), + (EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), + }; + + // Only declared when the user supplied a concrete ARN — otherwise the role + // is created in-template and referenced via Ref (no parameter needed). + if (!string.IsNullOrEmpty(commandInputs.TaskExecutionRole)) + { + list.Add((EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); + } + + + if (commandInputs.RequiresLogGroup) + { + list.Add((EcsTemplateParameterNames.LogGroupName, commandInputs.DefaultLogGroupPath)); + } + + + return list; } - class WholeDoubleConverter : JsonConverter { @@ -59,4 +100,4 @@ public override void WriteJson(JsonWriter writer, double? value, JsonSerializer return reader.Value == null ? null : Convert.ToDouble(reader.Value); } } -} \ No newline at end of file +} diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs new file mode 100644 index 000000000..b5a6db7cb --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using System.IO; +using Calamari.Aws.Deployment; +using Calamari.Aws.Inputs; +using Calamari.Aws.Inputs.Ecs; +using Calamari.Aws.Integration.Ecs; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; +using NSubstitute; +using NUnit.Framework; + +namespace Calamari.Tests.AWS.Ecs; + +[TestFixture] +public class EcsDeployTemplateGeneratorTests +{ + [Test] + public void MinimalTemplateAppearsAsExpected() + { + var fakeLog = Substitute.For(); + var fakeStackNameGenerator = Substitute.For(); + + var variables = new CalamariVariables + { + { AwsSpecialVariables.Ecs.Deploy.StackName, "TestStack" }, + { AwsSpecialVariables.Ecs.ClusterName, "TestCluster" }, + { DeploymentEnvironment.Id, "Environment-1"}, + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "SampleTask"}, + { AwsSpecialVariables.Ecs.Deploy.Cpu, "256"}, + { AwsSpecialVariables.Ecs.Deploy.Memory, "512"}, + { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64"}, + { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1"}, + { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, + { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, + { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "True"}, + { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False"}, + { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""}, + { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """ + ["sg-0d5e06a4bde84d1d3"] + """}, + { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ + ["subnet-0067a165dd462cb39"] + """}, + { + AwsSpecialVariables.Ecs.Deploy.Containers, + """[{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"#{Octopus.Action.Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}]""" + }, + {"Octopus.Action.Package[nginx].Image", "docker.io/nginx:1.29.1"} + }; + + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + + var template = new EcsDeployTemplateGenerator(inputs).Generate(); + + #if DEBUG + // Write template content over the top of content in and save "/Users/jt/Developer/Octopus/temp/DeployECSOutputs/Calamari.json" + File.WriteAllText("/Users/jt/Developer/Octopus/temp/DeployECSOutputs/Calamari.json", template.Body); + #endif + + } +} \ No newline at end of file From 7c2ad3a10f8896d241ca87012a952d75a959d1f1 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 10:06:01 +1000 Subject: [PATCH 57/80] Better error handling message --- .../Calamari.Aws/Commands/DeployEcsServiceCommand.cs | 3 +-- .../Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 11 ++++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index bc3a2c63a..c9e084b5a 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -23,8 +23,7 @@ public override int Execute(string[] commandLineArguments) var inputValidity = inputs.Validate(); if (!inputValidity.IsValid) { - // TODO: Better implementation - throw new CommandException($"Invalid inputs provided to {CommandName}"); + throw new CommandException(inputValidity.MissingKeyList); } diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 6f6719589..8d7257ebb 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -118,7 +118,7 @@ public string CpuArchitecture public bool RequiresLogGroup => Containers.Any(c => c.ContainerLogging.Type == ContainerLoggingType.Auto); - public bool ShouldWaitForDeploymentCompletion => WaitOption.Type == WaitType.WaitUntilCompleted || WaitOption.Type == WaitType.WaitWithTimeout; + public bool ShouldWaitForDeploymentCompletion => WaitOption.Type is WaitType.WaitUntilCompleted or WaitType.WaitWithTimeout; public InputsValidityResult Validate() { @@ -146,4 +146,13 @@ public InputsValidityResult Validate() public record InputsValidityResult(IEnumerable MissingKeys) { public bool IsValid => !MissingKeys.Any(); + + public string MissingKeyList { + get + { + var title = $"The following Property keys were missing{Environment.NewLine}"; + var body = string.Join(Environment.NewLine, MissingKeys.Select(p => $"- {p}")); + return title + body; + } + } } \ No newline at end of file From 4f609682d3c7bb317803563f45b3f26cc44293bf Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 13:45:12 +1000 Subject: [PATCH 58/80] More tweaks + tests --- .../Conventions/DeployEcsServiceConvention.cs | 1 + .../Ecs/ContainerSpecMappingExtensions.cs | 75 ++--- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 7 + .../Inputs/Ecs/EcsTemplateParameterNames.cs | 15 - .../Ecs/LoadBalancerMappingExtensions.cs | 4 +- .../Deploy/EcsDeployParameterGeneration.cs | 48 ++++ .../Ecs/{ => Deploy}/EcsDeployTemplate.cs | 58 ++-- .../EcsDeployTemplateGenerator.cs | 53 ++-- .../Ecs/Deploy/EcsTemplateParameterNames.cs | 20 ++ .../Ecs/EcsDeployTemplateGeneratorTests.cs | 164 ++++++++--- .../SpfOutputs/complexSpfOutputTemplate.json | 229 +++++++++++++++ .../multiContainerSpfOutputTemplate.json | 268 ++++++++++++++++++ .../SpfOutputs/simpleSpfOutputTemplate.json | 139 +++++++++ .../ContainerSpecMappingExtensionsTests.cs | 98 ++++++- .../Ecs/LoadBalancerMappingExtensionsTests.cs | 4 +- source/Calamari.Tests/Calamari.Tests.csproj | 3 + 16 files changed, 1050 insertions(+), 136 deletions(-) delete mode 100644 source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs rename source/Calamari.Aws/Integration/Ecs/{ => Deploy}/EcsDeployTemplate.cs (78%) rename source/Calamari.Aws/Integration/Ecs/{ => Deploy}/EcsDeployTemplateGenerator.cs (54%) create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/EcsTemplateParameterNames.cs create mode 100644 source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json create mode 100644 source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json create mode 100644 source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs index 570f39fa3..42d3a3f97 100644 --- a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -5,6 +5,7 @@ using Calamari.Aws.Integration.CloudFormation; using Calamari.Aws.Integration.CloudFormation.Templates; using Calamari.Aws.Integration.Ecs; +using Calamari.Aws.Integration.Ecs.Deploy; using Calamari.Aws.Util; using Calamari.CloudAccounts; using Calamari.Common.Commands; diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 45c0cd4d1..ee8636a1b 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -35,14 +35,15 @@ public static CfnTaskDefinition.HealthCheckProperty ParseHealthCheck(this Contai public static Dictionary ParseDockerLabels(this ContainerSpec containerSpec) { // Grouping Handle potential duplicates - return containerSpec.DockerLabels + var dockerLabels = containerSpec.DockerLabels .GroupBy(kvp => kvp.Key).ToDictionary(g => g.Key, g => g.Last().Value); + return dockerLabels.Count == 0 ? null : dockerLabels; } public static CfnTaskDefinition.KeyValuePairProperty[] ParseEnvironmentVariables(this ContainerSpec containerSpec) { - return containerSpec.EnvironmentVariables + var environmentVariables = containerSpec.EnvironmentVariables .Where(tkp => tkp.Type == KeyValueType.Plain) .GroupBy(kvp => kvp.Key) .Select(g => new CfnTaskDefinition.KeyValuePairProperty @@ -51,6 +52,9 @@ public static CfnTaskDefinition.KeyValuePairProperty[] ParseEnvironmentVariables Value = g.Last().Value, }) .ToArray(); + + return environmentVariables.Length == 0 ? null : environmentVariables; + } public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this ContainerSpec containerSpec) @@ -172,46 +176,47 @@ public static CfnTaskDefinition.EnvironmentFileProperty[] ParseEnvironmentFiles( return []; } - public static CfnTaskDefinition.LogConfigurationProperty ParseLogConfiguration(this ContainerSpec containerSpec) + public static CfnTaskDefinition.LogConfigurationProperty ParseLogConfiguration( + this ContainerSpec containerSpec, + string logGroupNameRef, + string awsRegionRef) { - if (containerSpec.ContainerLogging.LogDriver.HasValue) + switch (containerSpec.ContainerLogging.Type) { - if (containerSpec.ContainerLogging.LogDriver is LogDriver.None) - { - return null; - } - - var logDriver = LogDriver.None; - switch (containerSpec.ContainerLogging.Type) - { - case ContainerLoggingType.Auto: - logDriver = LogDriver.AwsLogs; - break; - case ContainerLoggingType.Manual: - default: + case ContainerLoggingType.Auto: + // Auto = "wire it up for me". LogDriver/LogOptions on the spec are ignored; + // we emit the standard awslogs configuration pointing at the task's log group. + return new CfnTaskDefinition.LogConfigurationProperty { - if(containerSpec.ContainerLogging.LogDriver.HasValue) + LogDriver = LogDriver.AwsLogs.ToString().ToLowerInvariant(), + Options = new Dictionary { - logDriver = containerSpec.ContainerLogging.LogDriver.Value; + { "awslogs-group", logGroupNameRef }, + { "awslogs-region", awsRegionRef }, + { "awslogs-stream-prefix", "ecs" } } - break; + }; + + case ContainerLoggingType.Manual: + default: + // Manual: honour the user's chosen driver and options. None / unset = no log config. + if (!containerSpec.ContainerLogging.LogDriver.HasValue + || containerSpec.ContainerLogging.LogDriver is LogDriver.None) + { + return null; } - } - return new CfnTaskDefinition.LogConfigurationProperty - { - LogDriver = logDriver.ToString().ToLowerInvariant(), - Options = containerSpec.ContainerLogging.LogOptions - .Where(lo => lo.Type == KeyValueType.Plain) - .ToDictionary(opt => opt.Key, opt => opt.Value), - SecretOptions = containerSpec.ContainerLogging.LogOptions - .Where(lo => lo.Type == KeyValueType.Secret) - .ToDictionary(opt => opt.Key, opt => opt.Value), - - }; + return new CfnTaskDefinition.LogConfigurationProperty + { + LogDriver = containerSpec.ContainerLogging.LogDriver.Value.ToString().ToLowerInvariant(), + Options = containerSpec.ContainerLogging.LogOptions + .Where(lo => lo.Type == KeyValueType.Plain) + .ToDictionary(opt => opt.Key, opt => opt.Value), + SecretOptions = containerSpec.ContainerLogging.LogOptions + .Where(lo => lo.Type == KeyValueType.Secret) + .ToDictionary(opt => opt.Key, opt => opt.Value), + }; } - - return null; } public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfiguration(this ContainerSpec containerSpec) @@ -223,7 +228,7 @@ public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfi var options = new Dictionary { - { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata } + { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata.ToLowerInvariant() } }; if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None }) { diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 8d7257ebb..6dce6d166 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -155,4 +155,11 @@ public string MissingKeyList { return title + body; } } +} + +public static class EcsInputDefaults +{ + public const double DesiredCount = 1; + public const double MinimumHealthPercent = 100; + public const double MaximumHealthPercent = 200; } \ No newline at end of file diff --git a/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs b/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs deleted file mode 100644 index 3036fc807..000000000 --- a/source/Calamari.Aws/Inputs/Ecs/EcsTemplateParameterNames.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Calamari.Aws.Inputs.Ecs; - -// CloudFormation parameter names used by EcsDeployTemplate. Shared between the -// generator (which sets Defaults and supplies override values at deploy time) -// and the template (which looks them up by name when wiring CDK Refs). -public static class EcsTemplateParameterNames -{ - public const string ClusterName = "ClusterName"; - public const string TaskDefinitionName = "TaskDefinitionName"; - public const string TaskDefinitionCpu = "TaskDefinitionCPU"; - public const string TaskDefinitionMemory = "TaskDefinitionMemory"; - public const string TaskRole = "TaskRole"; - public const string TaskExecutionRole = "TaskExecutionRole"; - public const string LogGroupName = "LogGroupName"; -} diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs index a3e590625..6dc25e72f 100644 --- a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -9,12 +9,14 @@ public static class LoadBalancerMappingExtensions { public static CfnService.LoadBalancerProperty[] ToLoadBalancerProperties(this IEnumerable loadBalancerMappings) { - return loadBalancerMappings.Select(lbm => new CfnService.LoadBalancerProperty + var lbMappings = loadBalancerMappings.Select(lbm => new CfnService.LoadBalancerProperty { ContainerName = lbm.ContainerName, ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), TargetGroupArn = lbm.TargetGroupArn, }) .ToArray(); + + return lbMappings.Length == 0 ? null : lbMappings; } } \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs new file mode 100644 index 000000000..5913810ff --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Calamari.Common.Util; + +namespace Calamari.Aws.Integration.Ecs.Deploy; + +public interface IEcsTemplateParameter +{ + string Name { get; } + // Typed value handed to CDK's CfnParameter Default — preserves the underlying + // type so a Number param emits a JSON number literal, not a quoted string. + object Default { get; } + // String form for the AWS SDK Parameter override list — required to be a string + // even for Number-typed params (CFN parses it back). + string Value { get; } + string CfnType { get; } +} + +public record EcsTemplateParameter(string Name, T TypedValue) : IEcsTemplateParameter +{ + public object Default => TypedValue; + + // Invariant culture so "1.5" doesn't become "1,5" in non-English locales — CFN + // and the AWS API both expect invariant-formatted numbers. + public string Value => TypedValue switch + { + null => string.Empty, + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => TypedValue.ToString() ?? string.Empty + }; + + public string CfnType => typeof(T) == typeof(double) ? "Number" : "String"; +} + +// Static factory enables generic type inference at the call site: +// EcsTemplateParameter.Of(name, "string value") +// EcsTemplateParameter.Of(name, 1.0) +public static class EcsTemplateParameter +{ + public static EcsTemplateParameter Of(string name, T value) => new(name, value); +} + +public class ListTemplateInputs(IEnumerable inputs) : ITemplateInputs +{ + public IEnumerable Inputs { get; } = inputs.ToList(); +} diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs similarity index 78% rename from source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs rename to source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index 3de88c724..8ad552993 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -1,37 +1,38 @@ +using System; using System.Collections.Generic; using System.Linq; using Amazon.CDK; using Amazon.CDK.AWS.ECS; using Amazon.CDK.AWS.Logs; using Calamari.Aws.Inputs.Ecs; -using Octopus.Calamari.Contracts.Aws.Ecs; -namespace Calamari.Aws.Integration.Ecs; +namespace Calamari.Aws.Integration.Ecs.Deploy; public sealed class EcsDeployTemplate : Stack { const string FargateLaunchType = "FARGATE"; const string AwsVpcNetworkMode = "awsvpc"; const string LinuxOperatingSystemFamily = "LINUX"; - + + readonly Dictionary paramRefs; public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, - IReadOnlyList<(string Name, string Value)> parameters, + IReadOnlyList parameters, App scope, string id, IStackProps props = null) : base(scope, id, props) { TemplateOptions.TemplateFormatVersion = "2010-09-09"; - var paramRefs = parameters.ToDictionary( - p => p.Name, - p => new CfnParameter(this, - p.Name, - new CfnParameterProps - { - Type = "String", - Default = p.Value - })); + paramRefs = parameters.ToDictionary( + p => p.Name, + p => new CfnParameter(this, + p.Name, + new CfnParameterProps + { + Type = p.CfnType, + Default = p.Default + })); // ExecutionRoleArn: parameter when user-supplied (in `paramRefs`), in-template // role otherwise. The role can't be known at request time because CFN creates @@ -40,6 +41,17 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, ? execRoleParam.ValueAsString : commandInputs.MapTaskExecutionRoleArn(this); + // For Auto-logging containers we need to point awslogs at the LogGroupName parameter + // and the deploy region. LogGroupName is only registered when any container is Auto + // (RequiresLogGroup), so accessing it outside that branch would throw — null is fine + // because ParseLogConfiguration only consults it in the Auto path. + var logGroupNameRef = commandInputs.RequiresLogGroup + ? paramRefs[EcsTemplateParameterNames.LogGroupName].ValueAsString + : null; + + // Stack.Region is a CDK token that synthesises to { Ref: AWS::Region } — + // the CFN pseudo-parameter that resolves to the deploy region at runtime. + var awsRegionRef = Region; var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty { @@ -71,7 +83,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, MountPoints = c.ParseMountPoints(), DependsOn = c.ParseDependencies(), VolumesFrom = c.ParseVolumesFrom(), - LogConfiguration = c.ParseLogConfiguration(), + LogConfiguration = c.ParseLogConfiguration(logGroupNameRef, awsRegionRef), EnvironmentFiles = c.ParseEnvironmentFiles(), FirelensConfiguration = c.ParseFireLensConfiguration(), @@ -107,7 +119,7 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, Cpu = paramRefs[EcsTemplateParameterNames.TaskDefinitionCpu].ValueAsString, Memory = paramRefs[EcsTemplateParameterNames.TaskDefinitionMemory].ValueAsString, ExecutionRoleArn = executionRoleArnRef, - TaskRoleArn = paramRefs[EcsTemplateParameterNames.TaskRole].ValueAsString, + TaskRoleArn = ParamOr(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), RequiresCompatibilities = [FargateLaunchType], NetworkMode = AwsVpcNetworkMode, RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty @@ -126,12 +138,12 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, Cluster = paramRefs[EcsTemplateParameterNames.ClusterName].ValueAsString, LaunchType = FargateLaunchType, TaskDefinition = taskDefinition.Ref, - DesiredCount = commandInputs.DesiredCount, + DesiredCount = ParamOr(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount), EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty { - MinimumHealthyPercent = commandInputs.MinimumHealthyPercentage, - MaximumPercent = commandInputs.MaximumHealthyPercentage + MinimumHealthyPercent = ParamOr(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage), + MaximumPercent = ParamOr(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage) }, NetworkConfiguration = new CfnService.NetworkConfigurationProperty { @@ -148,5 +160,13 @@ public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, service.AddDependency(taskDefinition); } - + + // Conditionally-registered parameters (only present when the input was customised + // away from the default): fall back to the literal commandInputs value when absent. + // Resources then render either { Ref: ... } or the inline value accordingly. + string ParamOr(string key, string literal) => + paramRefs.TryGetValue(key, out var p) ? p.ValueAsString : literal; + + double ParamOr(string key, double literal) => + paramRefs.TryGetValue(key, out var p) ? p.ValueAsNumber : literal; } diff --git a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs similarity index 54% rename from source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs rename to source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs index 402c2d165..ff91def5f 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -4,17 +4,12 @@ using Amazon.CDK; using Amazon.CloudFormation.Model; using Calamari.Aws.Inputs.Ecs; -using Calamari.Common.Util; using Newtonsoft.Json; -namespace Calamari.Aws.Integration.Ecs; +namespace Calamari.Aws.Integration.Ecs.Deploy; public record GeneratedTemplate(string Body, IReadOnlyList Parameters); -public class ListTemplateInputs(IEnumerable inputs) : ITemplateInputs -{ - public IEnumerable Inputs { get; } = inputs.ToList(); -} public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) { @@ -51,34 +46,60 @@ public GeneratedTemplate Generate() parameters.Select(p => new Parameter { ParameterKey = p.Name, ParameterValue = p.Value }).ToList()); } - List<(string Name, string Value)> BuildParameters() + List BuildParameters() { - var list = new List<(string Name, string Value)> + var list = new List { - (EcsTemplateParameterNames.ClusterName, commandInputs.ClusterName), - (EcsTemplateParameterNames.TaskDefinitionName, commandInputs.ServiceTaskName), - (EcsTemplateParameterNames.TaskDefinitionCpu, commandInputs.Cpu), - (EcsTemplateParameterNames.TaskDefinitionMemory, commandInputs.Memory), - (EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), + EcsTemplateParameter.Of(EcsTemplateParameterNames.ClusterName, commandInputs.ClusterName), + EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionName, commandInputs.ServiceTaskName), + EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionCpu, commandInputs.Cpu), + EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionMemory, commandInputs.Memory), }; + // The remaining parameters are only registered when the user-supplied value + // differs from the default — matching SPF, which keeps the template lean + // when defaults are in use and parameterises only what's been customised. + + if (!string.IsNullOrEmpty(commandInputs.TaskRole)) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole)); + } + // Only declared when the user supplied a concrete ARN — otherwise the role // is created in-template and referenced via Ref (no parameter needed). if (!string.IsNullOrEmpty(commandInputs.TaskExecutionRole)) { - list.Add((EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); } + if (DiffersFromDefault(commandInputs.DesiredCount, EcsInputDefaults.DesiredCount)) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount)); + } + + if (DiffersFromDefault(commandInputs.MinimumHealthyPercentage, EcsInputDefaults.MinimumHealthPercent)) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage)); + } + + if (DiffersFromDefault(commandInputs.MaximumHealthyPercentage, EcsInputDefaults.MaximumHealthPercent)) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage)); + } if (commandInputs.RequiresLogGroup) { - list.Add((EcsTemplateParameterNames.LogGroupName, commandInputs.DefaultLogGroupPath)); + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.LogGroupName, commandInputs.DefaultLogGroupPath)); } - return list; } + // Direct `!=` on doubles is unreliable across precision and NaN; compare via + // epsilon-based equality (matches the WholeDoubleConverter convention below). + static bool DiffersFromDefault(double value, double @default) => + Math.Abs(value - @default) > double.Epsilon; + class WholeDoubleConverter : JsonConverter { public override void WriteJson(JsonWriter writer, double? value, JsonSerializer serializer) diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsTemplateParameterNames.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsTemplateParameterNames.cs new file mode 100644 index 000000000..20e4e3ecc --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsTemplateParameterNames.cs @@ -0,0 +1,20 @@ +using System; + +namespace Calamari.Aws.Integration.Ecs.Deploy; + +// CloudFormation parameter names used by EcsDeployTemplate. Shared between the +// generator (which sets Defaults and supplies override values at deploy time) +// and the template (which looks them up by name when wiring CDK Refs). +public static class EcsTemplateParameterNames +{ + public const string ClusterName = "ClusterName"; + public const string TaskDefinitionName = "TaskDefinitionName"; + public const string TaskDefinitionCpu = "TaskDefinitionCPU"; + public const string TaskDefinitionMemory = "TaskDefinitionMemory"; + public const string TaskRole = "TaskRole"; + public const string TaskExecutionRole = "TaskExecutionRole"; + public const string LogGroupName = "LogGroupName"; + public const string DesiredCount = "DesiredCount"; + public const string MinimumHealthPercent = "MinimumHealthPercent"; + public const string MaximumHealthPercent = "MaximumHealthPercent"; +} \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs index b5a6db7cb..d80b80736 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -1,11 +1,13 @@ -using System.Diagnostics; using System.IO; +using System.Reflection; using Calamari.Aws.Deployment; -using Calamari.Aws.Inputs; using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; +using Calamari.Aws.Integration.Ecs.Deploy; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Variables; +using FluentAssertions; +using Newtonsoft.Json.Linq; using NSubstitute; using NUnit.Framework; @@ -14,48 +16,144 @@ namespace Calamari.Tests.AWS.Ecs; [TestFixture] public class EcsDeployTemplateGeneratorTests { + readonly ILog fakeLog = Substitute.For(); + readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); + + [Test] - public void MinimalTemplateAppearsAsExpected() + public void WithSimpleVariableSetup_MatchesExpectedSpfOutput() { - var fakeLog = Substitute.For(); - var fakeStackNameGenerator = Substitute.For(); + var expectedJson = ReadFromFile("simpleSpfOutputTemplate.json"); - var variables = new CalamariVariables + var variables = new CalamariVariables { - { AwsSpecialVariables.Ecs.Deploy.StackName, "TestStack" }, + { AwsSpecialVariables.Ecs.Deploy.StackName, "ecs-spf-#{Octopus.Deployment.Id}" }, { AwsSpecialVariables.Ecs.ClusterName, "TestCluster" }, - { DeploymentEnvironment.Id, "Environment-1"}, - { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "SampleTask"}, - { AwsSpecialVariables.Ecs.Deploy.Cpu, "256"}, - { AwsSpecialVariables.Ecs.Deploy.Memory, "512"}, - { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64"}, - { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1"}, - { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100"}, - { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200"}, - { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "True"}, - { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False"}, - { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }"""}, - { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """ - ["sg-0d5e06a4bde84d1d3"] - """}, - { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ - ["subnet-0067a165dd462cb39"] + { DeploymentEnvironment.Id, "Environment-1" }, + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "test-octopus-spfdeployed-task" }, + { AwsSpecialVariables.Ecs.Deploy.Cpu, "256" }, + { AwsSpecialVariables.Ecs.Deploy.Memory, "512" }, + { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64" }, + { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1" }, + { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100" }, + { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200" }, + { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "True" }, + { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False" }, + { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }""" }, + { + AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """ + ["sg-0d5e06a4bde84d1d"] + """ + }, + { + AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ + ["subnet-0650cd8a2119e829c", "subnet-0067a165dd462cb39"] + """ + }, + { AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" }, + { AwsSpecialVariables.Ecs.Deploy.TaskRole, "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" }, + // TODO: Update containers to use variable + {AwsSpecialVariables.Ecs.Tags, """[{"key":"owner","value":"spfdeployment"},{"key":"createdBy", "value":"#{Octopus.Project.Slug}"}]"""}, + {AwsSpecialVariables.Ecs.Deploy.Containers, """ + [{"containerName":"web-server-spf","containerImageReference":{"referenceId":"732002f0-4555-4dbf-8dc3-64255eee5f26","imageName":"docker.io/nginx:1.29","feedId":"Feeds-1061"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[{"containerPort":"80","protocol":"Tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[{"type":"Plain","key":"env","value":"#{Octopus.Environment.Name}"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Manual","logDriver":"None","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] """}, + + + {AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, "[]"}, + {AwsSpecialVariables.Ecs.Deploy.Volumes, "[]"}, + + + {"Octopus.Project.Slug", "test-project"}, + {"Octopus.Deployment.Id", "17"}, + {"Octopus.Environment.Name", "TestEnvironment"} + + + }; + + var resultJson = GenerateTemplateFromVariables(variables); + + JToken.DeepEquals(resultJson, expectedJson).Should().BeTrue(); + } + + [Test] + public void WithMultipleContainers_MatchesExpectedSpfOutput() + { + var expectedJson = ReadFromFile("multiContainerSpfOutputTemplate.json"); + + var variables = new CalamariVariables + { + { AwsSpecialVariables.Ecs.Deploy.StackName, "test-stack" }, + { AwsSpecialVariables.Ecs.ClusterName, "TestCluster" }, + { DeploymentEnvironment.Id, "Environment-1" }, + { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "test-multi-container-template" }, + { AwsSpecialVariables.Ecs.Deploy.Cpu, "256" }, + { AwsSpecialVariables.Ecs.Deploy.Memory, "512" }, + { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64" }, + { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "2" }, + { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100" }, + { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200" }, + { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "True" }, + { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "True" }, + { AwsSpecialVariables.Ecs.WaitOption, """{"type":"DontWait","timeoutMinutes":"30"}""" }, { - AwsSpecialVariables.Ecs.Deploy.Containers, - """[{"containerName":"sample-container","containerImageReference":{"referenceId":"547c5091-b891-4bb2-a582-78489bd9b18c","imageName":"#{Octopus.Action.Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"default"},"containerPortMappings":[{"containerPort":80,"protocol":"tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":false,"dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"manual","logDriver":"none","logOptions":[]},"firelensConfiguration":{"type":"disabled"},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}]""" + AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """ + ["sg-0d5e06a4bde84daaa"] + """ }, - {"Octopus.Action.Package[nginx].Image", "docker.io/nginx:1.29.1"} + { + AwsSpecialVariables.Ecs.Deploy.SubnetIds, """ + ["subnet-0650cd8a2119e8aaa"] + """ + }, + { AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" }, + { AwsSpecialVariables.Ecs.Deploy.TaskRole, "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" }, + + {AwsSpecialVariables.Ecs.Tags, """[{"key":"my-tag","value":"a great test value"}]"""}, + {AwsSpecialVariables.Ecs.Deploy.Containers, """ + [{"containerName":"web-server","containerImageReference":{"referenceId":"939a08a0-7dd9-471d-ac31-ac8e29eb04ff","imageName":"#{Octopus.Action[Deploy Amazon ECS Service].Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"memoryLimitSoft":"47","memoryLimitHard":"200","containerPortMappings":[{"containerPort":"80","protocol":"Tcp"},{"containerPort":"443","protocol":"Tcp"}],"cpus":"2","essential":"True","entryPoint":"sh, -c","command":"echo 'Deployment successful","workingDirectory":"/tmp","environmentFiles":["jttestc668db76/test/keyarm-packagev1.0.3.zip"],"environmentVariables":[{"type":"Plain","key":"containerenv","value":"some-otherovalue"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"True","mountPoints":[{"sourceVolume":"efs-volume","containerPath":"/etc","readonly":"False"}],"volumeFrom":[{"sourceContainer":"efs-volume","readonly":"True"}]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Enabled","firelensType":"Fluentd","enableEcsLogMetadata":"True","customConfigSource":{"type":"File","filePath":"/home/config"}},"dockerLabels":[{"key":"some-label","value":"label-value"}],"user":"test-user","healthCheck":{"command":["curl -f http://localhost/ || exit 1"],"interval":"240","retries":"7","startPeriod":"179","timeout":"54"},"dependencies":[],"startTimeout":"40","stopTimeout":"60","ulimits":[{"limitName":"core","hardLimit":"12","softLimit":"10"}]},{"containerName":"cache","containerImageReference":{"referenceId":"07c8e308-048f-4289-b25a-86fb901e2824","imageName":"#{Octopus.Action[Deploy Amazon ECS Service].Package[redis].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[" [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]."]},"dependencies":[],"ulimits":[]}] + """}, + + + {AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, "[]"}, + {AwsSpecialVariables.Ecs.Deploy.Volumes, """ + [{"type":"Efs","name":"efs-volume","fileSystemId":"efs-fs-id","accessPointId":"/data","rootDirectory":"/root","encryptionInTransit":"True","efsIamAuthorization":"True"}] + """}, + + {"Octopus.Action[Deploy Amazon ECS Service].Package[nginx].Image", "docker.io/nginx:1.31.1"}, + {"Octopus.Action[Deploy Amazon ECS Service].Package[redis].Image", "docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig"}, + {"Octopus.Project.Slug", "ecs-from-md-instance"}, + {"Octopus.Deployment.Id", "Deployments-622"}, + {"Octopus.Environment.Name", "Dev"} }; + var resultJson = GenerateTemplateFromVariables(variables); + JToken.DeepEquals(resultJson, expectedJson).Should().BeTrue(); + } + + JObject GenerateTemplateFromVariables(CalamariVariables variables) + { var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - var template = new EcsDeployTemplateGenerator(inputs).Generate(); - - #if DEBUG - // Write template content over the top of content in and save "/Users/jt/Developer/Octopus/temp/DeployECSOutputs/Calamari.json" - File.WriteAllText("/Users/jt/Developer/Octopus/temp/DeployECSOutputs/Calamari.json", template.Body); - #endif - + var resultJson = JObject.Parse(template.Body); + + return resultJson; + } + + + JObject ReadFromFile(string fileName) + { + var filePath = Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().FullLocalPath()) ?? "", + Path.Combine("AWS", "Ecs", "SpfOutputs", fileName)); + + var fullPath = Path.GetFullPath(filePath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"Test data file not found: {fullPath}"); + } + + var json = File.ReadAllText(fullPath); + + return JObject.Parse(json); } } \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json new file mode 100644 index 000000000..b2ade3440 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -0,0 +1,229 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "ServicetestBigCfTemplate": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "ClusterName" + }, + "TaskDefinition": { + "Ref": "TaskDefinitiontestBigCfTemplate" + }, + "DesiredCount": { + "Ref": "DesiredCount" + }, + "EnableECSManagedTags": true, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ], + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "SecurityGroups": [ + "sg-0d5e06a4bde84dxxx" + ], + "Subnets": [ + "subnet-0650cd8a2119e8xxx" + ] + } + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + } + }, + "DependsOn": "TaskDefinitiontestBigCfTemplate" + }, + "TaskDefinitiontestBigCfTemplate": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "EntryPoint": [ + "sh", + "-c" + ], + "Command": [ + "echo 'Deployment successful" + ], + "WorkingDirectory": "/tmp", + "Image": "#{Octopus.Action[test-step].Package[nginx].Image}", + "Memory": 200, + "MemoryReservation": 47, + "Name": "web-server", + "Cpu": 2, + "ResourceRequirements": [], + "EnvironmentFiles": [ + { + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + } + ], + "Environment": [ + { + "Name": "containerenv", + "Value": "some-otherovalue" + } + ], + "DisableNetworking": false, + "DnsServers": [], + "DnsSearchDomains": [], + "ExtraHosts": [], + "User": "test-user", + "DockerLabels": { + "some-label": "label-value" + }, + "MountPoints": [ + { + "SourceVolume": "efs-volume", + "ContainerPath": "/etc", + "ReadOnly": false + } + ], + "VolumesFrom": [ + { + "SourceContainer": "efs-volume", + "ReadOnly": true + } + ], + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": "/ecs/test-big-cf-template", + "awslogs-region": "#{Octopus.Action.Aws.Region}", + "awslogs-stream-prefix": "ecs" + } + }, + "FirelensConfiguration": { + "Type": "fluentd", + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" + } + }, + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp" + }, + { + "ContainerPort": 443, + "HostPort": 443, + "Protocol": "tcp" + } + ], + "HealthCheck": { + "Command": [ + "curl -f http://localhost/ || exit 1" + ], + "Interval": 240, + "Retries": 7, + "StartPeriod": 179, + "Timeout": 54 + }, + "StartTimeout": 40, + "StopTimeout": 60, + "Ulimits": [ + { + "Name": "core", + "HardLimit": 12, + "SoftLimit": 10 + } + ] + } + ], + "Family": { + "Ref": "TaskDefinitionName" + }, + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "TaskRoleArn": { + "Ref": "TaskRole" + }, + "RequiresCompatibilities": [ + "FARGATE" + ], + "NetworkMode": "awsvpc", + "Volumes": [ + { + "Name": "efs-volume", + "EFSVolumeConfiguration": { + "FilesystemId": "efs-fs-id", + "RootDirectory": "/root", + "TransitEncryption": "ENABLED", + "AuthorizationConfig": { + "IAM": "ENABLED" + } + } + } + ], + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ], + "RuntimePlatform": { + "CpuArchitecture": "X86_64", + "OperatingSystemFamily": "LINUX" + } + } + }, + "AwsLogGrouptestBigCfTemplate": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Ref": "LogGroupName" + } + } + } + }, + "Parameters": { + "ClusterName": { + "Type": "String", + "Default": "#{Octopus.Action.Aws.Ecs.ClusterName}" + }, + "TaskDefinitionName": { + "Type": "String", + "Default": "test-big-cf-template" + }, + "DesiredCount": { + "Type": "Number", + "Default": 2 + }, + "TaskDefinitionCPU": { + "Type": "String", + "Default": "256" + }, + "TaskDefinitionMemory": { + "Type": "String", + "Default": "512" + }, + "TaskExecutionRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "TaskRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "LogGroupName": { + "Type": "String" + } + } +} diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json new file mode 100644 index 000000000..45cdb4b59 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json @@ -0,0 +1,268 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "ClusterName": { + "Type": "String", + "Default": "TestCluster" + }, + "TaskDefinitionName": { + "Type": "String", + "Default": "test-multi-container-template" + }, + "TaskDefinitionCPU": { + "Type": "String", + "Default": "256" + }, + "TaskDefinitionMemory": { + "Type": "String", + "Default": "512" + }, + "TaskRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "TaskExecutionRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "DesiredCount": { + "Type": "Number", + "Default": 2 + }, + "LogGroupName": { + "Type": "String", + "Default": "/ecs/test-multi-container-template" + } + }, + "Resources": { + "AwsLogGrouptestMultiContainerTemplate": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Ref": "LogGroupName" + } + } + }, + "TaskDefinitiontestMultiContainerTemplate": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Command": [ + "echo 'Deployment successful" + ], + "Cpu": 2, + "DisableNetworking": false, + "DnsSearchDomains": [], + "DnsServers": [], + "DockerLabels": { + "some-label": "label-value" + }, + "EntryPoint": [ + "sh", + "-c" + ], + "Environment": [ + { + "Name": "containerenv", + "Value": "some-otherovalue" + } + ], + "EnvironmentFiles": [ + { + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + } + ], + "Essential": true, + "ExtraHosts": [], + "FirelensConfiguration": { + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" + }, + "Type": "fluentd" + }, + "HealthCheck": { + "Command": [ + "curl -f http://localhost/ || exit 1" + ], + "Interval": 240, + "Retries": 7, + "StartPeriod": 179, + "Timeout": 54 + }, + "Image": "docker.io/nginx:1.31.1", + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" + } + }, + "Memory": 200, + "MemoryReservation": 47, + "MountPoints": [ + { + "ContainerPath": "/etc", + "ReadOnly": false, + "SourceVolume": "efs-volume" + } + ], + "Name": "web-server", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp" + }, + { + "ContainerPort": 443, + "HostPort": 443, + "Protocol": "tcp" + } + ], + "ReadonlyRootFilesystem": true, + "ResourceRequirements": [], + "StartTimeout": 40, + "StopTimeout": 60, + "Ulimits": [ + { + "HardLimit": 12, + "Name": "core", + "SoftLimit": 10 + } + ], + "User": "test-user", + "VolumesFrom": [ + { + "ReadOnly": true, + "SourceContainer": "efs-volume" + } + ], + "WorkingDirectory": "/tmp" + }, + { + "DisableNetworking": false, + "DnsSearchDomains": [], + "DnsServers": [], + "EnvironmentFiles": [], + "Essential": true, + "ExtraHosts": [], + "HealthCheck": { + "Command": [ + " [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]." + ] + }, + "Image": "docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig", + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" + } + }, + "Name": "cache", + "PortMappings": [], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [] + } + ], + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "Family": { + "Ref": "TaskDefinitionName" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ], + "RuntimePlatform": { + "CpuArchitecture": "X86_64", + "OperatingSystemFamily": "LINUX" + }, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ], + "TaskRoleArn": { + "Ref": "TaskRole" + }, + "Volumes": [ + { + "EFSVolumeConfiguration": { + "AuthorizationConfig": { + "AccessPointId": "/data", + "IAM": "ENABLED" + }, + "FilesystemId": "efs-fs-id", + "RootDirectory": "/root", + "TransitEncryption": "ENABLED" + }, + "Name": "efs-volume" + } + ] + } + }, + "ServicetestMultiContainerTemplate": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "ClusterName" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + }, + "DesiredCount": { + "Ref": "DesiredCount" + }, + "EnableECSManagedTags": true, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "SecurityGroups": [ + "sg-0d5e06a4bde84daaa" + ], + "Subnets": [ + "subnet-0650cd8a2119e8aaa" + ] + } + }, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ], + "TaskDefinition": { + "Ref": "TaskDefinitiontestMultiContainerTemplate" + } + }, + "DependsOn": [ + "TaskDefinitiontestMultiContainerTemplate" + ] + } + } +} \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json new file mode 100644 index 000000000..bfb813ab3 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json @@ -0,0 +1,139 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "ClusterName": { + "Type": "String", + "Default": "TestCluster" + }, + "TaskDefinitionName": { + "Type": "String", + "Default": "test-octopus-spfdeployed-task" + }, + "TaskDefinitionCPU": { + "Type": "String", + "Default": "256" + }, + "TaskDefinitionMemory": { + "Type": "String", + "Default": "512" + }, + "TaskRole": { + "Type": "String", + "Default": "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" + }, + "TaskExecutionRole": { + "Type": "String", + "Default": "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" + } + }, + "Resources": { + "TaskDefinitiontestOctopusSpfdeployedTask": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "DisableNetworking": false, + "DnsSearchDomains": [], + "DnsServers": [], + "Environment": [ + { + "Name": "env", + "Value": "TestEnvironment" + } + ], + "EnvironmentFiles": [], + "Essential": true, + "ExtraHosts": [], + "Image": "docker.io/nginx:1.29", + "Name": "web-server-spf", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp" + } + ], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [] + } + ], + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "Family": { + "Ref": "TaskDefinitionName" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ], + "RuntimePlatform": { + "CpuArchitecture": "X86_64", + "OperatingSystemFamily": "LINUX" + }, + "Tags": [ + { + "Key": "createdBy", + "Value": "test-project" + }, + { + "Key": "owner", + "Value": "spfdeployment" + } + ], + "TaskRoleArn": { + "Ref": "TaskRole" + } + } + }, + "ServicetestOctopusSpfdeployedTask": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "ClusterName" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + }, + "DesiredCount": 1, + "EnableECSManagedTags": false, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "SecurityGroups": [ + "sg-0d5e06a4bde84d1d" + ], + "Subnets": [ + "subnet-0650cd8a2119e829c", + "subnet-0067a165dd462cb39" + ] + } + }, + "Tags": [ + { + "Key": "createdBy", + "Value": "test-project" + }, + { + "Key": "owner", + "Value": "spfdeployment" + } + ], + "TaskDefinition": { + "Ref": "TaskDefinitiontestOctopusSpfdeployedTask" + } + }, + "DependsOn": [ + "TaskDefinitiontestOctopusSpfdeployedTask" + ] + } + } +} \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index ec4560484..308efabee 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -261,13 +261,31 @@ public void ParseVolumesFrom_WithEmptyReadonly_DefaultsToFalse() } [Test] - public void ParseEnvironmentVariables_WhenNone_ReturnsEmptyDictionary() + public void ParseEnvironmentVariables_WhenNone_ReturnsNull() { var spec = new ContainerSpec(); var result = spec.ParseEnvironmentVariables(); - result.Should().BeEmpty(); + result.Should().BeNull(); + } + + [Test] + public void ParseEnvironmentVariables_WhenOnlySecretEntries_ReturnsNull() + { + // Plain entries are filtered to produce the array; if filtering yields nothing + // the method returns null (not an empty array). + var spec = new ContainerSpec + { + EnvironmentVariables = + [ + new TypedKeyValuePair { Type = KeyValueType.Secret, Key = "SECRET_KEY", Value = "arn:secret" } + ] + }; + + var result = spec.ParseEnvironmentVariables(); + + result.Should().BeNull(); } [Test] @@ -415,6 +433,16 @@ public void ParseSecrets_DoesNotConsiderPlainEntriesForDedupe() result[0].ValueFrom.Should().Be("arn:secret"); } + [Test] + public void ParseDockerLabels_WhenNone_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseDockerLabels(); + + result.Should().BeNull(); + } + [Test] public void ParseDockerLabels_WithDuplicateKeys_LastValueWins() { @@ -651,49 +679,89 @@ public void ParseEnvironmentFiles_WithFiles_MapsToS3TypeWithValue() result[1].Value.Should().Be("arn:aws:s3:::my-bucket/env-two"); } + const string TestLogGroupRef = "log-group-ref"; + const string TestRegionRef = "region-ref"; + [Test] - public void ParseLogConfiguration_WhenLogDriverNull_ReturnsNull() + public void ParseLogConfiguration_WhenManualAndLogDriverNull_ReturnsNull() { var spec = new ContainerSpec { - ContainerLogging = new ContainerLogging { LogDriver = null } + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Manual, + LogDriver = null + } }; - var result = spec.ParseLogConfiguration(); + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); result.Should().BeNull(); } [Test] - public void ParseLogConfiguration_WhenLogDriverNone_ReturnsNull() + public void ParseLogConfiguration_WhenManualAndLogDriverNone_ReturnsNull() { var spec = new ContainerSpec { ContainerLogging = new ContainerLogging { - LogDriver = LogDriver.None, - Type = ContainerLoggingType.Manual + Type = ContainerLoggingType.Manual, + LogDriver = LogDriver.None } }; - var result = spec.ParseLogConfiguration(); + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); result.Should().BeNull(); } [Test] - public void ParseLogConfiguration_WhenAuto_ForcesAwsLogsDriver() + public void ParseLogConfiguration_WhenAuto_EmitsAwsLogsWithStandardOptions() { + // Auto ignores any LogDriver/LogOptions on the spec; it always emits awslogs + // pointing at the supplied log group and region. var spec = new ContainerSpec { ContainerLogging = new ContainerLogging { Type = ContainerLoggingType.Auto, - LogDriver = LogDriver.Splunk + LogDriver = LogDriver.Splunk, // ignored + LogOptions = + [ + new TypedKeyValuePair { Type = KeyValueType.Plain, Key = "ignored", Value = "value" } + ] + } + }; + + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); + + result.Should().NotBeNull(); + result!.LogDriver.Should().Be("awslogs"); + result.Options.Should().BeOfType>() + .Which.Should().BeEquivalentTo(new Dictionary + { + { "awslogs-group", TestLogGroupRef }, + { "awslogs-region", TestRegionRef }, + { "awslogs-stream-prefix", "ecs" } + }); + } + + [Test] + public void ParseLogConfiguration_WhenAutoAndLogDriverNull_StillEmitsAwsLogs() + { + // Regression guard: previous implementation early-returned null if LogDriver + // wasn't set, which silently dropped Auto containers' log config. + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Auto, + LogDriver = null } }; - var result = spec.ParseLogConfiguration(); + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); result.Should().NotBeNull(); result!.LogDriver.Should().Be("awslogs"); @@ -711,13 +779,13 @@ public void ParseLogConfiguration_WhenManual_UsesProvidedLogDriver() } }; - var result = spec.ParseLogConfiguration(); + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); result!.LogDriver.Should().Be("splunk"); } [Test] - public void ParseLogConfiguration_SplitsPlainAndSecretOptions() + public void ParseLogConfiguration_WhenManual_SplitsPlainAndSecretOptions() { var spec = new ContainerSpec { @@ -734,7 +802,7 @@ public void ParseLogConfiguration_SplitsPlainAndSecretOptions() } }; - var result = spec.ParseLogConfiguration(); + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); result!.Options.Should().BeOfType>() .Which.Should().BeEquivalentTo(new Dictionary diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs index 68e518975..57fb5adf9 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/LoadBalancerMappingExtensionsTests.cs @@ -10,11 +10,11 @@ namespace Calamari.Tests.AWS.Inputs.Ecs; public class LoadBalancerMappingExtensionsTests { [Test] - public void ToLoadBalancerProperties_WhenEmpty_ReturnsEmptyArray() + public void ToLoadBalancerProperties_WhenEmpty_ReturnsNull() { var result = Array.Empty().ToLoadBalancerProperties(); - result.Should().BeEmpty(); + result.Should().BeNull(); } [Test] diff --git a/source/Calamari.Tests/Calamari.Tests.csproj b/source/Calamari.Tests/Calamari.Tests.csproj index 396bb665b..26f119a29 100644 --- a/source/Calamari.Tests/Calamari.Tests.csproj +++ b/source/Calamari.Tests/Calamari.Tests.csproj @@ -161,6 +161,9 @@ PreserveNewest + + Always + From f40baff56c78175132735b3efae32cb3345b2722 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 14:57:03 +1000 Subject: [PATCH 59/80] Add original template scenarios --- .../Ecs/EcsDeployTemplateGeneratorTests.cs | 31 ++ .../SpfOutputs/complexSpfOutputTemplate.json | 319 ++++++++++-------- 2 files changed, 200 insertions(+), 150 deletions(-) diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs index d80b80736..7e40f660f 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -130,6 +130,37 @@ public void WithMultipleContainers_MatchesExpectedSpfOutput() JToken.DeepEquals(resultJson, expectedJson).Should().BeTrue(); } + [Test] + public void WithComplexStep_MatchesExpectedSpfOutput() + { + var expectedJson = ReadFromFile("complexSpfOutputTemplate.json"); + + var variables = new CalamariVariables + { + {"Octopus.Action.Aws.Ecs.Deploy.CFStackName", "test-stack"}, + {"Octopus.Action.Aws.Ecs.Deploy.DesiredCount","2"}, + {"Octopus.Action.Aws.Ecs.Deploy.MinimumHealthPercent","150"}, + {"Octopus.Action.Aws.Ecs.Deploy.MaximumHealthPercent","300"}, + {"Octopus.Action.Aws.Ecs.Deploy.Cpu","256"}, + {"Octopus.Action.Aws.Ecs.Deploy.Memory","512"}, + {"Octopus.Action.Aws.Ecs.Deploy.RuntimeArchitecturePlatform","X86_64"}, + {"Octopus.Action.Aws.Ecs.Deploy.AutoAssignPublicIp","True"}, + {"Octopus.Action.Aws.Ecs.Deploy.EnableEcsManagedTags","True"}, + {"Octopus.Action.Aws.Ecs.Deploy.ServiceTaskName","test-big-cf-template"}, + {"Octopus.Action.Aws.Ecs.Deploy.TaskRole","arn:aws:iam::120766170633:role/ecsTaskExecutionRole"}, + {"Octopus.Action.Aws.Ecs.Deploy.TaskExecutionRole","arn:aws:iam::120766170633:role/ecsTaskExecutionRole"}, + {"Octopus.Action.Aws.Ecs.Deploy.SubnetIds","[\"subnet-0650cd8a2119e8xxx\"]"}, + {"Octopus.Action.Aws.Ecs.Deploy.SecurityGroupIds","[\"sg-0d5e06a4bde84dxxx\"]"}, + {"Octopus.Action.Aws.Ecs.Deploy.LoadBalancerMappings","[]"}, + {"Octopus.Action.Aws.Ecs.Deploy.Volumes","[{\"type\":\"Efs\",\"name\":\"efs-volume\",\"fileSystemId\":\"efs-fs-id\",\"accessPointId\":\"/data\",\"rootDirectory\":\"/root\",\"encryptionInTransit\":\"True\",\"efsIamAuthorization\":\"True\"}]"}, + {"Octopus.Action.Aws.Ecs.Tags","[{\"key\":\"my-tag\",\"value\":\"a great test value\"}]"}, + {"Octopus.Action.Aws.Ecs.WaitOption", "{\"type\":\"DontWait\",\"timeoutMinutes\":\"30\"}"}, + {"Octopus.Action.Aws.Ecs.Deploy.Containers", """[{"containerName":"web-server","containerImageReference":{"referenceId":"939a08a0-7dd9-471d-ac31-ac8e29eb04ff","imageName":"#{Octopus.Action[Deploy Amazon ECS Service - clone (1)].Package[nginx].Image}","feedId":"Feeds-1061"},"repositoryAuthentication":{"type":"Default"},"memoryLimitSoft":"47","memoryLimitHard":"200","containerPortMappings":[{"containerPort":"80","protocol":"Tcp"},{"containerPort":"443","protocol":"Tcp"}],"cpus":"2","essential":"True","entryPoint":"sh, -c","command":"echo 'Deployment successful","workingDirectory":"/tmp","environmentFiles":["jttestc668db76/test/keyarm-packagev1.0.3.zip"],"environmentVariables":[{"type":"Plain","key":"containerenv","value":"some-otherovalue"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"True","mountPoints":[{"sourceVolume":"efs-volume","containerPath":"/etc","readonly":"False"}],"volumeFrom":[{"sourceContainer":"efs-volume","readonly":"True"}]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Enabled","firelensType":"Fluentd","enableEcsLogMetadata":"True","customConfigSource":{"type":"File","filePath":"/home/config"}},"dockerLabels":[{"key":"some-label","value":"label-value"}],"user":"test-user","healthCheck":{"command":["curl -f http://localhost/ || exit 1"],"interval":"240","retries":"7","startPeriod":"179","timeout":"54"},"dependencies":[],"startTimeout":"40","stopTimeout":"60","ulimits":[{"limitName":"core","hardLimit":"12","softLimit":"10"}]}] """}, + }; + + var resultJson = GenerateTemplateFromVariables(variables); + JToken.DeepEquals(resultJson, expectedJson).Should().BeTrue(); + } JObject GenerateTemplateFromVariables(CalamariVariables variables) { var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json index b2ade3440..2b9b03a18 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -1,113 +1,128 @@ { "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "ClusterName": { + "Type": "String" + }, + "TaskDefinitionName": { + "Type": "String", + "Default": "test-big-cf-template" + }, + "TaskDefinitionCPU": { + "Type": "String", + "Default": "256" + }, + "TaskDefinitionMemory": { + "Type": "String", + "Default": "512" + }, + "TaskRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "TaskExecutionRole": { + "Type": "String", + "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" + }, + "DesiredCount": { + "Type": "Number", + "Default": 2 + }, + "MinimumHealthPercent": { + "Type": "Number", + "Default": 150 + }, + "MaximumHealthPercent": { + "Type": "Number", + "Default": 300 + }, + "LogGroupName": { + "Type": "String", + "Default": "/ecs/test-big-cf-template" + } + }, "Resources": { - "ServicetestBigCfTemplate": { - "Type": "AWS::ECS::Service", + "AwsLogGrouptestBigCfTemplate": { + "Type": "AWS::Logs::LogGroup", "Properties": { - "Cluster": { - "Ref": "ClusterName" - }, - "TaskDefinition": { - "Ref": "TaskDefinitiontestBigCfTemplate" - }, - "DesiredCount": { - "Ref": "DesiredCount" - }, - "EnableECSManagedTags": true, - "Tags": [ - { - "Key": "my-tag", - "Value": "a great test value" - } - ], - "LaunchType": "FARGATE", - "NetworkConfiguration": { - "AwsvpcConfiguration": { - "AssignPublicIp": "ENABLED", - "SecurityGroups": [ - "sg-0d5e06a4bde84dxxx" - ], - "Subnets": [ - "subnet-0650cd8a2119e8xxx" - ] - } - }, - "DeploymentConfiguration": { - "MaximumPercent": 200, - "MinimumHealthyPercent": 100 + "LogGroupName": { + "Ref": "LogGroupName" } - }, - "DependsOn": "TaskDefinitiontestBigCfTemplate" + } }, "TaskDefinitiontestBigCfTemplate": { "Type": "AWS::ECS::TaskDefinition", "Properties": { "ContainerDefinitions": [ { - "Essential": true, - "EntryPoint": [ - "sh", - "-c" - ], "Command": [ "echo 'Deployment successful" ], - "WorkingDirectory": "/tmp", - "Image": "#{Octopus.Action[test-step].Package[nginx].Image}", - "Memory": 200, - "MemoryReservation": 47, - "Name": "web-server", "Cpu": 2, - "ResourceRequirements": [], - "EnvironmentFiles": [ - { - "Type": "s3", - "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" - } - ], - "Environment": [ - { - "Name": "containerenv", - "Value": "some-otherovalue" - } - ], "DisableNetworking": false, - "DnsServers": [], "DnsSearchDomains": [], - "ExtraHosts": [], - "User": "test-user", + "DnsServers": [], "DockerLabels": { "some-label": "label-value" }, - "MountPoints": [ + "EntryPoint": [ + "sh", + "-c" + ], + "Environment": [ { - "SourceVolume": "efs-volume", - "ContainerPath": "/etc", - "ReadOnly": false + "Name": "containerenv", + "Value": "some-otherovalue" } ], - "VolumesFrom": [ + "EnvironmentFiles": [ { - "SourceContainer": "efs-volume", - "ReadOnly": true + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" } ], - "LogConfiguration": { - "LogDriver": "awslogs", - "Options": { - "awslogs-group": "/ecs/test-big-cf-template", - "awslogs-region": "#{Octopus.Action.Aws.Region}", - "awslogs-stream-prefix": "ecs" - } - }, + "Essential": true, + "ExtraHosts": [], "FirelensConfiguration": { - "Type": "fluentd", "Options": { "enable-ecs-log-metadata": "true", "config-file-type": "file", "config-file-value": "/home/config" + }, + "Type": "fluentd" + }, + "HealthCheck": { + "Command": [ + "curl -f http://localhost/ || exit 1" + ], + "Interval": 240, + "Retries": 7, + "StartPeriod": 179, + "Timeout": 54 + }, + "Image": "#{Octopus.Action[Deploy Amazon ECS Service - clone (1)].Package[nginx].Image}", + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" } }, + "Memory": 200, + "MemoryReservation": 47, + "MountPoints": [ + { + "ContainerPath": "/etc", + "ReadOnly": false, + "SourceVolume": "efs-volume" + } + ], + "Name": "web-server", "PortMappings": [ { "ContainerPort": 80, @@ -120,110 +135,114 @@ "Protocol": "tcp" } ], - "HealthCheck": { - "Command": [ - "curl -f http://localhost/ || exit 1" - ], - "Interval": 240, - "Retries": 7, - "StartPeriod": 179, - "Timeout": 54 - }, + "ReadonlyRootFilesystem": true, + "ResourceRequirements": [], "StartTimeout": 40, "StopTimeout": 60, "Ulimits": [ { - "Name": "core", "HardLimit": 12, + "Name": "core", "SoftLimit": 10 } - ] + ], + "User": "test-user", + "VolumesFrom": [ + { + "ReadOnly": true, + "SourceContainer": "efs-volume" + } + ], + "WorkingDirectory": "/tmp" } ], - "Family": { - "Ref": "TaskDefinitionName" - }, "Cpu": { "Ref": "TaskDefinitionCPU" }, - "Memory": { - "Ref": "TaskDefinitionMemory" - }, "ExecutionRoleArn": { "Ref": "TaskExecutionRole" }, - "TaskRoleArn": { - "Ref": "TaskRole" + "Family": { + "Ref": "TaskDefinitionName" }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "NetworkMode": "awsvpc", "RequiresCompatibilities": [ "FARGATE" ], - "NetworkMode": "awsvpc", + "RuntimePlatform": { + "CpuArchitecture": "X86_64", + "OperatingSystemFamily": "LINUX" + }, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ], + "TaskRoleArn": { + "Ref": "TaskRole" + }, "Volumes": [ { - "Name": "efs-volume", "EFSVolumeConfiguration": { - "FilesystemId": "efs-fs-id", - "RootDirectory": "/root", - "TransitEncryption": "ENABLED", "AuthorizationConfig": { + "AccessPointId": "/data", "IAM": "ENABLED" - } - } + }, + "FilesystemId": "efs-fs-id", + "RootDirectory": "/root", + "TransitEncryption": "ENABLED" + }, + "Name": "efs-volume" } - ], + ] + } + }, + "ServicetestBigCfTemplate": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "ClusterName" + }, + "DeploymentConfiguration": { + "MaximumPercent": { + "Ref": "MaximumHealthPercent" + }, + "MinimumHealthyPercent": { + "Ref": "MinimumHealthPercent" + } + }, + "DesiredCount": { + "Ref": "DesiredCount" + }, + "EnableECSManagedTags": true, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "SecurityGroups": [ + "sg-0d5e06a4bde84dxxx" + ], + "Subnets": [ + "subnet-0650cd8a2119e8xxx" + ] + } + }, "Tags": [ { "Key": "my-tag", "Value": "a great test value" } ], - "RuntimePlatform": { - "CpuArchitecture": "X86_64", - "OperatingSystemFamily": "LINUX" - } - } - }, - "AwsLogGrouptestBigCfTemplate": { - "Type": "AWS::Logs::LogGroup", - "Properties": { - "LogGroupName": { - "Ref": "LogGroupName" + "TaskDefinition": { + "Ref": "TaskDefinitiontestBigCfTemplate" } - } - } - }, - "Parameters": { - "ClusterName": { - "Type": "String", - "Default": "#{Octopus.Action.Aws.Ecs.ClusterName}" - }, - "TaskDefinitionName": { - "Type": "String", - "Default": "test-big-cf-template" - }, - "DesiredCount": { - "Type": "Number", - "Default": 2 - }, - "TaskDefinitionCPU": { - "Type": "String", - "Default": "256" - }, - "TaskDefinitionMemory": { - "Type": "String", - "Default": "512" - }, - "TaskExecutionRole": { - "Type": "String", - "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" - }, - "TaskRole": { - "Type": "String", - "Default": "arn:aws:iam::120766170633:role/ecsTaskExecutionRole" - }, - "LogGroupName": { - "Type": "String" + }, + "DependsOn": ["TaskDefinitiontestBigCfTemplate"] + } } -} +} \ No newline at end of file From f932e484de773348dd366370f92a2094e77f2de9 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 17:19:11 +1000 Subject: [PATCH 60/80] Get rid of AWS library --- source/Calamari.Aws/Calamari.Aws.csproj | 1 - .../Ecs/ContainerSpecMappingExtensions.cs | 289 +++++++-------- .../Ecs/LoadBalancerMappingExtensions.cs | 22 +- .../Inputs/Ecs/TagMappingExtensions.cs | 16 +- .../Ecs/TaskExecutionRoleMappingExtensions.cs | 51 --- .../Calamari.Aws/Inputs/Ecs/VolumeMapper.cs | 62 ++-- .../Ecs/Deploy/Cfn/ContainerDefinition.cs | 131 +++++++ .../Integration/Ecs/Deploy/Cfn/IamRole.cs | 26 ++ .../Integration/Ecs/Deploy/Cfn/Intrinsics.cs | 56 +++ .../Integration/Ecs/Deploy/Cfn/LogGroup.cs | 6 + .../Integration/Ecs/Deploy/Cfn/Service.cs | 46 +++ .../Ecs/Deploy/Cfn/TaskDefinition.cs | 47 +++ .../Integration/Ecs/Deploy/Cfn/Template.cs | 44 +++ .../Deploy/EcsDeployParameterGeneration.cs | 2 +- .../Ecs/Deploy/EcsDeployTemplate.cs | 332 ++++++++++-------- .../Ecs/Deploy/EcsDeployTemplateGenerator.cs | 52 +-- .../SpfOutputs/complexSpfOutputTemplate.json | 219 ++++++------ .../multiContainerSpfOutputTemplate.json | 226 ++++++------ .../SpfOutputs/simpleSpfOutputTemplate.json | 101 +++--- .../ContainerSpecMappingExtensionsTests.cs | 50 ++- ...TaskExecutionRoleMappingExtensionsTests.cs | 114 ------ .../Ecs/VolumeMappingExtensionsTests.cs | 58 ++- 22 files changed, 1030 insertions(+), 921 deletions(-) delete mode 100644 source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/IamRole.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Intrinsics.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/LogGroup.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/TaskDefinition.cs create mode 100644 source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs delete mode 100644 source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs diff --git a/source/Calamari.Aws/Calamari.Aws.csproj b/source/Calamari.Aws/Calamari.Aws.csproj index f4409b927..435e3e63b 100644 --- a/source/Calamari.Aws/Calamari.Aws.csproj +++ b/source/Calamari.Aws/Calamari.Aws.csproj @@ -22,7 +22,6 @@ true - diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index ee8636a1b..9f594bff5 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -1,195 +1,157 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; -using Amazon.CDK.AWS.ECS; -using Amazon.ECS; using Octopus.Calamari.Contracts.Aws.Ecs; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; using LogDriver = Octopus.Calamari.Contracts.Aws.Ecs.LogDriver; namespace Calamari.Aws.Inputs.Ecs; public static class ContainerSpecMappingExtensions { - public static T ConvertedOrDefault(this string value, Func converter, Func defaultOverride = null) { - return defaultOverride != null ? string.IsNullOrEmpty(value) ? defaultOverride() : converter(value) : string.IsNullOrEmpty(value) ? default : converter(value); + return defaultOverride != null + ? string.IsNullOrEmpty(value) ? defaultOverride() : converter(value) + : string.IsNullOrEmpty(value) ? default : converter(value); } - public static CfnTaskDefinition.HealthCheckProperty ParseHealthCheck(this ContainerSpec containerSpec) + public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) { - if (containerSpec.HealthCheck.Command.Count > 0) + if (containerSpec.HealthCheck.Command.Count == 0) return null; + + return new Cfn.HealthCheck { - return new CfnTaskDefinition.HealthCheckProperty - { - Command = containerSpec.HealthCheck.Command.ToArray(), - Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s)), - Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s)), - StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => double.Parse(s)), - Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s)), - }; - } - return null; + Command = containerSpec.HealthCheck.Command.ToArray(), + Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + }; } public static Dictionary ParseDockerLabels(this ContainerSpec containerSpec) { - // Grouping Handle potential duplicates - var dockerLabels = containerSpec.DockerLabels - .GroupBy(kvp => kvp.Key).ToDictionary(g => g.Key, g => g.Last().Value); - return dockerLabels.Count == 0 ? null : dockerLabels; - + var labels = containerSpec.DockerLabels + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.Last().Value); + return labels.Count == 0 ? null : labels; } - public static CfnTaskDefinition.KeyValuePairProperty[] ParseEnvironmentVariables(this ContainerSpec containerSpec) + public static Cfn.EnvironmentEntry[] ParseEnvironmentVariables(this ContainerSpec containerSpec) { - var environmentVariables = containerSpec.EnvironmentVariables - .Where(tkp => tkp.Type == KeyValueType.Plain) - .GroupBy(kvp => kvp.Key) - .Select(g => new CfnTaskDefinition.KeyValuePairProperty - { - Name = g.Key, - Value = g.Last().Value, - }) - .ToArray(); - - return environmentVariables.Length == 0 ? null : environmentVariables; - + var entries = containerSpec.EnvironmentVariables + .Where(tkp => tkp.Type == KeyValueType.Plain) + .GroupBy(kvp => kvp.Key) + .Select(g => new Cfn.EnvironmentEntry { Name = g.Key, Value = g.Last().Value }) + .ToArray(); + return entries.Length == 0 ? null : entries; } - public static CfnTaskDefinition.PortMappingProperty[] ParsePortMappings(this ContainerSpec containerSpec) - { - return containerSpec.ContainerPortMappings.Select(pm => new CfnTaskDefinition.PortMappingProperty - { - ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), - HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), - Protocol = pm.Protocol.ToString().ToLower(), - - }) - .ToArray(); - } + // SPF always emits PortMappings as an array — empty becomes [] not omitted. + public static Cfn.PortMapping[] ParsePortMappings(this ContainerSpec containerSpec) => + containerSpec.ContainerPortMappings.Select(pm => new Cfn.PortMapping + { + ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Protocol = pm.Protocol.ToString().ToLowerInvariant() + }).ToArray(); - public static CfnTaskDefinition.HostEntryProperty[] ParseExtraHosts(this ContainerSpec containerSpec) - { - return containerSpec.NetworkSettings.ExtraHosts.Select(eh => new CfnTaskDefinition.HostEntryProperty + // SPF always emits ExtraHosts as an array — empty becomes [] not omitted. + public static Cfn.ExtraHost[] ParseExtraHosts(this ContainerSpec containerSpec) => + containerSpec.NetworkSettings.ExtraHosts.Select(eh => new Cfn.ExtraHost { - Hostname = string.IsNullOrEmpty(eh.Hostname) ? null : eh.Hostname, + Hostname = string.IsNullOrEmpty(eh.Hostname) ? null : eh.Hostname, IpAddress = string.IsNullOrEmpty(eh.IpAddress) ? null : eh.IpAddress, }).ToArray(); - } - public static CfnTaskDefinition.RepositoryCredentialsProperty ParseRepositoryCredentials(this ContainerSpec containerSpec) - { - return containerSpec.RepositoryAuthentication.Type switch - { - RepositoryAuthenticationType.Default => null, - _ => new CfnTaskDefinition.RepositoryCredentialsProperty - { - CredentialsParameter = containerSpec.RepositoryAuthentication.SecretName - } - }; - } + public static Cfn.RepositoryCredentials ParseRepositoryCredentials(this ContainerSpec containerSpec) => + containerSpec.RepositoryAuthentication.Type switch + { + RepositoryAuthenticationType.Default => null, + _ => new Cfn.RepositoryCredentials + { + CredentialsParameter = containerSpec.RepositoryAuthentication.SecretName + } + }; - public static CfnTaskDefinition.ResourceRequirementProperty[] ParseResourceRequirements(this ContainerSpec containerSpec) - { - return string.IsNullOrEmpty(containerSpec.Gpus) + // SPF always emits ResourceRequirements as an array — empty becomes [] not omitted. + public static Cfn.ResourceRequirement[] ParseResourceRequirements(this ContainerSpec containerSpec) => + string.IsNullOrEmpty(containerSpec.Gpus) ? [] - : [new CfnTaskDefinition.ResourceRequirementProperty - { - Type = ResourceType.GPU, - Value = containerSpec.Gpus, - }]; - } + : [new Cfn.ResourceRequirement { Type = "GPU", Value = containerSpec.Gpus }]; - public static CfnTaskDefinition.UlimitProperty[] ParseULimits(this ContainerSpec containerSpec) + public static Cfn.Ulimit[] ParseULimits(this ContainerSpec containerSpec) { - if (containerSpec.Ulimits.Count > 0) - { - return containerSpec.Ulimits.Select(ul => new CfnTaskDefinition.UlimitProperty - { - Name = new Amazon.ECS.UlimitName(ul.LimitName), - HardLimit = double.Parse(ul.HardLimit), - SoftLimit = double.Parse(ul.SoftLimit), - - }).ToArray(); - } + if (containerSpec.Ulimits.Count == 0) return null; - return null; + return containerSpec.Ulimits.Select(ul => new Cfn.Ulimit + { + Name = ul.LimitName, + HardLimit = double.Parse(ul.HardLimit, CultureInfo.InvariantCulture), + SoftLimit = double.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) + }).ToArray(); } - public static CfnTaskDefinition.MountPointProperty[] ParseMountPoints(this ContainerSpec containerSpec) + public static Cfn.MountPoint[] ParseMountPoints(this ContainerSpec containerSpec) { - if (containerSpec.ContainerStorage.MountPoints.Count > 0) - { - return containerSpec.ContainerStorage.MountPoints.Select(mp => new CfnTaskDefinition.MountPointProperty - { - SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, - ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, - ReadOnly = mp.Readonly.ConvertedOrDefault(bool.Parse) - }) - .ToArray(); - } + if (containerSpec.ContainerStorage.MountPoints.Count == 0) return null; - return null; + return containerSpec.ContainerStorage.MountPoints.Select(mp => new Cfn.MountPoint + { + SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, + ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, + ReadOnly = mp.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + }).ToArray(); } - public static CfnTaskDefinition.ContainerDependencyProperty[] ParseDependencies(this ContainerSpec containerSpec) + public static Cfn.ContainerDependency[] ParseDependencies(this ContainerSpec containerSpec) { - if (containerSpec.Dependencies.Count > 0) - { - return containerSpec.Dependencies.Select(d => new CfnTaskDefinition.ContainerDependencyProperty - { - ContainerName = string.IsNullOrEmpty(d.ContainerName) ? null : d.ContainerName, - Condition = d.Condition.ToString().ToUpperInvariant(), - }).ToArray(); - } + if (containerSpec.Dependencies.Count == 0) return null; - return null; - } - - public static CfnTaskDefinition.VolumeFromProperty[] ParseVolumesFrom(this ContainerSpec containerSpec) - { - if (containerSpec.ContainerStorage.VolumeFrom.Count > 0) + return containerSpec.Dependencies.Select(d => new Cfn.ContainerDependency { - return containerSpec.ContainerStorage.VolumeFrom.Select(vf => new CfnTaskDefinition.VolumeFromProperty - { - SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, - ReadOnly = vf.Readonly.ConvertedOrDefault(bool.Parse) - }).ToArray(); - } - - return null; + ContainerName = string.IsNullOrEmpty(d.ContainerName) ? null : d.ContainerName, + Condition = d.Condition.ToString().ToUpperInvariant(), + }).ToArray(); } - public static CfnTaskDefinition.EnvironmentFileProperty[] ParseEnvironmentFiles(this ContainerSpec containerSpec) + public static Cfn.VolumeFrom[] ParseVolumesFrom(this ContainerSpec containerSpec) { - if (containerSpec.EnvironmentFiles.Count > 0) - { - return containerSpec.EnvironmentFiles.Select(ef => new CfnTaskDefinition.EnvironmentFileProperty - { - Type = "s3", // Hardcoded to always be S3 until we support other options - Value = ef - - }).ToArray(); - } + if (containerSpec.ContainerStorage.VolumeFrom.Count == 0) return null; - return []; + return containerSpec.ContainerStorage.VolumeFrom.Select(vf => new Cfn.VolumeFrom + { + SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, + ReadOnly = vf.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + }).ToArray(); } - public static CfnTaskDefinition.LogConfigurationProperty ParseLogConfiguration( + // SPF always emits EnvironmentFiles as an array — empty becomes [] not omitted. + public static Cfn.EnvironmentFile[] ParseEnvironmentFiles(this ContainerSpec containerSpec) => + containerSpec.EnvironmentFiles.Select(ef => new Cfn.EnvironmentFile + { + Type = "s3", // Hardcoded until we support other options + Value = ef + }).ToArray(); + + // logGroupNameRef and awsRegionRef are passed as Cfn.Value so callers can hand + // in either a literal or a Ref intrinsic. The Auto path consumes them; Manual ignores. + public static Cfn.LogConfiguration ParseLogConfiguration( this ContainerSpec containerSpec, - string logGroupNameRef, - string awsRegionRef) + Cfn.Value logGroupNameRef, + Cfn.Value awsRegionRef) { switch (containerSpec.ContainerLogging.Type) { case ContainerLoggingType.Auto: // Auto = "wire it up for me". LogDriver/LogOptions on the spec are ignored; - // we emit the standard awslogs configuration pointing at the task's log group. - return new CfnTaskDefinition.LogConfigurationProperty + // emit the standard awslogs configuration pointing at the task's log group. + return new Cfn.LogConfiguration { LogDriver = LogDriver.AwsLogs.ToString().ToLowerInvariant(), - Options = new Dictionary + Options = new Dictionary> { { "awslogs-group", logGroupNameRef }, { "awslogs-region", awsRegionRef }, @@ -206,57 +168,52 @@ public static CfnTaskDefinition.LogConfigurationProperty ParseLogConfiguration( return null; } - return new CfnTaskDefinition.LogConfigurationProperty + return new Cfn.LogConfiguration { LogDriver = containerSpec.ContainerLogging.LogDriver.Value.ToString().ToLowerInvariant(), Options = containerSpec.ContainerLogging.LogOptions - .Where(lo => lo.Type == KeyValueType.Plain) - .ToDictionary(opt => opt.Key, opt => opt.Value), + .Where(o => o.Type == KeyValueType.Plain) + .ToDictionary>( + o => o.Key, + o => o.Value), SecretOptions = containerSpec.ContainerLogging.LogOptions - .Where(lo => lo.Type == KeyValueType.Secret) - .ToDictionary(opt => opt.Key, opt => opt.Value), + .Where(o => o.Type == KeyValueType.Secret) + .Select(o => new Cfn.Secret { Name = o.Key, ValueFrom = o.Value }) + .ToArray() }; } } - public static CfnTaskDefinition.FirelensConfigurationProperty ParseFireLensConfiguration(this ContainerSpec containerSpec) + public static Cfn.FirelensConfiguration ParseFireLensConfiguration(this ContainerSpec containerSpec) { - if (containerSpec.FirelensConfiguration.Type == FireLensConfigurationType.Disabled) - { - return null; - } + if (containerSpec.FirelensConfiguration.Type == FireLensConfigurationType.Disabled) return null; var options = new Dictionary { - { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata.ToLowerInvariant() } + { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata?.ToLowerInvariant() } }; - if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None }) + + if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None } src) { - options.Add("config-file-type", containerSpec.FirelensConfiguration.CustomConfigSource.Type.ToString().ToLowerInvariant()); - options.Add("config-file-value", containerSpec.FirelensConfiguration.CustomConfigSource.FilePath); + options.Add("config-file-type", src.Type.ToString().ToLowerInvariant()); + options.Add("config-file-value", src.FilePath); } - var fireLensConfig = new CfnTaskDefinition.FirelensConfigurationProperty + + return new Cfn.FirelensConfiguration { - Type = containerSpec.FirelensConfiguration.FirelensType.ToString()?.ToLowerInvariant(), + Type = containerSpec.FirelensConfiguration.FirelensType?.ToString().ToLowerInvariant(), Options = options - }; - - return fireLensConfig; } - public static CfnTaskDefinition.SecretProperty[] ParseSecrets(this ContainerSpec containerSpec) + public static Cfn.Secret[] ParseSecrets(this ContainerSpec containerSpec) { - var secrets = containerSpec.EnvironmentVariables - .Where(tkp => tkp.Type == KeyValueType.Secret) - .GroupBy(kvp => kvp.Key) // Dedupe - .Select(g => new CfnTaskDefinition.SecretProperty() - { - Name = g.Key, - ValueFrom = g.Last().Value - }) - .ToArray(); - - return secrets.Length > 0 ? secrets : null; + var secrets = containerSpec.EnvironmentVariables + .Where(tkp => tkp.Type == KeyValueType.Secret) + .GroupBy(kvp => kvp.Key) + .Select(g => new Cfn.Secret { Name = g.Key, ValueFrom = g.Last().Value }) + .ToArray(); + return secrets.Length == 0 ? null : secrets; } -} \ No newline at end of file + +} diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs index 6dc25e72f..83e59e6bc 100644 --- a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -1,22 +1,22 @@ using System.Collections.Generic; +using System.Globalization; using System.Linq; -using Amazon.CDK.AWS.ECS; using Octopus.Calamari.Contracts.Aws.Ecs; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; namespace Calamari.Aws.Inputs.Ecs; public static class LoadBalancerMappingExtensions { - public static CfnService.LoadBalancerProperty[] ToLoadBalancerProperties(this IEnumerable loadBalancerMappings) + public static Cfn.LoadBalancer[] ToLoadBalancerProperties(this IEnumerable loadBalancerMappings) { - var lbMappings = loadBalancerMappings.Select(lbm => new CfnService.LoadBalancerProperty - { - ContainerName = lbm.ContainerName, - ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => double.Parse(s)), - TargetGroupArn = lbm.TargetGroupArn, - }) - .ToArray(); + var mappings = loadBalancerMappings.Select(lbm => new Cfn.LoadBalancer + { + ContainerName = lbm.ContainerName, + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + TargetGroupArn = lbm.TargetGroupArn + }).ToArray(); - return lbMappings.Length == 0 ? null : lbMappings; + return mappings.Length == 0 ? null : mappings; } -} \ No newline at end of file +} diff --git a/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs index 4951b26bc..b80e7cace 100644 --- a/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs @@ -1,18 +1,12 @@ using System.Collections.Generic; using System.Linq; -using Amazon.CDK; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; namespace Calamari.Aws.Inputs.Ecs; public static class TagMappingExtensions { - public static ICfnTag[] ToCloudFormationTags(this IEnumerable> tags) - { - return tags.Select(t => new CfnTag - { - Key = t.Key, - Value = t.Value - }) - .ToArray(); - } -} \ No newline at end of file + // SPF always emits Tags as an array — empty becomes [] not omitted. + public static Cfn.Tag[] ToCloudFormationTags(this IEnumerable> tags) => + tags.Select(t => new Cfn.Tag { Key = t.Key, Value = t.Value }).ToArray(); +} diff --git a/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs deleted file mode 100644 index 02d2787a9..000000000 --- a/source/Calamari.Aws/Inputs/Ecs/TaskExecutionRoleMappingExtensions.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Amazon.CDK; -using Amazon.CDK.AWS.IAM; -using Constructs; - -namespace Calamari.Aws.Inputs.Ecs; - -public static class TaskExecutionRoleMappingExtensions -{ - const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; - - public static string MapTaskExecutionRoleArn(this DeployEcsCommandInputs inputs, Construct scope) - { - if (!string.IsNullOrEmpty(inputs.TaskExecutionRole)) - { - return inputs.TaskExecutionRole; - } - - var policyArnParam = new CfnParameter(scope, - "AmazonECSTaskExecutionRolePolicyArn", - new CfnParameterProps - { - Type = "String", - Default = DefaultTaskExecutionPolicyArn - }); - - var role = new CfnRole(scope, - inputs.FallbackTaskExecutionRoleName, - new CfnRoleProps - { - Path = "/", - ManagedPolicyArns = [policyArnParam.ValueAsString], - AssumeRolePolicyDocument = new PolicyDocument(new PolicyDocumentProps - { - Statements = - [ - new PolicyStatement(new PolicyStatementProps - { - Effect = Effect.ALLOW, - Principals = [new ServicePrincipal("ecs-tasks.amazonaws.com")], - Actions = ["sts:AssumeRole"] - - }) - ] - }) - }); - - - return role.Ref; - - } -} \ No newline at end of file diff --git a/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs index c5c7250b7..d778f382f 100644 --- a/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs +++ b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs @@ -1,45 +1,39 @@ using System.Linq; -using Amazon.CDK.AWS.ECS; using Octopus.Calamari.Contracts.Aws.Ecs; -using Volume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; +using InputVolume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; namespace Calamari.Aws.Inputs.Ecs; public static class VolumeMappingExtensions { - public static CfnTaskDefinition.VolumeProperty[] ParseVolumes(this Volume[] volumes) + // SPF always emits Volumes as an array — empty becomes [] not omitted. + public static Cfn.Volume[] ParseVolumes(this InputVolume[] volumes) { - if (volumes.Length > 0) - { - var boundVolumes = volumes - .Where(v => v.Type == VolumeType.Bind).Select(v => new CfnTaskDefinition.VolumeProperty() + if (volumes.Length == 0) return []; + + var boundVolumes = volumes.Where(v => v.Type == VolumeType.Bind) + .Select(v => new Cfn.Volume { Name = v.Name }); + + var efsVolumes = volumes.Where(v => v.Type == VolumeType.Efs) + .Select(v => new Cfn.Volume { - Name = v.Name, - - }).ToArray(); - var efsVolumes = volumes - .Where(v => v.Type == VolumeType.Efs) - .Select(v => new CfnTaskDefinition.VolumeProperty - { - Name = v.Name, - EfsVolumeConfiguration = new CfnTaskDefinition.EFSVolumeConfigurationProperty - { - AuthorizationConfig = new CfnTaskDefinition.AuthorizationConfigProperty - { - Iam = v.EfsIamAuthorization.Equals(true.ToString()) ? "ENABLED" : "DISABLED", - //SPF didn't appear to be outputting this, we're adding here because it seems correct to - // No one ever complained, so we may not have customers leveraging Efs IAM Auth? - AccessPointId = v.AccessPointId - }, - FilesystemId = v.FileSystemId!, - RootDirectory = v.RootDirectory, - TransitEncryption = v.EncryptionInTransit.Equals(true.ToString()) ? "ENABLED" : "DISABLED", - } - }) - .ToArray(); - return boundVolumes.Concat(efsVolumes).ToArray(); - } + Name = v.Name, + EFSVolumeConfiguration = new Cfn.EfsVolumeConfiguration + { + FilesystemId = v.FileSystemId!, + RootDirectory = v.RootDirectory, + TransitEncryption = v.EncryptionInTransit.Equals(true.ToString()) ? "ENABLED" : "DISABLED", + AuthorizationConfig = new Cfn.AuthorizationConfig + { + Iam = v.EfsIamAuthorization.Equals(true.ToString()) ? "ENABLED" : "DISABLED", + // SPF didn't appear to be outputting this; we add it because it seems correct. + // No customer has complained, so we may not have anyone leveraging EFS IAM Auth. + AccessPointId = v.AccessPointId + } + } + }); - return null; + return boundVolumes.Concat(efsVolumes).ToArray(); } -} \ No newline at end of file +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs new file mode 100644 index 000000000..522f833ad --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; + +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record ContainerDefinition +{ + public string Name { get; init; } + public string Image { get; init; } + public bool? Essential { get; init; } + public bool? DisableNetworking { get; init; } + public string WorkingDirectory { get; init; } + public double? Memory { get; init; } + public double? MemoryReservation { get; init; } + public double? Cpu { get; init; } + public string User { get; init; } + public double? StartTimeout { get; init; } + public double? StopTimeout { get; init; } + public string[] DnsServers { get; init; } + public string[] DnsSearchDomains { get; init; } + public bool? ReadonlyRootFilesystem { get; init; } + public string[] Command { get; init; } + public string[] EntryPoint { get; init; } + public ResourceRequirement[] ResourceRequirements { get; init; } + public Dictionary DockerLabels { get; init; } + public PortMapping[] PortMappings { get; init; } + public HealthCheck HealthCheck { get; init; } + public ExtraHost[] ExtraHosts { get; init; } + public RepositoryCredentials RepositoryCredentials { get; init; } + public Ulimit[] Ulimits { get; init; } + public MountPoint[] MountPoints { get; init; } + public ContainerDependency[] DependsOn { get; init; } + public VolumeFrom[] VolumesFrom { get; init; } + public LogConfiguration LogConfiguration { get; init; } + public EnvironmentFile[] EnvironmentFiles { get; init; } + public FirelensConfiguration FirelensConfiguration { get; init; } + public EnvironmentEntry[] Environment { get; init; } + public Secret[] Secrets { get; init; } +} + +public sealed record ResourceRequirement +{ + public string Type { get; init; } + public string Value { get; init; } +} + +public sealed record PortMapping +{ + public double? ContainerPort { get; init; } + public double? HostPort { get; init; } + public string Protocol { get; init; } +} + +public sealed record HealthCheck +{ + public string[] Command { get; init; } + public double? Interval { get; init; } + public double? Retries { get; init; } + public double? StartPeriod { get; init; } + public double? Timeout { get; init; } +} + +public sealed record ExtraHost +{ + public string Hostname { get; init; } + public string IpAddress { get; init; } +} + +public sealed record RepositoryCredentials +{ + public string CredentialsParameter { get; init; } +} + +public sealed record Ulimit +{ + public string Name { get; init; } + public double HardLimit { get; init; } + public double SoftLimit { get; init; } +} + +public sealed record MountPoint +{ + public string SourceVolume { get; init; } + public string ContainerPath { get; init; } + public bool? ReadOnly { get; init; } +} + +public sealed record ContainerDependency +{ + public string ContainerName { get; init; } + public string Condition { get; init; } +} + +public sealed record VolumeFrom +{ + public string SourceContainer { get; init; } + public bool? ReadOnly { get; init; } +} + +// LogConfiguration.Options values can be either a literal string or a Ref intrinsic +// (e.g. awslogs-group → Ref(LogGroupName), awslogs-region → Ref(AWS::Region)). +public sealed record LogConfiguration +{ + public string LogDriver { get; init; } + public Dictionary> Options { get; init; } + public Secret[] SecretOptions { get; init; } +} + +public sealed record FirelensConfiguration +{ + public string Type { get; init; } + public Dictionary Options { get; init; } +} + +// Renamed from "KeyValuePair" to avoid clashing with System.Collections.Generic.KeyValuePair. +public sealed record EnvironmentEntry +{ + public string Name { get; init; } + public string Value { get; init; } +} + +public sealed record Secret +{ + public string Name { get; init; } + public string ValueFrom { get; init; } +} + +public sealed record EnvironmentFile +{ + public string Type { get; init; } + public string Value { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/IamRole.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/IamRole.cs new file mode 100644 index 000000000..5c26ccb11 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/IamRole.cs @@ -0,0 +1,26 @@ +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record IamRoleProperties +{ + public string Path { get; init; } + public Value[] ManagedPolicyArns { get; init; } + public AssumeRolePolicyDocument AssumeRolePolicyDocument { get; init; } +} + +public sealed record AssumeRolePolicyDocument +{ + public string Version { get; init; } + public AssumeRoleStatement[] Statement { get; init; } +} + +public sealed record AssumeRoleStatement +{ + public string Effect { get; init; } + public AssumeRolePrincipal Principal { get; init; } + public string[] Action { get; init; } +} + +public sealed record AssumeRolePrincipal +{ + public string[] Service { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Intrinsics.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Intrinsics.cs new file mode 100644 index 000000000..d19586003 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Intrinsics.cs @@ -0,0 +1,56 @@ +using System; +using Newtonsoft.Json; + +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +// The CloudFormation { "Ref": "Name" } intrinsic. Used for parameter references, +// resource logical-ID references, and pseudo-parameters like "AWS::Region". +public sealed record Ref +{ + [JsonProperty("Ref")] + public string Reference { get; } + + public Ref(string reference) => Reference = reference; +} + +// Wrapper for slots where a CloudFormation property can be either a literal value +// or a Ref intrinsic. Implicit conversions keep call sites tight: +// props.Cpu = "256"; // literal +// props.Cpu = new Ref("TaskDefinitionCPU"); // Ref +// At serialisation, ValueConverter writes just the literal or just the Ref — +// the wrapper itself never appears in the JSON. +[JsonConverter(typeof(ValueConverter))] +public sealed record Value +{ + public T Literal { get; init; } + public Ref Reference { get; init; } + + public static implicit operator Value(T literal) => new() { Literal = literal }; + public static implicit operator Value(Ref reference) => new() { Reference = reference }; +} + + sealed class ValueConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) => + objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Value<>); + + public override bool CanRead => false; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + return; + } + + var type = value.GetType(); + var reference = type.GetProperty(nameof(Value.Reference))!.GetValue(value); + var literal = type.GetProperty(nameof(Value.Literal))!.GetValue(value); + + serializer.Serialize(writer, reference ?? literal); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => + throw new NotSupportedException("Value is write-only — we don't parse CFN templates back into the model."); +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/LogGroup.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/LogGroup.cs new file mode 100644 index 000000000..0fbb76b99 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/LogGroup.cs @@ -0,0 +1,6 @@ +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record LogGroupProperties +{ + public Value LogGroupName { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs new file mode 100644 index 000000000..b2841b159 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json; + +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record ServiceProperties +{ + public Value Cluster { get; init; } + public string LaunchType { get; init; } + public Value TaskDefinition { get; init; } + public Value DesiredCount { get; init; } + + // CFN's actual property name is EnableECSManagedTags (all-caps "ECS"); keep + // the C# property in conventional PascalCase and override the JSON name here. + [JsonProperty("EnableECSManagedTags")] + public bool EnableEcsManagedTags { get; init; } + + public DeploymentConfiguration DeploymentConfiguration { get; init; } + public NetworkConfiguration NetworkConfiguration { get; init; } + public LoadBalancer[] LoadBalancers { get; init; } + public Tag[] Tags { get; init; } +} + +public sealed record DeploymentConfiguration +{ + public Value MinimumHealthyPercent { get; init; } + public Value MaximumPercent { get; init; } +} + +public sealed record NetworkConfiguration +{ + public AwsvpcConfiguration AwsvpcConfiguration { get; init; } +} + +public sealed record AwsvpcConfiguration +{ + public string AssignPublicIp { get; init; } + public string[] Subnets { get; init; } + public string[] SecurityGroups { get; init; } +} + +public sealed record LoadBalancer +{ + public string ContainerName { get; init; } + public double? ContainerPort { get; init; } + public string TargetGroupArn { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/TaskDefinition.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/TaskDefinition.cs new file mode 100644 index 000000000..1391e8fae --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/TaskDefinition.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json; + +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record TaskDefinitionProperties +{ + public ContainerDefinition[] ContainerDefinitions { get; init; } + public Value Family { get; init; } + public Value Cpu { get; init; } + public Value Memory { get; init; } + public Value ExecutionRoleArn { get; init; } + public Value TaskRoleArn { get; init; } + public string[] RequiresCompatibilities { get; init; } + public string NetworkMode { get; init; } + public RuntimePlatform RuntimePlatform { get; init; } + public Volume[] Volumes { get; init; } + public Tag[] Tags { get; init; } +} + +public sealed record RuntimePlatform +{ + public string OperatingSystemFamily { get; init; } + public string CpuArchitecture { get; init; } +} + +public sealed record Volume +{ + public string Name { get; init; } + public EfsVolumeConfiguration EFSVolumeConfiguration { get; init; } +} + +public sealed record EfsVolumeConfiguration +{ + public string FilesystemId { get; init; } + public string RootDirectory { get; init; } + public string TransitEncryption { get; init; } + public AuthorizationConfig AuthorizationConfig { get; init; } +} + +public sealed record AuthorizationConfig +{ + // CFN's actual property name is IAM (all-caps); keep the C# property in + // conventional PascalCase and override the JSON name here. + [JsonProperty("IAM")] + public string Iam { get; init; } + public string AccessPointId { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs new file mode 100644 index 000000000..8e433bebe --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +public sealed record Template +{ + public string AWSTemplateFormatVersion { get; init; } = "2010-09-09"; + public Dictionary Parameters { get; init; } = new(); + public Dictionary Resources { get; init; } = new(); +} + +public sealed record ParameterDef +{ + public string Type { get; init; } + public object Default { get; init; } +} + +// Single record for any CFN resource. Properties is typed as object so different +// resources can carry different strongly-typed property records — the static +// factory methods below enforce the right Properties type per resource kind. +public sealed record Resource +{ + public string Type { get; init; } + public string DependsOn { get; init; } + public object Properties { get; init; } + + public static Resource TaskDefinition(TaskDefinitionProperties properties) => + new() { Type = "AWS::ECS::TaskDefinition", Properties = properties }; + + public static Resource Service(string dependsOn, ServiceProperties properties) => + new() { Type = "AWS::ECS::Service", DependsOn = dependsOn, Properties = properties }; + + public static Resource LogGroup(LogGroupProperties properties) => + new() { Type = "AWS::Logs::LogGroup", Properties = properties }; + + public static Resource IamRole(IamRoleProperties properties) => + new() { Type = "AWS::IAM::Role", Properties = properties }; +} + +public sealed record Tag +{ + public string Key { get; init; } + public string Value { get; init; } +} diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs index 5913810ff..f88d40c64 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs @@ -9,7 +9,7 @@ namespace Calamari.Aws.Integration.Ecs.Deploy; public interface IEcsTemplateParameter { string Name { get; } - // Typed value handed to CDK's CfnParameter Default — preserves the underlying + // Typed value handed to the CFN template's parameter Default — preserves the underlying // type so a Number param emits a JSON number literal, not a quoted string. object Default { get; } // String form for the AWS SDK Parameter override list — required to be a string diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index 8ad552993..735232314 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -1,172 +1,208 @@ -using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; -using Amazon.CDK; -using Amazon.CDK.AWS.ECS; -using Amazon.CDK.AWS.Logs; using Calamari.Aws.Inputs.Ecs; +using Octopus.Calamari.Contracts.Aws.Ecs; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; namespace Calamari.Aws.Integration.Ecs.Deploy; -public sealed class EcsDeployTemplate : Stack +// Strongly-typed CloudFormation template builder for an ECS Fargate service deploy. +// Composes a `Cfn.Template` graph from `DeployEcsCommandInputs` + the parameter list, +// delegating per-shape mapping to the `Inputs.Ecs.*` extension methods. +sealed class EcsDeployTemplate { const string FargateLaunchType = "FARGATE"; const string AwsVpcNetworkMode = "awsvpc"; const string LinuxOperatingSystemFamily = "LINUX"; + const string DefaultTaskExecutionPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"; + const string TaskExecutionPolicyArnParameterName = "AmazonECSTaskExecutionRolePolicyArn"; - readonly Dictionary paramRefs; + readonly DeployEcsCommandInputs commandInputs; + readonly IReadOnlyList parameters; + readonly HashSet registeredParameterNames; + readonly bool createsInTemplateExecutionRole; - public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, - IReadOnlyList parameters, - App scope, - string id, - IStackProps props = null) : base(scope, id, props) + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, IReadOnlyList parameters) { - TemplateOptions.TemplateFormatVersion = "2010-09-09"; - - paramRefs = parameters.ToDictionary( - p => p.Name, - p => new CfnParameter(this, - p.Name, - new CfnParameterProps - { - Type = p.CfnType, - Default = p.Default - })); - - // ExecutionRoleArn: parameter when user-supplied (in `paramRefs`), in-template - // role otherwise. The role can't be known at request time because CFN creates - // it during the same deploy, so it can't sit behind a parameter override. - var executionRoleArnRef = paramRefs.TryGetValue(EcsTemplateParameterNames.TaskExecutionRole, out var execRoleParam) - ? execRoleParam.ValueAsString - : commandInputs.MapTaskExecutionRoleArn(this); - - // For Auto-logging containers we need to point awslogs at the LogGroupName parameter - // and the deploy region. LogGroupName is only registered when any container is Auto - // (RequiresLogGroup), so accessing it outside that branch would throw — null is fine - // because ParseLogConfiguration only consults it in the Auto path. - var logGroupNameRef = commandInputs.RequiresLogGroup - ? paramRefs[EcsTemplateParameterNames.LogGroupName].ValueAsString - : null; - - // Stack.Region is a CDK token that synthesises to { Ref: AWS::Region } — - // the CFN pseudo-parameter that resolves to the deploy region at runtime. - var awsRegionRef = Region; + this.commandInputs = commandInputs; + this.parameters = parameters; + registeredParameterNames = parameters.Select(p => p.Name).ToHashSet(); + // No user-supplied execution role → template creates one in-stack and adds an + // extra CFN parameter for the managed-policy ARN. + createsInTemplateExecutionRole = !registeredParameterNames.Contains(EcsTemplateParameterNames.TaskExecutionRole); + } + + public Cfn.Template Build() => new() + { + Parameters = BuildParametersSection(), + Resources = BuildResourcesSection() + }; - var containers = commandInputs.Containers.Select(c => new CfnTaskDefinition.ContainerDefinitionProperty + Dictionary BuildParametersSection() + { + var section = parameters.ToDictionary( + p => p.Name, + p => new Cfn.ParameterDef { Type = p.CfnType, Default = p.Default }); + + if (createsInTemplateExecutionRole) { - Name = c.ContainerName, - Image = c.ContainerImageReference.ImageName, - Essential = c.Essential.ConvertedOrDefault(bool.Parse), - DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(bool.Parse), - WorkingDirectory = c.WorkingDirectory, - Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s)), - MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s)), - Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s)), - User = c.User, - StartTimeout = c.StartTimeout.ConvertedOrDefault( s => double.Parse(s)), - StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s)), - DnsServers = c.NetworkSettings.DnsServers.ToArray(), - DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), - ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), - - Command = c.Command.ConvertedOrDefault(s => [s], () => null), - EntryPoint = c.EntryPoint.ConvertedOrDefault(input => input.Split(',').Select(s => s.Trim()).ToArray(), () => null), - - ResourceRequirements = c.ParseResourceRequirements(), - DockerLabels = c.ParseDockerLabels(), - PortMappings = c.ParsePortMappings(), - HealthCheck = c.ParseHealthCheck(), - ExtraHosts = c.ParseExtraHosts(), - RepositoryCredentials = c.ParseRepositoryCredentials(), - Ulimits = c.ParseULimits(), - MountPoints = c.ParseMountPoints(), - DependsOn = c.ParseDependencies(), - VolumesFrom = c.ParseVolumesFrom(), - LogConfiguration = c.ParseLogConfiguration(logGroupNameRef, awsRegionRef), - EnvironmentFiles = c.ParseEnvironmentFiles(), - FirelensConfiguration = c.ParseFireLensConfiguration(), - - Environment = c.ParseEnvironmentVariables(), - Secrets = c.ParseSecrets() - - // SPF referenced these properties but never set them. - // Due to TS vs. CS SDK differences, we don't even mention them, - // so they won't appear in the final template at all. - // They appear here for consistency - // Privileged = null, - // Links = null, - // DockerSecurityOptions = null - - }).ToArray(); + section[TaskExecutionPolicyArnParameterName] = new Cfn.ParameterDef + { + Type = "String", + Default = DefaultTaskExecutionPolicyArn + }; + } + + return section; + } + + Dictionary BuildResourcesSection() + { + var section = new Dictionary(); + + if (createsInTemplateExecutionRole) + { + section[commandInputs.FallbackTaskExecutionRoleName] = Cfn.Resource.IamRole(BuildExecutionRoleProperties()); + } if (commandInputs.RequiresLogGroup) { - _ = new CfnLogGroup(this, - commandInputs.LogGroupName, - new CfnLogGroupProps - { - LogGroupName = paramRefs[EcsTemplateParameterNames.LogGroupName].ValueAsString - }); + section[commandInputs.LogGroupName] = Cfn.Resource.LogGroup(new Cfn.LogGroupProperties + { + LogGroupName = new Cfn.Ref(EcsTemplateParameterNames.LogGroupName) + }); + } + + section[commandInputs.TaskName] = Cfn.Resource.TaskDefinition(BuildTaskDefinitionProperties()); + section[commandInputs.ServiceName] = Cfn.Resource.Service(commandInputs.TaskName, BuildServiceProperties()); + + return section; + } + + Cfn.IamRoleProperties BuildExecutionRoleProperties() => new() + { + Path = "/", + ManagedPolicyArns = [new Cfn.Ref(TaskExecutionPolicyArnParameterName)], + AssumeRolePolicyDocument = new Cfn.AssumeRolePolicyDocument + { + Version = "2012-10-17", + Statement = + [ + new Cfn.AssumeRoleStatement + { + Effect = "Allow", + Principal = new Cfn.AssumeRolePrincipal { Service = ["ecs-tasks.amazonaws.com"] }, + Action = ["sts:AssumeRole"] + } + ] } + }; + + Cfn.TaskDefinitionProperties BuildTaskDefinitionProperties() + { + // For Auto-logging containers we point awslogs at the LogGroupName parameter + // and the deploy region. LogGroupName is only registered when any container is Auto + // (RequiresLogGroup), so the Ref is only valid in that case — null is fine because + // ParseLogConfiguration only consults it in the Auto path. + Cfn.Value logGroupNameRef = commandInputs.RequiresLogGroup + ? new Cfn.Ref(EcsTemplateParameterNames.LogGroupName) + : null; + Cfn.Value awsRegionRef = new Cfn.Ref("AWS::Region"); - var taskDefinition = new CfnTaskDefinition(this, - commandInputs.TaskName, - new CfnTaskDefinitionProps - { - ContainerDefinitions = containers, - Family = paramRefs[EcsTemplateParameterNames.TaskDefinitionName].ValueAsString, - Cpu = paramRefs[EcsTemplateParameterNames.TaskDefinitionCpu].ValueAsString, - Memory = paramRefs[EcsTemplateParameterNames.TaskDefinitionMemory].ValueAsString, - ExecutionRoleArn = executionRoleArnRef, - TaskRoleArn = ParamOr(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), - RequiresCompatibilities = [FargateLaunchType], - NetworkMode = AwsVpcNetworkMode, - RuntimePlatform = new CfnTaskDefinition.RuntimePlatformProperty - { - OperatingSystemFamily = LinuxOperatingSystemFamily, - CpuArchitecture = commandInputs.CpuArchitecture - }, - Volumes = commandInputs.Volumes.ParseVolumes(), - Tags = commandInputs.Tags.ToCloudFormationTags() - }); - - var service = new CfnService(this, - commandInputs.ServiceName, - new CfnServiceProps - { - Cluster = paramRefs[EcsTemplateParameterNames.ClusterName].ValueAsString, - LaunchType = FargateLaunchType, - TaskDefinition = taskDefinition.Ref, - DesiredCount = ParamOr(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount), - EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, - DeploymentConfiguration = new CfnService.DeploymentConfigurationProperty - { - MinimumHealthyPercent = ParamOr(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage), - MaximumPercent = ParamOr(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage) - }, - NetworkConfiguration = new CfnService.NetworkConfigurationProperty - { - AwsvpcConfiguration = new CfnService.AwsVpcConfigurationProperty - { - AssignPublicIp = commandInputs.AutoAssignPublicIp, - Subnets = commandInputs.SubnetIDs, - SecurityGroups = commandInputs.NetworkSecurityGroupIds - } - }, - LoadBalancers = commandInputs.LoadBalancerMappings.ToLoadBalancerProperties(), - Tags = commandInputs.Tags.ToCloudFormationTags(), - }); - - service.AddDependency(taskDefinition); + Cfn.Value executionRoleArn = createsInTemplateExecutionRole + ? new Cfn.Ref(commandInputs.FallbackTaskExecutionRoleName) + : new Cfn.Ref(EcsTemplateParameterNames.TaskExecutionRole); + + return new Cfn.TaskDefinitionProperties + { + ContainerDefinitions = commandInputs.Containers.Select(c => BuildContainerDefinition(c, logGroupNameRef, awsRegionRef)).ToArray(), + Family = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionName), + Cpu = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionCpu), + Memory = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionMemory), + ExecutionRoleArn = executionRoleArn, + TaskRoleArn = StringRefOr(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), + RequiresCompatibilities = [FargateLaunchType], + NetworkMode = AwsVpcNetworkMode, + RuntimePlatform = new Cfn.RuntimePlatform + { + OperatingSystemFamily = LinuxOperatingSystemFamily, + CpuArchitecture = commandInputs.CpuArchitecture + }, + Volumes = commandInputs.Volumes.ParseVolumes(), + Tags = commandInputs.Tags.ToCloudFormationTags() + }; } - // Conditionally-registered parameters (only present when the input was customised - // away from the default): fall back to the literal commandInputs value when absent. - // Resources then render either { Ref: ... } or the inline value accordingly. - string ParamOr(string key, string literal) => - paramRefs.TryGetValue(key, out var p) ? p.ValueAsString : literal; + Cfn.ServiceProperties BuildServiceProperties() => new() + { + Cluster = new Cfn.Ref(EcsTemplateParameterNames.ClusterName), + LaunchType = FargateLaunchType, + TaskDefinition = new Cfn.Ref(commandInputs.TaskName), + DesiredCount = NumberRefOr(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount), + EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, + DeploymentConfiguration = new Cfn.DeploymentConfiguration + { + MinimumHealthyPercent = NumberRefOr(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage), + MaximumPercent = NumberRefOr(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage) + }, + NetworkConfiguration = new Cfn.NetworkConfiguration + { + AwsvpcConfiguration = new Cfn.AwsvpcConfiguration + { + AssignPublicIp = commandInputs.AutoAssignPublicIp, + Subnets = commandInputs.SubnetIDs, + SecurityGroups = commandInputs.NetworkSecurityGroupIds + } + }, + LoadBalancers = commandInputs.LoadBalancerMappings.ToLoadBalancerProperties(), + Tags = commandInputs.Tags.ToCloudFormationTags() + }; + + static Cfn.ContainerDefinition BuildContainerDefinition( + ContainerSpec c, + Cfn.Value logGroupNameRef, + Cfn.Value awsRegionRef) => new() + { + Name = c.ContainerName, + Image = c.ContainerImageReference.ImageName, + Essential = c.Essential.ConvertedOrDefault(s => bool.Parse(s)), + DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.Parse(s)), + WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Cpu = c.Cpus.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + User = string.IsNullOrEmpty(c.User) ? null : c.User, + StartTimeout = c.StartTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + // SPF always emits these arrays even when empty — preserve that shape. + DnsServers = c.NetworkSettings.DnsServers.ToArray(), + DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), + ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(s => bool.Parse(s)), + Command = c.Command.ConvertedOrDefault(s => [s], () => null), + EntryPoint = c.EntryPoint.ConvertedOrDefault(s => s.Split(',').Select(x => x.Trim()).ToArray(), () => null), + ResourceRequirements = c.ParseResourceRequirements(), + DockerLabels = c.ParseDockerLabels(), + PortMappings = c.ParsePortMappings(), + HealthCheck = c.ParseHealthCheck(), + ExtraHosts = c.ParseExtraHosts(), + RepositoryCredentials = c.ParseRepositoryCredentials(), + Ulimits = c.ParseULimits(), + MountPoints = c.ParseMountPoints(), + DependsOn = c.ParseDependencies(), + VolumesFrom = c.ParseVolumesFrom(), + LogConfiguration = c.ParseLogConfiguration(logGroupNameRef, awsRegionRef), + EnvironmentFiles = c.ParseEnvironmentFiles(), + FirelensConfiguration = c.ParseFireLensConfiguration(), + Environment = c.ParseEnvironmentVariables(), + Secrets = c.ParseSecrets() + }; + + // Conditionally-registered parameters: when the parameter exists, emit a Ref so CFN + // parameter overrides at deploy time take effect; otherwise inline the literal. + Cfn.Value StringRefOr(string parameterName, string literal) => + registeredParameterNames.Contains(parameterName) ? new Cfn.Ref(parameterName) : literal; - double ParamOr(string key, double literal) => - paramRefs.TryGetValue(key, out var p) ? p.ValueAsNumber : literal; + Cfn.Value NumberRefOr(string parameterName, double literal) => + registeredParameterNames.Contains(parameterName) ? new Cfn.Ref(parameterName) : literal; } diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs index ff91def5f..7dc5a9c5a 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Amazon.CDK; using Amazon.CloudFormation.Model; using Calamari.Aws.Inputs.Ecs; using Newtonsoft.Json; @@ -10,36 +9,18 @@ namespace Calamari.Aws.Integration.Ecs.Deploy; public record GeneratedTemplate(string Body, IReadOnlyList Parameters); - public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) { - readonly App app = new(); - readonly IStackProps stackProps = new StackProps - { - Synthesizer = new DefaultStackSynthesizer(new DefaultStackSynthesizerProps - { - // This flag kills the Rules assertion section and the bootstrap version parameter completely - GenerateBootstrapVersionRule = false - }) - }; - public GeneratedTemplate Generate() { var parameters = BuildParameters(); + var template = new EcsDeployTemplate(commandInputs, parameters).Build(); - _ = new EcsDeployTemplate(commandInputs, parameters, app, commandInputs.CfStackName, stackProps); - - var assembly = app.Synth(); - var stackArtifact = assembly.GetStackByName(commandInputs.CfStackName); - - var settings = new JsonSerializerSettings + var body = JsonConvert.SerializeObject(template, new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore - }; - settings.Converters.Add(new WholeDoubleConverter()); - - var body = JsonConvert.SerializeObject(stackArtifact.Template, settings); + }); return new GeneratedTemplate( body, @@ -65,8 +46,6 @@ List BuildParameters() list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole)); } - // Only declared when the user supplied a concrete ARN — otherwise the role - // is created in-template and referenced via Ref (no parameter needed). if (!string.IsNullOrEmpty(commandInputs.TaskExecutionRole)) { list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); @@ -95,30 +74,7 @@ List BuildParameters() return list; } - // Direct `!=` on doubles is unreliable across precision and NaN; compare via - // epsilon-based equality (matches the WholeDoubleConverter convention below). + // Epsilon-based double comparison — direct != is unreliable across precision and NaN. static bool DiffersFromDefault(double value, double @default) => Math.Abs(value - @default) > double.Epsilon; - - class WholeDoubleConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, double? value, JsonSerializer serializer) - { - if (value == null) - writer.WriteNull(); - else if (Math.Abs(value.Value - Math.Floor(value.Value)) < double.Epsilon) - writer.WriteValue((long)value.Value); - else - writer.WriteValue(value.Value); - } - - public override double? ReadJson(JsonReader reader, - Type objectType, - double? existingValue, - bool hasExistingValue, - JsonSerializer serializer) - { - return reader.Value == null ? null : Convert.ToDouble(reader.Value); - } - } } diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json index 2b9b03a18..8e408be4c 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -26,15 +26,15 @@ }, "DesiredCount": { "Type": "Number", - "Default": 2 + "Default": 2.0 }, "MinimumHealthPercent": { "Type": "Number", - "Default": 150 + "Default": 150.0 }, "MaximumHealthPercent": { "Type": "Number", - "Default": 300 + "Default": 300.0 }, "LogGroupName": { "Type": "String", @@ -55,52 +55,73 @@ "Properties": { "ContainerDefinitions": [ { + "Name": "web-server", + "Image": "#{Octopus.Action[Deploy Amazon ECS Service - clone (1)].Package[nginx].Image}", + "Essential": true, + "DisableNetworking": false, + "WorkingDirectory": "/tmp", + "Memory": 200.0, + "MemoryReservation": 47.0, + "Cpu": 2.0, + "User": "test-user", + "StartTimeout": 40.0, + "StopTimeout": 60.0, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": true, "Command": [ "echo 'Deployment successful" ], - "Cpu": 2, - "DisableNetworking": false, - "DnsSearchDomains": [], - "DnsServers": [], - "DockerLabels": { - "some-label": "label-value" - }, "EntryPoint": [ "sh", "-c" ], - "Environment": [ + "ResourceRequirements": [], + "DockerLabels": { + "some-label": "label-value" + }, + "PortMappings": [ { - "Name": "containerenv", - "Value": "some-otherovalue" - } - ], - "EnvironmentFiles": [ + "ContainerPort": 80.0, + "HostPort": 80.0, + "Protocol": "tcp" + }, { - "Type": "s3", - "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + "ContainerPort": 443.0, + "HostPort": 443.0, + "Protocol": "tcp" } ], - "Essential": true, - "ExtraHosts": [], - "FirelensConfiguration": { - "Options": { - "enable-ecs-log-metadata": "true", - "config-file-type": "file", - "config-file-value": "/home/config" - }, - "Type": "fluentd" - }, "HealthCheck": { "Command": [ "curl -f http://localhost/ || exit 1" ], - "Interval": 240, - "Retries": 7, - "StartPeriod": 179, - "Timeout": 54 + "Interval": 240.0, + "Retries": 7.0, + "StartPeriod": 179.0, + "Timeout": 54.0 }, - "Image": "#{Octopus.Action[Deploy Amazon ECS Service - clone (1)].Package[nginx].Image}", + "ExtraHosts": [], + "Ulimits": [ + { + "Name": "core", + "HardLimit": 12.0, + "SoftLimit": 10.0 + } + ], + "MountPoints": [ + { + "SourceVolume": "efs-volume", + "ContainerPath": "/etc", + "ReadOnly": false + } + ], + "VolumesFrom": [ + { + "SourceContainer": "efs-volume", + "ReadOnly": true + } + ], "LogConfiguration": { "LogDriver": "awslogs", "Options": { @@ -113,121 +134,104 @@ "awslogs-stream-prefix": "ecs" } }, - "Memory": 200, - "MemoryReservation": 47, - "MountPoints": [ - { - "ContainerPath": "/etc", - "ReadOnly": false, - "SourceVolume": "efs-volume" - } - ], - "Name": "web-server", - "PortMappings": [ - { - "ContainerPort": 80, - "HostPort": 80, - "Protocol": "tcp" - }, + "EnvironmentFiles": [ { - "ContainerPort": 443, - "HostPort": 443, - "Protocol": "tcp" + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" } ], - "ReadonlyRootFilesystem": true, - "ResourceRequirements": [], - "StartTimeout": 40, - "StopTimeout": 60, - "Ulimits": [ - { - "HardLimit": 12, - "Name": "core", - "SoftLimit": 10 + "FirelensConfiguration": { + "Type": "fluentd", + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" } - ], - "User": "test-user", - "VolumesFrom": [ + }, + "Environment": [ { - "ReadOnly": true, - "SourceContainer": "efs-volume" + "Name": "containerenv", + "Value": "some-otherovalue" } - ], - "WorkingDirectory": "/tmp" + ] } ], + "Family": { + "Ref": "TaskDefinitionName" + }, "Cpu": { "Ref": "TaskDefinitionCPU" }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, "ExecutionRoleArn": { "Ref": "TaskExecutionRole" }, - "Family": { - "Ref": "TaskDefinitionName" - }, - "Memory": { - "Ref": "TaskDefinitionMemory" + "TaskRoleArn": { + "Ref": "TaskRole" }, - "NetworkMode": "awsvpc", "RequiresCompatibilities": [ "FARGATE" ], + "NetworkMode": "awsvpc", "RuntimePlatform": { - "CpuArchitecture": "X86_64", - "OperatingSystemFamily": "LINUX" - }, - "Tags": [ - { - "Key": "my-tag", - "Value": "a great test value" - } - ], - "TaskRoleArn": { - "Ref": "TaskRole" + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" }, "Volumes": [ { + "Name": "efs-volume", "EFSVolumeConfiguration": { - "AuthorizationConfig": { - "AccessPointId": "/data", - "IAM": "ENABLED" - }, "FilesystemId": "efs-fs-id", "RootDirectory": "/root", - "TransitEncryption": "ENABLED" - }, - "Name": "efs-volume" + "TransitEncryption": "ENABLED", + "AuthorizationConfig": { + "IAM": "ENABLED", + "AccessPointId": "/data" + } + } + } + ], + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" } ] } }, "ServicetestBigCfTemplate": { "Type": "AWS::ECS::Service", + "DependsOn": "TaskDefinitiontestBigCfTemplate", "Properties": { "Cluster": { "Ref": "ClusterName" }, - "DeploymentConfiguration": { - "MaximumPercent": { - "Ref": "MaximumHealthPercent" - }, - "MinimumHealthyPercent": { - "Ref": "MinimumHealthPercent" - } + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestBigCfTemplate" }, "DesiredCount": { "Ref": "DesiredCount" }, "EnableECSManagedTags": true, - "LaunchType": "FARGATE", + "DeploymentConfiguration": { + "MinimumHealthyPercent": { + "Ref": "MinimumHealthPercent" + }, + "MaximumPercent": { + "Ref": "MaximumHealthPercent" + } + }, "NetworkConfiguration": { "AwsvpcConfiguration": { "AssignPublicIp": "ENABLED", - "SecurityGroups": [ - "sg-0d5e06a4bde84dxxx" - ], "Subnets": [ "subnet-0650cd8a2119e8xxx" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84dxxx" ] } }, @@ -236,13 +240,8 @@ "Key": "my-tag", "Value": "a great test value" } - ], - "TaskDefinition": { - "Ref": "TaskDefinitiontestBigCfTemplate" - } - }, - "DependsOn": ["TaskDefinitiontestBigCfTemplate"] - + ] + } } } } \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json index 45cdb4b59..a54367237 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json @@ -27,7 +27,7 @@ }, "DesiredCount": { "Type": "Number", - "Default": 2 + "Default": 2.0 }, "LogGroupName": { "Type": "String", @@ -48,52 +48,73 @@ "Properties": { "ContainerDefinitions": [ { + "Name": "web-server", + "Image": "docker.io/nginx:1.31.1", + "Essential": true, + "DisableNetworking": false, + "WorkingDirectory": "/tmp", + "Memory": 200.0, + "MemoryReservation": 47.0, + "Cpu": 2.0, + "User": "test-user", + "StartTimeout": 40.0, + "StopTimeout": 60.0, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": true, "Command": [ "echo 'Deployment successful" ], - "Cpu": 2, - "DisableNetworking": false, - "DnsSearchDomains": [], - "DnsServers": [], - "DockerLabels": { - "some-label": "label-value" - }, "EntryPoint": [ "sh", "-c" ], - "Environment": [ + "ResourceRequirements": [], + "DockerLabels": { + "some-label": "label-value" + }, + "PortMappings": [ { - "Name": "containerenv", - "Value": "some-otherovalue" - } - ], - "EnvironmentFiles": [ + "ContainerPort": 80.0, + "HostPort": 80.0, + "Protocol": "tcp" + }, { - "Type": "s3", - "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + "ContainerPort": 443.0, + "HostPort": 443.0, + "Protocol": "tcp" } ], - "Essential": true, - "ExtraHosts": [], - "FirelensConfiguration": { - "Options": { - "enable-ecs-log-metadata": "true", - "config-file-type": "file", - "config-file-value": "/home/config" - }, - "Type": "fluentd" - }, "HealthCheck": { "Command": [ "curl -f http://localhost/ || exit 1" ], - "Interval": 240, - "Retries": 7, - "StartPeriod": 179, - "Timeout": 54 + "Interval": 240.0, + "Retries": 7.0, + "StartPeriod": 179.0, + "Timeout": 54.0 }, - "Image": "docker.io/nginx:1.31.1", + "ExtraHosts": [], + "Ulimits": [ + { + "Name": "core", + "HardLimit": 12.0, + "SoftLimit": 10.0 + } + ], + "MountPoints": [ + { + "SourceVolume": "efs-volume", + "ContainerPath": "/etc", + "ReadOnly": false + } + ], + "VolumesFrom": [ + { + "SourceContainer": "efs-volume", + "ReadOnly": true + } + ], "LogConfiguration": { "LogDriver": "awslogs", "Options": { @@ -106,61 +127,43 @@ "awslogs-stream-prefix": "ecs" } }, - "Memory": 200, - "MemoryReservation": 47, - "MountPoints": [ - { - "ContainerPath": "/etc", - "ReadOnly": false, - "SourceVolume": "efs-volume" - } - ], - "Name": "web-server", - "PortMappings": [ - { - "ContainerPort": 80, - "HostPort": 80, - "Protocol": "tcp" - }, + "EnvironmentFiles": [ { - "ContainerPort": 443, - "HostPort": 443, - "Protocol": "tcp" + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" } ], - "ReadonlyRootFilesystem": true, - "ResourceRequirements": [], - "StartTimeout": 40, - "StopTimeout": 60, - "Ulimits": [ - { - "HardLimit": 12, - "Name": "core", - "SoftLimit": 10 + "FirelensConfiguration": { + "Type": "fluentd", + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" } - ], - "User": "test-user", - "VolumesFrom": [ + }, + "Environment": [ { - "ReadOnly": true, - "SourceContainer": "efs-volume" + "Name": "containerenv", + "Value": "some-otherovalue" } - ], - "WorkingDirectory": "/tmp" + ] }, { + "Name": "cache", + "Image": "docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig", + "Essential": true, "DisableNetworking": false, - "DnsSearchDomains": [], "DnsServers": [], - "EnvironmentFiles": [], - "Essential": true, - "ExtraHosts": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [], + "PortMappings": [], "HealthCheck": { "Command": [ " [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]." ] }, - "Image": "docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig", + "ExtraHosts": [], "LogConfiguration": { "LogDriver": "awslogs", "Options": { @@ -173,80 +176,81 @@ "awslogs-stream-prefix": "ecs" } }, - "Name": "cache", - "PortMappings": [], - "ReadonlyRootFilesystem": false, - "ResourceRequirements": [] + "EnvironmentFiles": [] } ], + "Family": { + "Ref": "TaskDefinitionName" + }, "Cpu": { "Ref": "TaskDefinitionCPU" }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, "ExecutionRoleArn": { "Ref": "TaskExecutionRole" }, - "Family": { - "Ref": "TaskDefinitionName" - }, - "Memory": { - "Ref": "TaskDefinitionMemory" + "TaskRoleArn": { + "Ref": "TaskRole" }, - "NetworkMode": "awsvpc", "RequiresCompatibilities": [ "FARGATE" ], + "NetworkMode": "awsvpc", "RuntimePlatform": { - "CpuArchitecture": "X86_64", - "OperatingSystemFamily": "LINUX" - }, - "Tags": [ - { - "Key": "my-tag", - "Value": "a great test value" - } - ], - "TaskRoleArn": { - "Ref": "TaskRole" + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" }, "Volumes": [ { + "Name": "efs-volume", "EFSVolumeConfiguration": { - "AuthorizationConfig": { - "AccessPointId": "/data", - "IAM": "ENABLED" - }, "FilesystemId": "efs-fs-id", "RootDirectory": "/root", - "TransitEncryption": "ENABLED" - }, - "Name": "efs-volume" + "TransitEncryption": "ENABLED", + "AuthorizationConfig": { + "IAM": "ENABLED", + "AccessPointId": "/data" + } + } + } + ], + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" } ] } }, "ServicetestMultiContainerTemplate": { "Type": "AWS::ECS::Service", + "DependsOn": "TaskDefinitiontestMultiContainerTemplate", "Properties": { "Cluster": { "Ref": "ClusterName" }, - "DeploymentConfiguration": { - "MaximumPercent": 200, - "MinimumHealthyPercent": 100 + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestMultiContainerTemplate" }, "DesiredCount": { "Ref": "DesiredCount" }, "EnableECSManagedTags": true, - "LaunchType": "FARGATE", + "DeploymentConfiguration": { + "MinimumHealthyPercent": 100.0, + "MaximumPercent": 200.0 + }, "NetworkConfiguration": { "AwsvpcConfiguration": { "AssignPublicIp": "ENABLED", - "SecurityGroups": [ - "sg-0d5e06a4bde84daaa" - ], "Subnets": [ "subnet-0650cd8a2119e8aaa" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84daaa" ] } }, @@ -255,14 +259,8 @@ "Key": "my-tag", "Value": "a great test value" } - ], - "TaskDefinition": { - "Ref": "TaskDefinitiontestMultiContainerTemplate" - } - }, - "DependsOn": [ - "TaskDefinitiontestMultiContainerTemplate" - ] + ] + } } } } \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json index bfb813ab3..75b3b9bf1 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json @@ -32,108 +32,107 @@ "Properties": { "ContainerDefinitions": [ { + "Name": "web-server-spf", + "Image": "docker.io/nginx:1.29", + "Essential": true, "DisableNetworking": false, - "DnsSearchDomains": [], "DnsServers": [], - "Environment": [ + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [], + "PortMappings": [ { - "Name": "env", - "Value": "TestEnvironment" + "ContainerPort": 80.0, + "HostPort": 80.0, + "Protocol": "tcp" } ], - "EnvironmentFiles": [], - "Essential": true, "ExtraHosts": [], - "Image": "docker.io/nginx:1.29", - "Name": "web-server-spf", - "PortMappings": [ + "EnvironmentFiles": [], + "Environment": [ { - "ContainerPort": 80, - "HostPort": 80, - "Protocol": "tcp" + "Name": "env", + "Value": "TestEnvironment" } - ], - "ReadonlyRootFilesystem": false, - "ResourceRequirements": [] + ] } ], + "Family": { + "Ref": "TaskDefinitionName" + }, "Cpu": { "Ref": "TaskDefinitionCPU" }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, "ExecutionRoleArn": { "Ref": "TaskExecutionRole" }, - "Family": { - "Ref": "TaskDefinitionName" - }, - "Memory": { - "Ref": "TaskDefinitionMemory" + "TaskRoleArn": { + "Ref": "TaskRole" }, - "NetworkMode": "awsvpc", "RequiresCompatibilities": [ "FARGATE" ], + "NetworkMode": "awsvpc", "RuntimePlatform": { - "CpuArchitecture": "X86_64", - "OperatingSystemFamily": "LINUX" + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" }, + "Volumes": [], "Tags": [ - { - "Key": "createdBy", - "Value": "test-project" - }, { "Key": "owner", "Value": "spfdeployment" + }, + { + "Key": "createdBy", + "Value": "test-project" } - ], - "TaskRoleArn": { - "Ref": "TaskRole" - } + ] } }, "ServicetestOctopusSpfdeployedTask": { "Type": "AWS::ECS::Service", + "DependsOn": "TaskDefinitiontestOctopusSpfdeployedTask", "Properties": { "Cluster": { "Ref": "ClusterName" }, - "DeploymentConfiguration": { - "MaximumPercent": 200, - "MinimumHealthyPercent": 100 + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestOctopusSpfdeployedTask" }, - "DesiredCount": 1, + "DesiredCount": 1.0, "EnableECSManagedTags": false, - "LaunchType": "FARGATE", + "DeploymentConfiguration": { + "MinimumHealthyPercent": 100.0, + "MaximumPercent": 200.0 + }, "NetworkConfiguration": { "AwsvpcConfiguration": { "AssignPublicIp": "ENABLED", - "SecurityGroups": [ - "sg-0d5e06a4bde84d1d" - ], "Subnets": [ "subnet-0650cd8a2119e829c", "subnet-0067a165dd462cb39" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84d1d" ] } }, "Tags": [ - { - "Key": "createdBy", - "Value": "test-project" - }, { "Key": "owner", "Value": "spfdeployment" + }, + { + "Key": "createdBy", + "Value": "test-project" } - ], - "TaskDefinition": { - "Ref": "TaskDefinitiontestOctopusSpfdeployedTask" - } - }, - "DependsOn": [ - "TaskDefinitiontestOctopusSpfdeployedTask" - ] + ] + } } } } \ No newline at end of file diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index 308efabee..bd07a3bed 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using Amazon.CDK.AWS.ECS; using Calamari.Aws.Inputs.Ecs; using FluentAssertions; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; using ContainerDependency = Octopus.Calamari.Contracts.Aws.Ecs.ContainerDependency; using ContainerDependencyCondition = Octopus.Calamari.Contracts.Aws.Ecs.ContainerDependencyCondition; using ContainerMountPoint = Octopus.Calamari.Contracts.Aws.Ecs.ContainerMountPoint; @@ -738,13 +738,12 @@ public void ParseLogConfiguration_WhenAuto_EmitsAwsLogsWithStandardOptions() result.Should().NotBeNull(); result!.LogDriver.Should().Be("awslogs"); - result.Options.Should().BeOfType>() - .Which.Should().BeEquivalentTo(new Dictionary - { - { "awslogs-group", TestLogGroupRef }, - { "awslogs-region", TestRegionRef }, - { "awslogs-stream-prefix", "ecs" } - }); + result.Options.Should().BeEquivalentTo(new Dictionary> + { + { "awslogs-group", TestLogGroupRef }, + { "awslogs-region", TestRegionRef }, + { "awslogs-stream-prefix", "ecs" } + }); } [Test] @@ -804,17 +803,15 @@ public void ParseLogConfiguration_WhenManual_SplitsPlainAndSecretOptions() var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); - result!.Options.Should().BeOfType>() - .Which.Should().BeEquivalentTo(new Dictionary - { - { "awslogs-region", "us-east-1" }, - { "awslogs-group", "my-group" } - }); - result.SecretOptions.Should().BeOfType>() - .Which.Should().BeEquivalentTo(new Dictionary - { - { "secret-token", "arn:secret" } - }); + result!.Options.Should().BeEquivalentTo(new Dictionary> + { + { "awslogs-region", "us-east-1" }, + { "awslogs-group", "my-group" } + }); + result.SecretOptions.Should().BeEquivalentTo(new[] + { + new Cfn.Secret { Name = "secret-token", ValueFrom = "arn:secret" } + }); } [Test] @@ -877,9 +874,8 @@ public void ParseFireLensConfiguration_WhenEnabled_AlwaysIncludesEnableEcsLogMet var result = spec.ParseFireLensConfiguration(); - result!.Options.Should().BeOfType>() - .Which.Should().ContainKey("enable-ecs-log-metadata") - .WhoseValue.Should().Be("false"); + result!.Options.Should().ContainKey("enable-ecs-log-metadata") + .WhoseValue.Should().Be("false"); } [Test] @@ -898,9 +894,8 @@ public void ParseFireLensConfiguration_WithCustomConfigSourceNone_OmitsConfigFil var result = spec.ParseFireLensConfiguration(); - var options = (Dictionary)result!.Options; - options.Should().NotContainKey("config-file-type"); - options.Should().NotContainKey("config-file-value"); + result!.Options.Should().NotContainKey("config-file-type"); + result.Options.Should().NotContainKey("config-file-value"); } [Test] @@ -925,8 +920,7 @@ public void ParseFireLensConfiguration_WithCustomConfigSource_AddsConfigFileOpti var result = spec.ParseFireLensConfiguration(); - var options = (Dictionary)result!.Options; - options.Should().Contain("config-file-type", expected); - options.Should().Contain("config-file-value", "/etc/fluent.conf"); + result!.Options.Should().Contain("config-file-type", expected); + result.Options.Should().Contain("config-file-value", "/etc/fluent.conf"); } } diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs deleted file mode 100644 index 1c9a0b274..000000000 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/TaskExecutionRoleMappingExtensionsTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Amazon.CDK; -using Calamari.Aws.Deployment; -using Calamari.Aws.Inputs.Ecs; -using Calamari.Aws.Integration.Ecs; -using Calamari.Common.Plumbing.Logging; -using Calamari.Common.Plumbing.Variables; -using FluentAssertions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using NSubstitute; -using NUnit.Framework; - -namespace Calamari.Tests.AWS.Inputs.Ecs; - -[TestFixture] -public class TaskExecutionRoleMappingExtensionsTests -{ - readonly ILog fakeLog = Substitute.For(); - readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); - - [Test] - public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleSupplied_ReturnsSuppliedArnVerbatim() - { - const string suppliedArn = "arn:aws:iam::123456789012:role/MyCustomExecutionRole"; - var variables = MinimumRequiredVariableSet(); - variables[AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole] = suppliedArn; - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - - var app = new App(); - var stack = new Stack(app, "TestStack"); - - var result = inputs.MapTaskExecutionRoleArn(stack); - - result.Should().Be(suppliedArn); - } - - [Test] - public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleSupplied_DoesNotCreateRoleOrPolicyArnParameter() - { - var variables = MinimumRequiredVariableSet(); - variables[AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole] = "arn:aws:iam::123:role/foo"; - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); - - var app = new App(); - var stack = new Stack(app, "TestStack"); - - inputs.MapTaskExecutionRoleArn(stack); - - // CDK always injects a BootstrapVersion parameter, so we can't assert Parameters is empty. - // Instead, assert our specific parameter and resource are absent. - var template = SynthTemplate(app, "TestStack"); - template["Parameters"]?["AmazonECSTaskExecutionRolePolicyArn"].Should().BeNull(); - template["Resources"]?[inputs.FallbackTaskExecutionRoleName].Should().BeNull(); - } - - [Test] - public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleEmpty_ReturnsCfnReferenceToken() - { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); - - var app = new App(); - var stack = new Stack(app, "TestStack"); - - var result = inputs.MapTaskExecutionRoleArn(stack); - - result.Should().NotBeNullOrEmpty(); - // CDK Ref tokens are unresolved at this point — they start with "${Token[" until synthesis. - result.Should().StartWith("${Token["); - } - - [Test] - public void MapTaskExecutionRoleArn_WhenTaskExecutionRoleEmpty_AddsRoleAndPolicyArnParameterToScope() - { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); - - var app = new App(); - var stack = new Stack(app, "TestStack"); - - inputs.MapTaskExecutionRoleArn(stack); - - var template = SynthTemplate(app, "TestStack"); - template["Parameters"]?["AmazonECSTaskExecutionRolePolicyArn"].Should().NotBeNull(); - template["Resources"]?[inputs.FallbackTaskExecutionRoleName].Should().NotBeNull(); - template["Resources"]?[inputs.FallbackTaskExecutionRoleName]?["Type"]?.Value() - .Should().Be("AWS::IAM::Role"); - } - - static JObject SynthTemplate(App app, string stackName) - { - var template = app.Synth().GetStackByName(stackName).Template; - return JObject.Parse(JsonConvert.SerializeObject(template)); - } - - static CalamariVariables MinimumRequiredVariableSet() - { - return new CalamariVariables - { - { AwsSpecialVariables.Ecs.ClusterName, "MyCluster" }, - { DeploymentEnvironment.Id, "Environment-1" }, - { AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, "TestEcsTask" }, - { AwsSpecialVariables.Ecs.Deploy.Cpu, "2" }, - { AwsSpecialVariables.Ecs.Deploy.Memory, "1" }, - { AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, "X86_64" }, - { AwsSpecialVariables.Ecs.Deploy.DesiredCount, "1" }, - { AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, "100" }, - { AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, "200" }, - { AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, "False" }, - { AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, "False" }, - { AwsSpecialVariables.Ecs.WaitOption, """{ "type": "waitWithTimeout", "timeout": 30 }""" }, - { AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, """["sg-0d5e06a4bde84dabc"]""" }, - { AwsSpecialVariables.Ecs.Deploy.SubnetIds, """["subnet-0650cd8a2119e8abc"]""" } - }; - } -} diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs index 2f8b964b7..2ab7c99cd 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs @@ -1,10 +1,9 @@ using System; -using Amazon.CDK.AWS.ECS; using Calamari.Aws.Inputs.Ecs; using FluentAssertions; using NUnit.Framework; using Octopus.Calamari.Contracts.Aws.Ecs; -using Volume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; +using InputVolume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; namespace Calamari.Tests.AWS.Inputs.Ecs; @@ -14,7 +13,7 @@ public class VolumeMappingExtensionsTests [Test] public void ParseVolumes_WhenEmpty_ReturnsNull() { - var result = Array.Empty().ParseVolumes(); + var result = Array.Empty().ParseVolumes(); result.Should().BeNull(); } @@ -24,14 +23,14 @@ public void ParseVolumes_WithBindVolume_MapsNameOnly() { var volumes = new[] { - new Volume { Type = VolumeType.Bind, Name = "scratch" } + new InputVolume { Type = VolumeType.Bind, Name = "scratch" } }; var result = volumes.ParseVolumes(); result.Should().HaveCount(1); - result[0].Name.Should().Be("scratch"); - result[0].EfsVolumeConfiguration.Should().BeNull(); + result![0].Name.Should().Be("scratch"); + result[0].EFSVolumeConfiguration.Should().BeNull(); } [Test] @@ -39,7 +38,7 @@ public void ParseVolumes_WithEfsVolume_MapsFullEfsConfiguration() { var volumes = new[] { - new Volume + new InputVolume { Type = VolumeType.Efs, Name = "shared-data", @@ -56,16 +55,15 @@ public void ParseVolumes_WithEfsVolume_MapsFullEfsConfiguration() result.Should().HaveCount(1); result![0].Name.Should().Be("shared-data"); - var efs = result[0].EfsVolumeConfiguration.Should() - .BeOfType().Subject; - efs.FilesystemId.Should().Be("fs-0123abcd"); + var efs = result[0].EFSVolumeConfiguration; + efs.Should().NotBeNull(); + efs!.FilesystemId.Should().Be("fs-0123abcd"); efs.RootDirectory.Should().Be("/data"); efs.TransitEncryption.Should().Be("ENABLED"); - var auth = efs.AuthorizationConfig.Should() - .BeOfType().Subject; - auth.Iam.Should().Be("ENABLED"); - auth.AccessPointId.Should().Be("fsap-0123abcd"); + efs.AuthorizationConfig.Should().NotBeNull(); + efs.AuthorizationConfig!.Iam.Should().Be("ENABLED"); + efs.AuthorizationConfig.AccessPointId.Should().Be("fsap-0123abcd"); } [Test] @@ -73,7 +71,7 @@ public void ParseVolumes_EfsVolume_DefaultsTransitEncryptionAndIamToDisabled() { var volumes = new[] { - new Volume + new InputVolume { Type = VolumeType.Efs, Name = "shared-data", @@ -85,12 +83,9 @@ public void ParseVolumes_EfsVolume_DefaultsTransitEncryptionAndIamToDisabled() var result = volumes.ParseVolumes(); - var efs = result![0].EfsVolumeConfiguration.Should() - .BeOfType().Subject; - efs.TransitEncryption.Should().Be("DISABLED"); - efs.AuthorizationConfig.Should() - .BeOfType() - .Which.Iam.Should().Be("DISABLED"); + var efs = result![0].EFSVolumeConfiguration; + efs!.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig!.Iam.Should().Be("DISABLED"); } [Test] @@ -99,7 +94,7 @@ public void ParseVolumes_EfsVolume_TransitEncryptionAndIamAreCaseSensitive() // The implementation compares to true.ToString() == "True" — lowercase "true" should not enable. var volumes = new[] { - new Volume + new InputVolume { Type = VolumeType.Efs, Name = "shared-data", @@ -111,12 +106,9 @@ public void ParseVolumes_EfsVolume_TransitEncryptionAndIamAreCaseSensitive() var result = volumes.ParseVolumes(); - var efs = result![0].EfsVolumeConfiguration.Should() - .BeOfType().Subject; - efs.TransitEncryption.Should().Be("DISABLED"); - efs.AuthorizationConfig.Should() - .BeOfType() - .Which.Iam.Should().Be("DISABLED"); + var efs = result![0].EFSVolumeConfiguration; + efs!.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig!.Iam.Should().Be("DISABLED"); } [Test] @@ -124,16 +116,16 @@ public void ParseVolumes_OrdersBindVolumesBeforeEfsVolumes() { var volumes = new[] { - new Volume { Type = VolumeType.Efs, Name = "efs-1", FileSystemId = "fs-1", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, - new Volume { Type = VolumeType.Bind, Name = "bind-1" }, - new Volume { Type = VolumeType.Efs, Name = "efs-2", FileSystemId = "fs-2", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, - new Volume { Type = VolumeType.Bind, Name = "bind-2" } + new InputVolume { Type = VolumeType.Efs, Name = "efs-1", FileSystemId = "fs-1", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, + new InputVolume { Type = VolumeType.Bind, Name = "bind-1" }, + new InputVolume { Type = VolumeType.Efs, Name = "efs-2", FileSystemId = "fs-2", EncryptionInTransit = "True", EfsIamAuthorization = "True" }, + new InputVolume { Type = VolumeType.Bind, Name = "bind-2" } }; var result = volumes.ParseVolumes(); result.Should().HaveCount(4); - result[0].Name.Should().Be("bind-1"); + result![0].Name.Should().Be("bind-1"); result[1].Name.Should().Be("bind-2"); result[2].Name.Should().Be("efs-1"); result[3].Name.Should().Be("efs-2"); From fdc23cde75ab173cc28e3837581042bb7933d501 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 17:33:39 +1000 Subject: [PATCH 61/80] Fix up tests --- .../ContainerSpecMappingExtensionsTests.cs | 26 ++++++++++++++++--- .../Ecs/VolumeMappingExtensionsTests.cs | 4 +-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index bd07a3bed..a09607b8e 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -75,7 +75,7 @@ public void ParseMountPoints_WithEmptyReadonly_DefaultsToFalse() var result = spec.ParseMountPoints(); - result[0].ReadOnly.Should().Be(false); + result[0].ReadOnly.Should().BeNull(); } [Test] @@ -257,7 +257,7 @@ public void ParseVolumesFrom_WithEmptyReadonly_DefaultsToFalse() var result = spec.ParseVolumesFrom(); - result[0].ReadOnly.Should().Be(false); + result[0].ReadOnly.Should().BeNull(); } [Test] @@ -498,6 +498,16 @@ public void ParseHealthCheck_WhenCommandPresent_MapsAllProperties() result.Timeout.Should().Be(5); } + [Test] + public void ParsePortMappings_WhenEmpty_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParsePortMappings(); + + result.Should().BeEmpty(); + } + [Test] public void ParsePortMappings_MapsContainerPortAndProtocol() { @@ -514,7 +524,7 @@ public void ParsePortMappings_MapsContainerPortAndProtocol() result.Should().HaveCount(1); result[0].ContainerPort.Should().Be(8080); result[0].HostPort.Should().Be(8080); - result[0].Protocol.Should().Be("Tcp"); + result[0].Protocol.Should().Be("tcp"); } [Test] @@ -595,6 +605,16 @@ public void ParseULimits_MapsLimitNameAndValues() result[0].SoftLimit.Should().Be(1024); } + [Test] + public void ParseExtraHosts_WhenEmpty_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseExtraHosts(); + + result.Should().BeEmpty(); + } + [Test] public void ParseExtraHosts_MapsHostnameAndIp() { diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs index 2ab7c99cd..ba8dfd2e1 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs @@ -11,11 +11,11 @@ namespace Calamari.Tests.AWS.Inputs.Ecs; public class VolumeMappingExtensionsTests { [Test] - public void ParseVolumes_WhenEmpty_ReturnsNull() + public void ParseVolumes_WhenEmpty_ReturnsEmptyArray() { var result = Array.Empty().ParseVolumes(); - result.Should().BeNull(); + result.Should().BeEmpty(); } [Test] From 31f24c5168c5f426afd388bfe5db3c31b4e0f116 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 1 Jun 2026 17:36:00 +1000 Subject: [PATCH 62/80] fix up more tests --- .../AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index a09607b8e..214d5d6fa 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -55,7 +55,7 @@ public void ParseMountPoints_WithMountPoints_MapsAllProperties() } [Test] - public void ParseMountPoints_WithEmptyReadonly_DefaultsToFalse() + public void ParseMountPoints_WithEmptyReadonly_IsNull() { var spec = new ContainerSpec { @@ -238,7 +238,7 @@ public void ParseVolumesFrom_WithEmptySourceContainer_ReturnsNull() } [Test] - public void ParseVolumesFrom_WithEmptyReadonly_DefaultsToFalse() + public void ParseVolumesFrom_WithEmptyReadonly_IsNull() { var spec = new ContainerSpec { From 37be4c2f1f0a345cbadc7528955da92b29e53a0d Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 11:08:21 +1000 Subject: [PATCH 63/80] Remove Test Fixture New implementation consumes they existing Deploy CF Template command and we test all the other pieces around that. This test provides very little additional value --- .../AWS/Ecs/DeployEcsServiceFixture.cs | 259 ------------------ 1 file changed, 259 deletions(-) delete mode 100644 source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs diff --git a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs deleted file mode 100644 index 1c15a12c6..000000000 --- a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs +++ /dev/null @@ -1,259 +0,0 @@ -// using System; -// using System.IO; -// using System.Linq; -// using System.Threading; -// using System.Threading.Tasks; -// using Amazon; -// using Amazon.CloudFormation; -// using Amazon.CloudFormation.Model; -// using Amazon.Runtime; -// using Calamari.Aws.Commands; -// using Calamari.Aws.Deployment; -// using Calamari.Aws.Integration.Ecs; -// using Calamari.Common.Plumbing.Variables; -// using Calamari.Testing; -// using Calamari.Testing.Helpers; -// using Calamari.Tests.Fixtures.Integration.FileSystem; -// using FluentAssertions; -// using Newtonsoft.Json; -// using NSubstitute; -// using NUnit.Framework; -// -// namespace Calamari.Tests.AWS.Ecs; -// -// [TestFixture] -// [Category(TestCategory.RunOnceOnWindowsAndLinux)] -// public class DeployEcsServiceFixture -// { -// // Fixed infrastructure in account 017645897735 (us-east-1) -// const string Region = "us-east-1"; -// const string ClusterName = "calamari-ecs-integration-tests"; -// const string SubnetId = "subnet-0d3da9354f8253081"; -// const string SecurityGroupId = "sg-053ae28309775ea7b"; -// -// readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); -// -// string stackName; -// -// [TearDown] -// public async Task TearDown() -// { -// if (!string.IsNullOrEmpty(stackName)) -// { -// try -// { -// await DeleteStack(stackName); -// } -// catch (Exception e) -// { -// TestContext.WriteLine($"Failed to clean up stack {stackName}: {e.Message}"); -// } -// } -// } -// -// [Test] -// public async Task DeployEcsService_CreatesCloudFormationStack() -// { -// stackName = GenerateStackName(); -// const string serviceName = "test-svc"; -// -// var variables = await CreateVariables(serviceName, stackName); -// var log = new InMemoryLog(); -// var fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); -// -// var tempDir = Path.Combine(Path.GetTempPath(), $"calamari-ecs-{Guid.NewGuid():N}"); -// Directory.CreateDirectory(tempDir); -// try -// { -// var templatePath = Path.Combine(tempDir, "template.json"); -// var parametersPath = Path.Combine(tempDir, "parameters.json"); -// await File.WriteAllTextAsync(templatePath, BuildTemplate(serviceName)); -// await File.WriteAllTextAsync(parametersPath, "[]"); -// -// var command = new DeployEcsServiceCommand(log, variables, fakeStackNameGenerator); -// -// var result = command.Execute(["--template", templatePath, "--templateParameters", parametersPath]); -// -// result.Should().Be(0); -// await ValidateStackExists(stackName, true); -// } -// finally -// { -// try { Directory.Delete(tempDir, recursive: true); } -// catch -// { -// // ignored -// } -// } -// } -// -// static string GenerateStackName() => -// $"calamari-ecs-{Guid.NewGuid():N}".Substring(0, 40); -// -// -// static string BuildTemplate(string serviceName) => $$""" -// { -// "AWSTemplateFormatVersion": "2010-09-09", -// "Resources": { -// "ExecutionRole": { -// "Type": "AWS::IAM::Role", -// "Properties": { -// "AssumeRolePolicyDocument": { -// "Version": "2012-10-17", -// "Statement": [{ -// "Effect": "Allow", -// "Principal": { "Service": "ecs-tasks.amazonaws.com" }, -// "Action": "sts:AssumeRole" -// }] -// }, -// "ManagedPolicyArns": [ -// "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -// ] -// } -// }, -// "TaskDefinition": { -// "Type": "AWS::ECS::TaskDefinition", -// "Properties": { -// "Family": "{{serviceName}}", -// "RequiresCompatibilities": ["FARGATE"], -// "NetworkMode": "awsvpc", -// "Cpu": "256", -// "Memory": "512", -// "ExecutionRoleArn": { "Fn::GetAtt": ["ExecutionRole", "Arn"] }, -// "ContainerDefinitions": [ -// { -// "Name": "web", -// "Image": "public.ecr.aws/docker/library/nginx:alpine", -// "Essential": true, -// "PortMappings": [{ "ContainerPort": 80, "Protocol": "tcp" }] -// } -// ] -// } -// }, -// "Service": { -// "Type": "AWS::ECS::Service", -// "Properties": { -// "Cluster": "{{ClusterName}}", -// "ServiceName": "{{serviceName}}", -// "TaskDefinition": { "Ref": "TaskDefinition" }, -// "LaunchType": "FARGATE", -// "DesiredCount": 0, -// "NetworkConfiguration": { -// "AwsvpcConfiguration": { -// "Subnets": ["{{SubnetId}}"], -// "SecurityGroups": ["{{SecurityGroupId}}"], -// "AssignPublicIp": "ENABLED" -// } -// } -// } -// } -// } -// } -// """; -// -// static async Task CreateVariables(string serviceName, string cfStackName) -// { -// var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); -// var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); -// -// var variables = new CalamariVariables(); -// -// variables.Set("Octopus.Account.AccountType", "AmazonWebServicesAccount"); -// variables.Set("Octopus.Action.AwsAccount.Variable", "AWSAccount"); -// variables.Set("AWSAccount.AccessKey", accessKey); -// variables.Set("AWSAccount.SecretKey", secretKey); -// variables.Set("Octopus.Action.Aws.Region", Region); -// variables.Set("Octopus.Action.Aws.AssumeRole", "False"); -// variables.Set("Octopus.Action.AwsAccount.UseInstanceRole", "False"); -// -// variables.Set("Octopus.Environment.Id", "Environments-1"); -// variables.Set("Octopus.Environment.Name", "Test"); -// variables.Set("Octopus.Project.Name", "ECS Integration Test"); -// variables.Set("Octopus.Action.Name", "Deploy ECS"); -// -// variables.Set(AwsSpecialVariables.CloudFormation.StackName, cfStackName); -// -// // Stack-level tags (Vanta compliance tags that integration infra requires) -// variables.Set(AwsSpecialVariables.CloudFormation.Tags, JsonConvert.SerializeObject(new[] -// { -// new { Key = "VantaOwner", Value = "modern-deployments-team@octopus.com" }, -// new { Key = "VantaNonProd", Value = "true" }, -// new { Key = "VantaNoAlert", Value = "Ephemeral ECS service created during integration tests" }, -// new { Key = "VantaContainsUserData", Value = "false" }, -// new { Key = "VantaUserDataStored", Value = "N/A" }, -// new { Key = "VantaDescription", Value = "Ephemeral ECS service created during integration tests" } -// })); -// -// -// 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"); -// -// return variables; -// } -// -// static async Task ValidateStackExists(string stackName, bool shouldExist) -// { -// var credentials = await GetCredentials(); -// var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; -// -// using var client = new AmazonCloudFormationClient(credentials, config); -// try -// { -// var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); -// var stack = response.Stacks.FirstOrDefault(); -// -// if (shouldExist) -// { -// stack.Should().NotBeNull($"stack {stackName} should exist"); -// stack!.StackStatus.Value.Should().NotContain("FAILED"); -// } -// else -// { -// stack?.StackStatus.Value.Should().Be("DELETE_COMPLETE"); -// } -// } -// catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") -// { -// if (shouldExist) -// { -// Assert.Fail($"Stack {stackName} does not exist but was expected to."); -// } -// } -// } -// -// static async Task DeleteStack(string stackName) -// { -// var credentials = await GetCredentials(); -// var config = new AmazonCloudFormationConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(Region) }; -// -// using var client = new AmazonCloudFormationClient(credentials, config); -// await client.DeleteStackAsync(new DeleteStackRequest { StackName = stackName }); -// -// for (var i = 0; i < 30; i++) -// { -// await Task.Delay(TimeSpan.FromSeconds(10)); -// try -// { -// var response = await client.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); -// var stack = response.Stacks.FirstOrDefault(); -// if (stack == null || stack.StackStatus.Value == "DELETE_COMPLETE") -// { -// return; -// } -// } -// catch (AmazonCloudFormationException ex) when (ex.ErrorCode == "ValidationError") -// { -// return; -// } -// } -// } -// -// static async Task GetCredentials() -// { -// var accessKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3AccessKey, CancellationToken.None); -// var secretKey = await ExternalVariables.Get(ExternalVariable.AwsCloudFormationAndS3SecretKey, CancellationToken.None); -// return new BasicAWSCredentials(accessKey, secretKey); -// } -// } From 9941821d39ea26bab9ad865639bc8b34f9167d7d Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 12:20:03 +1000 Subject: [PATCH 64/80] Remove unnecessary cast --- source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 6dce6d166..d197a9567 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -13,14 +13,14 @@ namespace Calamari.Aws.Inputs.Ecs; public class DeployEcsCommandInputs { - readonly CalamariVariables variables; + readonly IVariables variables; readonly IEcsStackNameGenerator stackNameGenerator; readonly ILog log; readonly HashSet requiredVariableKeys = []; public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, ILog log) { - this.variables = variables as CalamariVariables; + this.variables = variables; this.stackNameGenerator = stackNameGenerator; this.log = log; From eb208152a8146be24e1a49359e9f8872c9d1e4a0 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 13:58:50 +1000 Subject: [PATCH 65/80] Switch desired count to int --- source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 4 ++-- .../Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index d197a9567..bbb1eb65c 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -79,7 +79,7 @@ public string CfStackName public string Memory => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory); - public double DesiredCount => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); + public int DesiredCount => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); @@ -159,7 +159,7 @@ public string MissingKeyList { public static class EcsInputDefaults { - public const double DesiredCount = 1; + public const int DesiredCount = 1; public const double MinimumHealthPercent = 100; public const double MaximumHealthPercent = 200; } \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs index 7dc5a9c5a..fbdc3b782 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -51,7 +51,7 @@ List BuildParameters() list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); } - if (DiffersFromDefault(commandInputs.DesiredCount, EcsInputDefaults.DesiredCount)) + if (commandInputs.DesiredCount != EcsInputDefaults.DesiredCount) { list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount)); } From 00ee5149ffce941c4572c7cb134771d19cebd4c3 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 14:30:01 +1000 Subject: [PATCH 66/80] style: formatting --- .../Ecs/ContainerSpecMappingExtensions.cs | 92 ++++++++++--------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 9f594bff5..1f272a9bf 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -14,7 +14,9 @@ public static T ConvertedOrDefault(this string value, Func convert { return defaultOverride != null ? string.IsNullOrEmpty(value) ? defaultOverride() : converter(value) - : string.IsNullOrEmpty(value) ? default : converter(value); + : string.IsNullOrEmpty(value) + ? default + : converter(value); } public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) @@ -23,11 +25,11 @@ public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) return new Cfn.HealthCheck { - Command = containerSpec.HealthCheck.Command.ToArray(), - Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Command = containerSpec.HealthCheck.Command.ToArray(), + Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), }; } @@ -52,19 +54,21 @@ public static Cfn.EnvironmentEntry[] ParseEnvironmentVariables(this ContainerSpe // SPF always emits PortMappings as an array — empty becomes [] not omitted. public static Cfn.PortMapping[] ParsePortMappings(this ContainerSpec containerSpec) => containerSpec.ContainerPortMappings.Select(pm => new Cfn.PortMapping - { - ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Protocol = pm.Protocol.ToString().ToLowerInvariant() - }).ToArray(); + { + ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Protocol = pm.Protocol.ToString().ToLowerInvariant() + }) + .ToArray(); // SPF always emits ExtraHosts as an array — empty becomes [] not omitted. public static Cfn.ExtraHost[] ParseExtraHosts(this ContainerSpec containerSpec) => containerSpec.NetworkSettings.ExtraHosts.Select(eh => new Cfn.ExtraHost - { - Hostname = string.IsNullOrEmpty(eh.Hostname) ? null : eh.Hostname, - IpAddress = string.IsNullOrEmpty(eh.IpAddress) ? null : eh.IpAddress, - }).ToArray(); + { + Hostname = string.IsNullOrEmpty(eh.Hostname) ? null : eh.Hostname, + IpAddress = string.IsNullOrEmpty(eh.IpAddress) ? null : eh.IpAddress, + }) + .ToArray(); public static Cfn.RepositoryCredentials ParseRepositoryCredentials(this ContainerSpec containerSpec) => containerSpec.RepositoryAuthentication.Type switch @@ -87,11 +91,12 @@ public static Cfn.Ulimit[] ParseULimits(this ContainerSpec containerSpec) if (containerSpec.Ulimits.Count == 0) return null; return containerSpec.Ulimits.Select(ul => new Cfn.Ulimit - { - Name = ul.LimitName, - HardLimit = double.Parse(ul.HardLimit, CultureInfo.InvariantCulture), - SoftLimit = double.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) - }).ToArray(); + { + Name = ul.LimitName, + HardLimit = double.Parse(ul.HardLimit, CultureInfo.InvariantCulture), + SoftLimit = double.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) + }) + .ToArray(); } public static Cfn.MountPoint[] ParseMountPoints(this ContainerSpec containerSpec) @@ -99,11 +104,12 @@ public static Cfn.MountPoint[] ParseMountPoints(this ContainerSpec containerSpec if (containerSpec.ContainerStorage.MountPoints.Count == 0) return null; return containerSpec.ContainerStorage.MountPoints.Select(mp => new Cfn.MountPoint - { - SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, - ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, - ReadOnly = mp.Readonly.ConvertedOrDefault(s => bool.Parse(s)) - }).ToArray(); + { + SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, + ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, + ReadOnly = mp.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + }) + .ToArray(); } public static Cfn.ContainerDependency[] ParseDependencies(this ContainerSpec containerSpec) @@ -111,10 +117,11 @@ public static Cfn.ContainerDependency[] ParseDependencies(this ContainerSpec con if (containerSpec.Dependencies.Count == 0) return null; return containerSpec.Dependencies.Select(d => new Cfn.ContainerDependency - { - ContainerName = string.IsNullOrEmpty(d.ContainerName) ? null : d.ContainerName, - Condition = d.Condition.ToString().ToUpperInvariant(), - }).ToArray(); + { + ContainerName = string.IsNullOrEmpty(d.ContainerName) ? null : d.ContainerName, + Condition = d.Condition.ToString().ToUpperInvariant(), + }) + .ToArray(); } public static Cfn.VolumeFrom[] ParseVolumesFrom(this ContainerSpec containerSpec) @@ -122,19 +129,21 @@ public static Cfn.VolumeFrom[] ParseVolumesFrom(this ContainerSpec containerSpec if (containerSpec.ContainerStorage.VolumeFrom.Count == 0) return null; return containerSpec.ContainerStorage.VolumeFrom.Select(vf => new Cfn.VolumeFrom - { - SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, - ReadOnly = vf.Readonly.ConvertedOrDefault(s => bool.Parse(s)) - }).ToArray(); + { + SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, + ReadOnly = vf.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + }) + .ToArray(); } // SPF always emits EnvironmentFiles as an array — empty becomes [] not omitted. public static Cfn.EnvironmentFile[] ParseEnvironmentFiles(this ContainerSpec containerSpec) => containerSpec.EnvironmentFiles.Select(ef => new Cfn.EnvironmentFile - { - Type = "s3", // Hardcoded until we support other options - Value = ef - }).ToArray(); + { + Type = "s3", // Hardcoded until we support other options + Value = ef + }) + .ToArray(); // logGroupNameRef and awsRegionRef are passed as Cfn.Value so callers can hand // in either a literal or a Ref intrinsic. The Auto path consumes them; Manual ignores. @@ -153,8 +162,8 @@ public static Cfn.LogConfiguration ParseLogConfiguration( LogDriver = LogDriver.AwsLogs.ToString().ToLowerInvariant(), Options = new Dictionary> { - { "awslogs-group", logGroupNameRef }, - { "awslogs-region", awsRegionRef }, + { "awslogs-group", logGroupNameRef }, + { "awslogs-region", awsRegionRef }, { "awslogs-stream-prefix", "ecs" } } }; @@ -195,13 +204,13 @@ public static Cfn.FirelensConfiguration ParseFireLensConfiguration(this Containe if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None } src) { - options.Add("config-file-type", src.Type.ToString().ToLowerInvariant()); + options.Add("config-file-type", src.Type.ToString().ToLowerInvariant()); options.Add("config-file-value", src.FilePath); } return new Cfn.FirelensConfiguration { - Type = containerSpec.FirelensConfiguration.FirelensType?.ToString().ToLowerInvariant(), + Type = containerSpec.FirelensConfiguration.FirelensType?.ToString().ToLowerInvariant(), Options = options }; } @@ -215,5 +224,4 @@ public static Cfn.Secret[] ParseSecrets(this ContainerSpec containerSpec) .ToArray(); return secrets.Length == 0 ? null : secrets; } - -} +} \ No newline at end of file From 0b3ce0b962d0f1f610c883035021c36f535c6e23 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 14:30:33 +1000 Subject: [PATCH 67/80] Update variable deserialization to evaluate variable first --- .../VariablesDeserialisationExtensions.cs | 10 ++++++++-- .../VariablesDeserialisationExtensionsTests.cs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index d9de1f3f0..f83d1b192 100644 --- a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs +++ b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs @@ -9,7 +9,6 @@ public static class VariablesDeserialisationExtensions public static T GetValueDeserialisedAs(this IVariables variables, string name) { - var variableJson = variables.Get(name); if (string.IsNullOrEmpty(variableJson)) @@ -17,9 +16,16 @@ public static T GetValueDeserialisedAs(this IVariables variables, string name throw new CommandException($"Variable {name} was not supplied"); } + // Expand any `#{...}` references in the raw JSON before deserialising. Without + // this, variables nested inside the JSON (e.g. a containerImage referencing a + // package variable) stay as literal `#{...}` text in the typed objects. + // Variables that contain JSON-significant characters must use `| JsonEscape` + // when referenced so the substituted text stays parseable. + var evaluatedJson = variables.Evaluate(variableJson); + try { - var output = JsonConvert.DeserializeObject(variableJson, CalamariContractSerializationSettings.Default); + var output = JsonConvert.DeserializeObject(evaluatedJson, CalamariContractSerializationSettings.Default); return output ?? throw new CommandException($"Variable {name} was deserialized as null "); } catch (JsonSerializationException) diff --git a/source/Calamari.Tests/Common/Plumbing/Variables/VariablesDeserialisationExtensionsTests.cs b/source/Calamari.Tests/Common/Plumbing/Variables/VariablesDeserialisationExtensionsTests.cs index 73f8cd131..ab5602d41 100644 --- a/source/Calamari.Tests/Common/Plumbing/Variables/VariablesDeserialisationExtensionsTests.cs +++ b/source/Calamari.Tests/Common/Plumbing/Variables/VariablesDeserialisationExtensionsTests.cs @@ -93,6 +93,23 @@ public void VariableSerialisedAndDeserialised_AppearsTheSame() result.Should().BeEquivalentTo(inputObject); } + [Test] + public void VariableWithSubstitution_ExpandsBeforeDeserialisation() + { + // #{...} references inside the JSON must be expanded before the type binder runs; + // otherwise nested variables stay as literal "#{...}" text in the typed result. + var variables = new CalamariVariables + { + { "Octopus.Action.Package[nginx].Image", "docker.io/nginx:1.29.1" }, + { TestKey, """{ "name": "Image is #{Octopus.Action.Package[nginx].Image}", "numericValue": 7 }""" } + }; + + var result = variables.GetValueDeserialisedAs(TestKey); + + result.Name.Should().Be("Image is docker.io/nginx:1.29.1"); + result.NumericValue.Should().Be(7); + } + [Test] public void VariableSerialisedAndDeserialisedWithNullableProperty_AppearsTheSame() { From 9135d3acf8d72efa7b93dd48ed2bac9b2ae2f9ca Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 16:11:52 +1000 Subject: [PATCH 68/80] Fix number types --- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 15 +-- .../Integration/Ecs/Deploy/Cfn/Service.cs | 6 +- .../Ecs/Deploy/EcsDeployTemplate.cs | 99 ++++++++++--------- .../Ecs/Deploy/EcsDeployTemplateGenerator.cs | 8 +- 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index bbb1eb65c..e5ce0f5cc 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -80,8 +80,8 @@ public string CfStackName public string Memory => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory); public int DesiredCount => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); - public double MinimumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); - public double MaximumHealthyPercentage => double.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); + public int MinimumHealthyPercentage => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); + public int MaximumHealthyPercentage => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); public string AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp) ? "ENABLED" : "DISABLED"; @@ -108,8 +108,6 @@ public string CpuArchitecture public WaitOption WaitOption => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.WaitOption); - public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); - public KeyValuePair[] Tags => variables.GetValueDeserialisedAs[]>(AwsSpecialVariables.Ecs.Tags); public LoadBalancerMapping[] LoadBalancerMappings => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings); @@ -120,6 +118,11 @@ public string CpuArchitecture public bool ShouldWaitForDeploymentCompletion => WaitOption.Type is WaitType.WaitUntilCompleted or WaitType.WaitWithTimeout; + public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); + + public string ResolveImageName(ContainerImageReference imageReference) => variables.Get(PackageVariables.IndexedImage(imageReference.ReferenceId)); + + public InputsValidityResult Validate() { var variableNames = variables.GetNames(); @@ -160,6 +163,6 @@ public string MissingKeyList { public static class EcsInputDefaults { public const int DesiredCount = 1; - public const double MinimumHealthPercent = 100; - public const double MaximumHealthPercent = 200; + public const int MinimumHealthPercent = 100; + public const int MaximumHealthPercent = 200; } \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs index b2841b159..a7b53f87c 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs @@ -7,7 +7,7 @@ public sealed record ServiceProperties public Value Cluster { get; init; } public string LaunchType { get; init; } public Value TaskDefinition { get; init; } - public Value DesiredCount { get; init; } + public Value DesiredCount { get; init; } // CFN's actual property name is EnableECSManagedTags (all-caps "ECS"); keep // the C# property in conventional PascalCase and override the JSON name here. @@ -22,8 +22,8 @@ public sealed record ServiceProperties public sealed record DeploymentConfiguration { - public Value MinimumHealthyPercent { get; init; } - public Value MaximumPercent { get; init; } + public Value MinimumHealthyPercent { get; init; } + public Value MaximumPercent { get; init; } } public sealed record NetworkConfiguration diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index 735232314..ab5f3e6a5 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -116,86 +116,87 @@ Cfn.TaskDefinitionProperties BuildTaskDefinitionProperties() return new Cfn.TaskDefinitionProperties { - ContainerDefinitions = commandInputs.Containers.Select(c => BuildContainerDefinition(c, logGroupNameRef, awsRegionRef)).ToArray(), - Family = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionName), - Cpu = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionCpu), - Memory = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionMemory), - ExecutionRoleArn = executionRoleArn, - TaskRoleArn = StringRefOr(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), + ContainerDefinitions = commandInputs.Containers.Select(c => BuildContainerDefinition(commandInputs, c, logGroupNameRef, awsRegionRef)).ToArray(), + Family = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionName), + Cpu = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionCpu), + Memory = new Cfn.Ref(EcsTemplateParameterNames.TaskDefinitionMemory), + ExecutionRoleArn = executionRoleArn, + TaskRoleArn = StringRefOr(EcsTemplateParameterNames.TaskRole, commandInputs.TaskRole), RequiresCompatibilities = [FargateLaunchType], - NetworkMode = AwsVpcNetworkMode, - RuntimePlatform = new Cfn.RuntimePlatform + NetworkMode = AwsVpcNetworkMode, + RuntimePlatform = new Cfn.RuntimePlatform { OperatingSystemFamily = LinuxOperatingSystemFamily, - CpuArchitecture = commandInputs.CpuArchitecture + CpuArchitecture = commandInputs.CpuArchitecture }, Volumes = commandInputs.Volumes.ParseVolumes(), - Tags = commandInputs.Tags.ToCloudFormationTags() + Tags = commandInputs.Tags.ToCloudFormationTags() }; } Cfn.ServiceProperties BuildServiceProperties() => new() { - Cluster = new Cfn.Ref(EcsTemplateParameterNames.ClusterName), - LaunchType = FargateLaunchType, - TaskDefinition = new Cfn.Ref(commandInputs.TaskName), - DesiredCount = NumberRefOr(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount), + Cluster = new Cfn.Ref(EcsTemplateParameterNames.ClusterName), + LaunchType = FargateLaunchType, + TaskDefinition = new Cfn.Ref(commandInputs.TaskName), + DesiredCount = NumberRefOr(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount), EnableEcsManagedTags = commandInputs.EnableEcsManagedTags, DeploymentConfiguration = new Cfn.DeploymentConfiguration { MinimumHealthyPercent = NumberRefOr(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage), - MaximumPercent = NumberRefOr(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage) + MaximumPercent = NumberRefOr(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage) }, NetworkConfiguration = new Cfn.NetworkConfiguration { AwsvpcConfiguration = new Cfn.AwsvpcConfiguration { AssignPublicIp = commandInputs.AutoAssignPublicIp, - Subnets = commandInputs.SubnetIDs, + Subnets = commandInputs.SubnetIDs, SecurityGroups = commandInputs.NetworkSecurityGroupIds } }, LoadBalancers = commandInputs.LoadBalancerMappings.ToLoadBalancerProperties(), - Tags = commandInputs.Tags.ToCloudFormationTags() + Tags = commandInputs.Tags.ToCloudFormationTags() }; static Cfn.ContainerDefinition BuildContainerDefinition( + DeployEcsCommandInputs commandInputs, ContainerSpec c, Cfn.Value logGroupNameRef, Cfn.Value awsRegionRef) => new() { - Name = c.ContainerName, - Image = c.ContainerImageReference.ImageName, - Essential = c.Essential.ConvertedOrDefault(s => bool.Parse(s)), - DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.Parse(s)), - WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, - Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Cpu = c.Cpus.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - User = string.IsNullOrEmpty(c.User) ? null : c.User, - StartTimeout = c.StartTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Name = c.ContainerName, + Image = commandInputs.ResolveImageName(c.ContainerImageReference), + Essential = c.Essential.ConvertedOrDefault(s => bool.Parse(s)), + DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.Parse(s)), + WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Cpu = c.Cpus.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + User = string.IsNullOrEmpty(c.User) ? null : c.User, + StartTimeout = c.StartTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), // SPF always emits these arrays even when empty — preserve that shape. - DnsServers = c.NetworkSettings.DnsServers.ToArray(), - DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), + DnsServers = c.NetworkSettings.DnsServers.ToArray(), + DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(s => bool.Parse(s)), - Command = c.Command.ConvertedOrDefault(s => [s], () => null), - EntryPoint = c.EntryPoint.ConvertedOrDefault(s => s.Split(',').Select(x => x.Trim()).ToArray(), () => null), - ResourceRequirements = c.ParseResourceRequirements(), - DockerLabels = c.ParseDockerLabels(), - PortMappings = c.ParsePortMappings(), - HealthCheck = c.ParseHealthCheck(), - ExtraHosts = c.ParseExtraHosts(), - RepositoryCredentials = c.ParseRepositoryCredentials(), - Ulimits = c.ParseULimits(), - MountPoints = c.ParseMountPoints(), - DependsOn = c.ParseDependencies(), - VolumesFrom = c.ParseVolumesFrom(), - LogConfiguration = c.ParseLogConfiguration(logGroupNameRef, awsRegionRef), - EnvironmentFiles = c.ParseEnvironmentFiles(), - FirelensConfiguration = c.ParseFireLensConfiguration(), - Environment = c.ParseEnvironmentVariables(), - Secrets = c.ParseSecrets() + Command = c.Command.ConvertedOrDefault(s => [s], () => null), + EntryPoint = c.EntryPoint.ConvertedOrDefault(s => s.Split(',').Select(x => x.Trim()).ToArray(), () => null), + ResourceRequirements = c.ParseResourceRequirements(), + DockerLabels = c.ParseDockerLabels(), + PortMappings = c.ParsePortMappings(), + HealthCheck = c.ParseHealthCheck(), + ExtraHosts = c.ParseExtraHosts(), + RepositoryCredentials = c.ParseRepositoryCredentials(), + Ulimits = c.ParseULimits(), + MountPoints = c.ParseMountPoints(), + DependsOn = c.ParseDependencies(), + VolumesFrom = c.ParseVolumesFrom(), + LogConfiguration = c.ParseLogConfiguration(logGroupNameRef, awsRegionRef), + EnvironmentFiles = c.ParseEnvironmentFiles(), + FirelensConfiguration = c.ParseFireLensConfiguration(), + Environment = c.ParseEnvironmentVariables(), + Secrets = c.ParseSecrets() }; // Conditionally-registered parameters: when the parameter exists, emit a Ref so CFN @@ -203,6 +204,6 @@ static Cfn.ContainerDefinition BuildContainerDefinition( Cfn.Value StringRefOr(string parameterName, string literal) => registeredParameterNames.Contains(parameterName) ? new Cfn.Ref(parameterName) : literal; - Cfn.Value NumberRefOr(string parameterName, double literal) => + Cfn.Value NumberRefOr(string parameterName, int literal) => registeredParameterNames.Contains(parameterName) ? new Cfn.Ref(parameterName) : literal; -} +} \ No newline at end of file diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs index fbdc3b782..af51b50e9 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -56,12 +56,12 @@ List BuildParameters() list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount)); } - if (DiffersFromDefault(commandInputs.MinimumHealthyPercentage, EcsInputDefaults.MinimumHealthPercent)) + if (commandInputs.MinimumHealthyPercentage != EcsInputDefaults.MinimumHealthPercent) { list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage)); } - if (DiffersFromDefault(commandInputs.MaximumHealthyPercentage, EcsInputDefaults.MaximumHealthPercent)) + if (commandInputs.MaximumHealthyPercentage != EcsInputDefaults.MaximumHealthPercent) { list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage)); } @@ -73,8 +73,4 @@ List BuildParameters() return list; } - - // Epsilon-based double comparison — direct != is unreliable across precision and NaN. - static bool DiffersFromDefault(double value, double @default) => - Math.Abs(value - @default) > double.Epsilon; } From 1490ec04fb422c306e9246eb608014c01ce30ec9 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 16:47:14 +1000 Subject: [PATCH 69/80] More type fixes --- .../Inputs/Ecs/ContainerSpecMappingExtensions.cs | 8 ++++---- .../Ecs/Deploy/Cfn/ContainerDefinition.cs | 8 ++++---- .../Ecs/Deploy/EcsDeployParameterGeneration.cs | 4 +++- .../Integration/Ecs/Deploy/EcsDeployTemplate.cs | 13 ++++++------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 1f272a9bf..39a510b42 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -26,10 +26,10 @@ public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) return new Cfn.HealthCheck { Command = containerSpec.HealthCheck.Command.ToArray(), - Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), }; } diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs index 522f833ad..4cdf7e071 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs @@ -53,10 +53,10 @@ public sealed record PortMapping public sealed record HealthCheck { public string[] Command { get; init; } - public double? Interval { get; init; } - public double? Retries { get; init; } - public double? StartPeriod { get; init; } - public double? Timeout { get; init; } + public int? Interval { get; init; } + public int? Retries { get; init; } + public int? StartPeriod { get; init; } + public int? Timeout { get; init; } } public sealed record ExtraHost diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs index f88d40c64..741b40d94 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs @@ -31,7 +31,9 @@ public record EcsTemplateParameter(string Name, T TypedValue) : IEcsTemplateP _ => TypedValue.ToString() ?? string.Empty }; - public string CfnType => typeof(T) == typeof(double) ? "Number" : "String"; + // CFN parameter types: "Number" covers ints + floats; everything else maps to "String". + // (CFN has no Boolean parameter type — booleans are emitted as literals in resources.) + public string CfnType => typeof(T) == typeof(int) ? "Number" : "String"; } // Static factory enables generic type inference at the call site: diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index ab5f3e6a5..c0d47167a 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -3,7 +3,6 @@ using System.Linq; using Calamari.Aws.Inputs.Ecs; using Octopus.Calamari.Contracts.Aws.Ecs; -using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; namespace Calamari.Aws.Integration.Ecs.Deploy; @@ -167,19 +166,19 @@ static Cfn.ContainerDefinition BuildContainerDefinition( { Name = c.ContainerName, Image = commandInputs.ResolveImageName(c.ContainerImageReference), - Essential = c.Essential.ConvertedOrDefault(s => bool.Parse(s)), - DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.Parse(s)), + Essential = c.Essential.ConvertedOrDefault(bool.Parse), + DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(bool.Parse), WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, - Memory = c.MemoryLimitHard.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - Cpu = c.Cpus.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), User = string.IsNullOrEmpty(c.User) ? null : c.User, StartTimeout = c.StartTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), // SPF always emits these arrays even when empty — preserve that shape. DnsServers = c.NetworkSettings.DnsServers.ToArray(), DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), - ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(s => bool.Parse(s)), + ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), Command = c.Command.ConvertedOrDefault(s => [s], () => null), EntryPoint = c.EntryPoint.ConvertedOrDefault(s => s.Split(',').Select(x => x.Trim()).ToArray(), () => null), ResourceRequirements = c.ParseResourceRequirements(), From 5d10057ddb091ef7621ecdee088b146353c795ef Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 17:18:19 +1000 Subject: [PATCH 70/80] Add abstraction for resolving image names --- source/Calamari.Aws/AwsModule.cs | 2 + .../Commands/DeployEcsServiceCommand.cs | 4 +- .../Inputs/Ecs/DeployEcsCommandInputs.cs | 6 +- .../Inputs/Ecs/EcsImageNameResolver.cs | 14 +++ .../Ecs/EcsDeployTemplateGeneratorTests.cs | 14 +-- .../SpfOutputs/complexSpfOutputTemplate.json | 2 +- .../Ecs/DeployEcsCommandInputsFixture.cs | 87 ++++++++++--------- 7 files changed, 75 insertions(+), 54 deletions(-) create mode 100644 source/Calamari.Aws/Inputs/Ecs/EcsImageNameResolver.cs diff --git a/source/Calamari.Aws/AwsModule.cs b/source/Calamari.Aws/AwsModule.cs index 92066e79d..f3d069026 100644 --- a/source/Calamari.Aws/AwsModule.cs +++ b/source/Calamari.Aws/AwsModule.cs @@ -1,4 +1,5 @@ using Autofac; +using Calamari.Aws.Inputs.Ecs; using Calamari.Aws.Integration.Ecs; namespace Calamari.Aws; @@ -8,5 +9,6 @@ public class AwsModule: Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } \ No newline at end of file diff --git a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs index c9e084b5a..2bda1b7d0 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -12,14 +12,14 @@ namespace Calamari.Aws.Commands; [Command(CommandName, Description = "Deploys a service to an Amazon ECS cluster")] -public class DeployEcsServiceCommand(ILog log, IVariables variables, IEcsStackNameGenerator stackNameGenerator) : Command +public class DeployEcsServiceCommand(ILog log, IVariables variables, IEcsStackNameGenerator stackNameGenerator, IEcsImageNameResolver ecsImageNameResolver) : Command { const string CommandName = "deploy-aws-ecs-service"; public override int Execute(string[] commandLineArguments) { var environment = AwsEnvironmentGeneration.Create(log, variables).GetAwaiter().GetResult(); - var inputs = new DeployEcsCommandInputs(variables, stackNameGenerator, log); + var inputs = new DeployEcsCommandInputs(variables, stackNameGenerator, ecsImageNameResolver, log); var inputValidity = inputs.Validate(); if (!inputValidity.IsValid) { diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index e5ce0f5cc..58afe3bc9 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -15,13 +15,15 @@ public class DeployEcsCommandInputs { readonly IVariables variables; readonly IEcsStackNameGenerator stackNameGenerator; + readonly IEcsImageNameResolver ecsImageResolver; readonly ILog log; readonly HashSet requiredVariableKeys = []; - public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, ILog log) + public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, IEcsImageNameResolver ecsImageResolver, ILog log) { this.variables = variables; this.stackNameGenerator = stackNameGenerator; + this.ecsImageResolver = ecsImageResolver; this.log = log; // strings @@ -120,7 +122,7 @@ public string CpuArchitecture public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); - public string ResolveImageName(ContainerImageReference imageReference) => variables.Get(PackageVariables.IndexedImage(imageReference.ReferenceId)); + public string ResolveImageName(ContainerImageReference imageReference) => ecsImageResolver.ResolveImageName(imageReference, variables); public InputsValidityResult Validate() diff --git a/source/Calamari.Aws/Inputs/Ecs/EcsImageNameResolver.cs b/source/Calamari.Aws/Inputs/Ecs/EcsImageNameResolver.cs new file mode 100644 index 000000000..529f1cfa3 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/EcsImageNameResolver.cs @@ -0,0 +1,14 @@ +using Calamari.Common.Plumbing.Variables; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Aws.Inputs.Ecs; + +public interface IEcsImageNameResolver +{ + string ResolveImageName(ContainerImageReference imageReference, IVariables variables); +} + +public class EcsImageNameResolver : IEcsImageNameResolver +{ + public string ResolveImageName(ContainerImageReference imageReference, IVariables variables) => variables.Get(PackageVariables.IndexedImage(imageReference.ReferenceId)); +} diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs index 7e40f660f..fe81f8b48 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -10,6 +10,7 @@ using Newtonsoft.Json.Linq; using NSubstitute; using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; namespace Calamari.Tests.AWS.Ecs; @@ -18,13 +19,14 @@ public class EcsDeployTemplateGeneratorTests { readonly ILog fakeLog = Substitute.For(); readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); + readonly IEcsImageNameResolver fakeEcsImageResolver = Substitute.For(); [Test] public void WithSimpleVariableSetup_MatchesExpectedSpfOutput() { - var expectedJson = ReadFromFile("simpleSpfOutputTemplate.json"); - + var expectedJson = ReadFromFile("simpleSpfOutputTemplate.json"); + fakeEcsImageResolver.ResolveImageName(Arg.Is(v => v.ReferenceId == "732002f0-4555-4dbf-8dc3-64255eee5f26"), Arg.Any()).Returns("docker.io/nginx:1.29"); var variables = new CalamariVariables { { AwsSpecialVariables.Ecs.Deploy.StackName, "ecs-spf-#{Octopus.Deployment.Id}" }, @@ -52,10 +54,10 @@ public void WithSimpleVariableSetup_MatchesExpectedSpfOutput() }, { AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" }, { AwsSpecialVariables.Ecs.Deploy.TaskRole, "arn:aws:iam::720766170633:role/ecsTaskExecutionRole" }, - // TODO: Update containers to use variable + {AwsSpecialVariables.Ecs.Tags, """[{"key":"owner","value":"spfdeployment"},{"key":"createdBy", "value":"#{Octopus.Project.Slug}"}]"""}, {AwsSpecialVariables.Ecs.Deploy.Containers, """ - [{"containerName":"web-server-spf","containerImageReference":{"referenceId":"732002f0-4555-4dbf-8dc3-64255eee5f26","imageName":"docker.io/nginx:1.29","feedId":"Feeds-1061"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[{"containerPort":"80","protocol":"Tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[{"type":"Plain","key":"env","value":"#{Octopus.Environment.Name}"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Manual","logDriver":"None","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] + [{"containerName":"web-server-spf","containerImageReference":{"referenceId":"732002f0-4555-4dbf-8dc3-64255eee5f26","imageName":"nginx","feedId":"Feeds-1061"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[{"containerPort":"80","protocol":"Tcp"}],"essential":"True","environmentFiles":[],"environmentVariables":[{"type":"Plain","key":"env","value":"#{Octopus.Environment.Name}"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Manual","logDriver":"None","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[]},"dependencies":[],"ulimits":[]}] """}, @@ -134,7 +136,7 @@ public void WithMultipleContainers_MatchesExpectedSpfOutput() public void WithComplexStep_MatchesExpectedSpfOutput() { var expectedJson = ReadFromFile("complexSpfOutputTemplate.json"); - + fakeEcsImageResolver.ResolveImageName(Arg.Any(), Arg.Any()).Returns("index.docker.io/nginx:latest"); var variables = new CalamariVariables { {"Octopus.Action.Aws.Ecs.Deploy.CFStackName", "test-stack"}, @@ -163,7 +165,7 @@ public void WithComplexStep_MatchesExpectedSpfOutput() } JObject GenerateTemplateFromVariables(CalamariVariables variables) { - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeEcsImageResolver, fakeLog); var template = new EcsDeployTemplateGenerator(inputs).Generate(); var resultJson = JObject.Parse(template.Body); diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json index 8e408be4c..a73506d85 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -56,7 +56,7 @@ "ContainerDefinitions": [ { "Name": "web-server", - "Image": "#{Octopus.Action[Deploy Amazon ECS Service - clone (1)].Package[nginx].Image}", + "Image": "index.docker.io/nginx:latest", "Essential": true, "DisableNetworking": false, "WorkingDirectory": "/tmp", diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs index ebaa55fcc..077101b55 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -19,12 +19,13 @@ public class DeployEcsCommandInputsFixture readonly ILog fakeLog = Substitute.For(); readonly IEcsStackNameGenerator fakeStackNameGenerator = Substitute.For(); + readonly IEcsImageNameResolver fakeImageNameResolver = Substitute.For(); [Test] public void Validate_WithEmptyVariableList_ReturnsFalseWithAllRequiredVariables() { var variables = new CalamariVariables(); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.Validate().IsValid; @@ -35,7 +36,7 @@ public void Validate_WithEmptyVariableList_ReturnsFalseWithAllRequiredVariables( public void Validate_WithMissingRequiredVariables_ReturnsFalse() { var variables = new CalamariVariables(); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.Validate().IsValid; @@ -45,7 +46,7 @@ public void Validate_WithMissingRequiredVariables_ReturnsFalse() [Test] public void Validate_WithAllExpectedVariables_ReturnsTrue() { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.Validate().IsValid; @@ -59,7 +60,7 @@ public void ClusterName_ReturnsEvaluatedClusterName(bool useExpression) { const string expectedClusterName = "MyTestCluster"; var variables = SetupVariable(AwsSpecialVariables.Ecs.ClusterName, expectedClusterName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var clusterName = inputs.ClusterName; @@ -69,7 +70,7 @@ public void ClusterName_ReturnsEvaluatedClusterName(bool useExpression) [Test] public void CfStackName_WhenNotInVariables_ReturnsValue() { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.CfStackName; @@ -83,7 +84,7 @@ public void CfStackName_WhenInVariables_ReturnsValue(bool useExpression) { const string expectedStackName = "MyTestStack"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.StackName, expectedStackName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.CfStackName; @@ -96,7 +97,7 @@ public void CfStackName_WhenEmptyString_ReturnGeneratedValue() const string expectedStackName = "MyGeneratedStack"; fakeStackNameGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(expectedStackName); var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.StackName, "", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.CfStackName; @@ -108,7 +109,7 @@ public void Environment_ReturnsDeploymentEnvironmentId() { const string expectedEnvironmentId = "TestEnvironment-1"; var variables = SetupVariable(DeploymentEnvironment.Id, expectedEnvironmentId, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.Environment; @@ -119,7 +120,7 @@ public void Environment_ReturnsDeploymentEnvironmentId() public void Tenant_WithNoTenantVariable_ReturnsEmptyString() { var variables = MinimumRequiredVariableSet(); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.Tenant; @@ -131,7 +132,7 @@ public void Tenant_WithTenantVariable_ReturnsTenantId() { const string expectedTenantId = "TestTenant-1"; var variables = SetupVariable(DeploymentVariables.Tenant.Id, expectedTenantId, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var stackName = inputs.Tenant; @@ -145,7 +146,7 @@ public void ServiceName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression { const string expectedServiceTaskName = "MyNewEcsService"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var serviceName = inputs.ServiceName; @@ -160,7 +161,7 @@ public void TaskName_ReturnsServiceTaskNameValueWithPrefix(bool useExpression) { const string expectedServiceTaskName = "MyNewEcsServiceTask"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var taskName = inputs.TaskName; @@ -174,7 +175,7 @@ public void LogGroupName_ReturnsServiceTaskNameValueWithPrefix(bool useExpressio { const string expectedServiceTaskName = "MyNewEcsServiceTask"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var logGroupName = inputs.LogGroupName; @@ -188,7 +189,7 @@ public void Cpu_IsReturnedAsAString(bool useExpression) { const string cpuInput = "0.5"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Cpu, cpuInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var cpu = inputs.Cpu; @@ -202,7 +203,7 @@ public void Memory_IsReturnedAsAString(bool useExpression) { const string memoryInput = "0.5"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Memory, memoryInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var memory = inputs.Memory; @@ -216,7 +217,7 @@ public void CpuArchitecture_IsReturnedAsString(bool useExpression) { const string cpuArchitecture = "ARM64"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.RuntimeArchitecturePlatform, cpuArchitecture, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var architecture = inputs.CpuArchitecture; @@ -230,7 +231,7 @@ public void DesiredCount_IsReturnedAsADouble(bool useExpression) { const string desiredCountInput = "7"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount, desiredCountInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var desiredCount = inputs.DesiredCount; @@ -244,7 +245,7 @@ public void MinimumHealthyPercentage_IsReturnedAsADouble(bool useExpression) { const string minHealthInput = "50"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent, minHealthInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.MinimumHealthyPercentage; @@ -258,7 +259,7 @@ public void MaximumHealthyPercentage_IsReturnedAsADouble(bool useExpression) { const string maxHealthInput = "150"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent, maxHealthInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.MaximumHealthyPercentage; @@ -270,7 +271,7 @@ public void WaitOption_IsDeserialisedAndReturned() { const string waitOptionInput = """{ "type": "waitUntilCompleted" }"""; var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, waitOptionInput, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.WaitOption; @@ -282,7 +283,7 @@ public void WaitOption_IsDeserialisedAndReturned() public void ShouldWaitForDeploymentCompletion_WhenDontWait_ReturnsFalse() { var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, """{ "type": "dontWait" }""", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); inputs.ShouldWaitForDeploymentCompletion.Should().BeFalse(); } @@ -293,7 +294,7 @@ public void ShouldWaitForDeploymentCompletion_WhenDontWait_ReturnsFalse() public void ShouldWaitForDeploymentCompletion_WhenWaiting_ReturnsTrue(string waitType) { var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, $$"""{ "type": "{{waitType}}", "timeoutMinutes": "30" }""", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); inputs.ShouldWaitForDeploymentCompletion.Should().BeTrue(); } @@ -305,7 +306,7 @@ public void AutoAssignPublicIp_IsReturnedAsAString(bool useExpression) { const string enablePublicIpInput = "True"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp, enablePublicIpInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.AutoAssignPublicIp; @@ -319,7 +320,7 @@ public void EnableEcsManagedTags_IsReturnedAsABool(bool useExpression) { const string enableEcsManagedTagsInput = "True"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.EnableEcsManagedTags, enableEcsManagedTagsInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.EnableEcsManagedTags; @@ -335,7 +336,7 @@ public void NetworkSecurityGroupIds_IsReturnedAsAStringArray(bool useExpression) ["sg-0123abcd456789fgh", "sg-abcd1234abcdef567"] """"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, securityGroupsInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.NetworkSecurityGroupIds; @@ -353,7 +354,7 @@ public void SubnetIds_IsReturnedAsAStringArray(bool useExpression) ["subnet-0123abcd456789fgh", "subnet-abcd1234abcdef567", "subnet-xxxxxxxxxxxxxxxx"] """"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.SubnetIds, subnetsInput, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var result = inputs.SubnetIDs; @@ -366,7 +367,7 @@ public void SubnetIds_IsReturnedAsAStringArray(bool useExpression) [Test] public void TaskRole_WithValueUnspecified_ReturnsEmptyString() { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var roleId = inputs.TaskRole; @@ -376,7 +377,7 @@ public void TaskRole_WithValueUnspecified_ReturnsEmptyString() [Test] public void TaskExecutionRole_WithValueUnspecified_ReturnsEmptyString() { - var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var roleId = inputs.TaskExecutionRole; @@ -390,7 +391,7 @@ public void TaskRole_ReturnsSuppliedValue(bool useExpression) { var taskRoleArn = "arn:aws:iam::123456780912:role/ecsTaskRole"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.TaskRole, taskRoleArn, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var roleId = inputs.TaskRole; @@ -405,7 +406,7 @@ public void TaskExecutionRole_ReturnsSuppliedValue(bool useExpression) // { AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, "arn:aws:iam::123456780912:role/ecsTaskRole"} var taskExecRoleArn = "arn:aws:iam::123456780912:role/ecsExecTaskRole"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.TaskExecutionRole, taskExecRoleArn, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var roleId = inputs.TaskExecutionRole; @@ -419,7 +420,7 @@ public void FallbackTaskExecutionRoleName_ReturnsServiceTaskNameValueWithPrefix( { const string serviceTaskName = "MyNewEcsServiceTask"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, serviceTaskName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var taskExecutionRoleName = inputs.FallbackTaskExecutionRoleName; @@ -433,7 +434,7 @@ public void ServiceTaskName_ReturnsRawNonPrefixedNameValue(bool useExpression) { const string expectedServiceTaskName = "ServiceTaskName"; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName, expectedServiceTaskName, useExpression); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var serviceTaskName = inputs.ServiceTaskName; @@ -449,7 +450,7 @@ public void Containers_ReturnsListOfMappedContainers() """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Containers, containerJson, false); variables["Octopus.Action.Package[nginx].Image"] = "docker.io/nginx:1.29.1"; - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var containers = inputs.Containers; containers.Length.Should().Be(1); @@ -472,7 +473,7 @@ public void Tags_WithSingleTag_ReturnsDeserialisedList() { const string tagsJson = """[{"key":"Environment","value":"Test"}]"""; var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, tagsJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var tags = inputs.Tags; @@ -492,7 +493,7 @@ public void Tags_WithMultipleTags_PreservesAllEntries() ] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, tagsJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var tags = inputs.Tags; @@ -507,7 +508,7 @@ public void Tags_WithMultipleTags_PreservesAllEntries() public void Tags_WithEmptyArray_ReturnsEmptyList() { var variables = SetupVariable(AwsSpecialVariables.Ecs.Tags, "[]", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var tags = inputs.Tags; @@ -528,7 +529,7 @@ public void LoadBalancerMappings_WithSingleMapping_ReturnsDeserialisedArray() ] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, mappingsJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var mappings = inputs.LoadBalancerMappings; @@ -548,7 +549,7 @@ public void LoadBalancerMappings_WithMultipleMappings_PreservesAllEntries() ] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, mappingsJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var mappings = inputs.LoadBalancerMappings; @@ -562,7 +563,7 @@ public void LoadBalancerMappings_WithMultipleMappings_PreservesAllEntries() public void LoadBalancerMappings_WithEmptyArray_ReturnsEmpty() { var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.LoadBalancerMappings, "[]", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var mappings = inputs.LoadBalancerMappings; @@ -587,7 +588,7 @@ public void Volumes_WithSingleEfsVolume_ReturnsDeserialisedArray() ] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var volumes = inputs.Volumes; @@ -606,7 +607,7 @@ public void Volumes_WithBindVolume_DeserialisesType() { const string volumesJson = """[{"type":"bind","name":"scratch"}]"""; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var volumes = inputs.Volumes; @@ -629,7 +630,7 @@ public void Volumes_WithMultipleVolumes_PreservesAllEntries() ] """; var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, volumesJson, false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var volumes = inputs.Volumes; @@ -642,7 +643,7 @@ public void Volumes_WithMultipleVolumes_PreservesAllEntries() public void Volumes_WithEmptyArray_ReturnsEmpty() { var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.Volumes, "[]", false); - var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeLog); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); var volumes = inputs.Volumes; From cf52f22798e6bbc5c4c142adf9cf9ce6a53edb37 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 18:44:09 +1000 Subject: [PATCH 71/80] Fix number formatting --- .../Ecs/SpfOutputs/complexSpfOutputTemplate.json | 14 +++++++------- .../Ecs/SpfOutputs/simpleSpfOutputTemplate.json | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json index a73506d85..b016c4a45 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -26,15 +26,15 @@ }, "DesiredCount": { "Type": "Number", - "Default": 2.0 + "Default": 2 }, "MinimumHealthPercent": { "Type": "Number", - "Default": 150.0 + "Default": 150 }, "MaximumHealthPercent": { "Type": "Number", - "Default": 300.0 + "Default": 300 }, "LogGroupName": { "Type": "String", @@ -96,10 +96,10 @@ "Command": [ "curl -f http://localhost/ || exit 1" ], - "Interval": 240.0, - "Retries": 7.0, - "StartPeriod": 179.0, - "Timeout": 54.0 + "Interval": 240, + "Retries": 7, + "StartPeriod": 179, + "Timeout": 54 }, "ExtraHosts": [], "Ulimits": [ diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json index 75b3b9bf1..7eb47b1a1 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json @@ -104,11 +104,11 @@ "TaskDefinition": { "Ref": "TaskDefinitiontestOctopusSpfdeployedTask" }, - "DesiredCount": 1.0, + "DesiredCount": 1, "EnableECSManagedTags": false, "DeploymentConfiguration": { - "MinimumHealthyPercent": 100.0, - "MaximumPercent": 200.0 + "MinimumHealthyPercent": 100, + "MaximumPercent": 200 }, "NetworkConfiguration": { "AwsvpcConfiguration": { From d5915d10cbee67135b50071f9d7c22869cbbbbfa Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 2 Jun 2026 18:52:45 +1000 Subject: [PATCH 72/80] fix numbers --- .../AWS/Ecs/EcsDeployTemplateGeneratorTests.cs | 7 +++++-- .../multiContainerSpfOutputTemplate.json | 14 +++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs index fe81f8b48..574f818a5 100644 --- a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -81,7 +81,10 @@ public void WithSimpleVariableSetup_MatchesExpectedSpfOutput() public void WithMultipleContainers_MatchesExpectedSpfOutput() { var expectedJson = ReadFromFile("multiContainerSpfOutputTemplate.json"); - + + fakeEcsImageResolver.ResolveImageName(Arg.Is(v => v.ReferenceId == "939a08a0-7dd9-471d-ac31-ac8e29eb04ff"), Arg.Any()).Returns("docker.io/nginx:1.31.1"); + fakeEcsImageResolver.ResolveImageName(Arg.Is(v => v.ReferenceId == "07c8e308-048f-4289-b25a-86fb901e2824"), Arg.Any()).Returns("docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig"); + var variables = new CalamariVariables { { AwsSpecialVariables.Ecs.Deploy.StackName, "test-stack" }, @@ -112,7 +115,7 @@ public void WithMultipleContainers_MatchesExpectedSpfOutput() {AwsSpecialVariables.Ecs.Tags, """[{"key":"my-tag","value":"a great test value"}]"""}, {AwsSpecialVariables.Ecs.Deploy.Containers, """ - [{"containerName":"web-server","containerImageReference":{"referenceId":"939a08a0-7dd9-471d-ac31-ac8e29eb04ff","imageName":"#{Octopus.Action[Deploy Amazon ECS Service].Package[nginx].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"memoryLimitSoft":"47","memoryLimitHard":"200","containerPortMappings":[{"containerPort":"80","protocol":"Tcp"},{"containerPort":"443","protocol":"Tcp"}],"cpus":"2","essential":"True","entryPoint":"sh, -c","command":"echo 'Deployment successful","workingDirectory":"/tmp","environmentFiles":["jttestc668db76/test/keyarm-packagev1.0.3.zip"],"environmentVariables":[{"type":"Plain","key":"containerenv","value":"some-otherovalue"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"True","mountPoints":[{"sourceVolume":"efs-volume","containerPath":"/etc","readonly":"False"}],"volumeFrom":[{"sourceContainer":"efs-volume","readonly":"True"}]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Enabled","firelensType":"Fluentd","enableEcsLogMetadata":"True","customConfigSource":{"type":"File","filePath":"/home/config"}},"dockerLabels":[{"key":"some-label","value":"label-value"}],"user":"test-user","healthCheck":{"command":["curl -f http://localhost/ || exit 1"],"interval":"240","retries":"7","startPeriod":"179","timeout":"54"},"dependencies":[],"startTimeout":"40","stopTimeout":"60","ulimits":[{"limitName":"core","hardLimit":"12","softLimit":"10"}]},{"containerName":"cache","containerImageReference":{"referenceId":"07c8e308-048f-4289-b25a-86fb901e2824","imageName":"#{Octopus.Action[Deploy Amazon ECS Service].Package[redis].Image}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[" [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]."]},"dependencies":[],"ulimits":[]}] + [{"containerName":"web-server","containerImageReference":{"referenceId":"939a08a0-7dd9-471d-ac31-ac8e29eb04ff","imageName":"nginx}","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"memoryLimitSoft":"47","memoryLimitHard":"200","containerPortMappings":[{"containerPort":"80","protocol":"Tcp"},{"containerPort":"443","protocol":"Tcp"}],"cpus":"2","essential":"True","entryPoint":"sh, -c","command":"echo 'Deployment successful","workingDirectory":"/tmp","environmentFiles":["jttestc668db76/test/keyarm-packagev1.0.3.zip"],"environmentVariables":[{"type":"Plain","key":"containerenv","value":"some-otherovalue"}],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"True","mountPoints":[{"sourceVolume":"efs-volume","containerPath":"/etc","readonly":"False"}],"volumeFrom":[{"sourceContainer":"efs-volume","readonly":"True"}]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Enabled","firelensType":"Fluentd","enableEcsLogMetadata":"True","customConfigSource":{"type":"File","filePath":"/home/config"}},"dockerLabels":[{"key":"some-label","value":"label-value"}],"user":"test-user","healthCheck":{"command":["curl -f http://localhost/ || exit 1"],"interval":"240","retries":"7","startPeriod":"179","timeout":"54"},"dependencies":[],"startTimeout":"40","stopTimeout":"60","ulimits":[{"limitName":"core","hardLimit":"12","softLimit":"10"}]},{"containerName":"cache","containerImageReference":{"referenceId":"07c8e308-048f-4289-b25a-86fb901e2824","imageName":"redis","feedId":"Feeds-1001"},"repositoryAuthentication":{"type":"Default"},"containerPortMappings":[],"essential":"True","environmentFiles":[],"environmentVariables":[],"networkSettings":{"disableNetworking":"False","dnsServers":[],"dnsSearchDomains":[],"extraHosts":[]},"containerStorage":{"readOnlyRootFileSystem":"False","mountPoints":[],"volumeFrom":[]},"containerLogging":{"type":"Auto","logOptions":[]},"firelensConfiguration":{"type":"Disabled","enableEcsLogMetadata":""},"dockerLabels":[],"healthCheck":{"command":[" [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]."]},"dependencies":[],"ulimits":[]}] """}, diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json index a54367237..0971d825d 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json @@ -27,7 +27,7 @@ }, "DesiredCount": { "Type": "Number", - "Default": 2.0 + "Default": 2 }, "LogGroupName": { "Type": "String", @@ -89,10 +89,10 @@ "Command": [ "curl -f http://localhost/ || exit 1" ], - "Interval": 240.0, - "Retries": 7.0, - "StartPeriod": 179.0, - "Timeout": 54.0 + "Interval": 240, + "Retries": 7, + "StartPeriod": 179, + "Timeout": 54 }, "ExtraHosts": [], "Ulimits": [ @@ -240,8 +240,8 @@ }, "EnableECSManagedTags": true, "DeploymentConfiguration": { - "MinimumHealthyPercent": 100.0, - "MaximumPercent": 200.0 + "MinimumHealthyPercent": 100, + "MaximumPercent": 200 }, "NetworkConfiguration": { "AwsvpcConfiguration": { From 80a036f634f5417e75416e9ac2ae831f680c9728 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 09:54:17 +1000 Subject: [PATCH 73/80] Remove verbose evaluation as it is not needed --- .../Variables/VariablesDeserialisationExtensions.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index f83d1b192..d5b77cee5 100644 --- a/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs +++ b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs @@ -16,16 +16,9 @@ public static T GetValueDeserialisedAs(this IVariables variables, string name throw new CommandException($"Variable {name} was not supplied"); } - // Expand any `#{...}` references in the raw JSON before deserialising. Without - // this, variables nested inside the JSON (e.g. a containerImage referencing a - // package variable) stay as literal `#{...}` text in the typed objects. - // Variables that contain JSON-significant characters must use `| JsonEscape` - // when referenced so the substituted text stays parseable. - var evaluatedJson = variables.Evaluate(variableJson); - try { - var output = JsonConvert.DeserializeObject(evaluatedJson, CalamariContractSerializationSettings.Default); + var output = JsonConvert.DeserializeObject(variableJson, CalamariContractSerializationSettings.Default); return output ?? throw new CommandException($"Variable {name} was deserialized as null "); } catch (JsonSerializationException) From 81ebb250a09a51557fc6266d782abd4f4481d727 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 10:16:29 +1000 Subject: [PATCH 74/80] Remove remaining doubles + fix boolean parsing --- .../Ecs/ContainerSpecMappingExtensions.cs | 12 +++++----- .../Ecs/LoadBalancerMappingExtensions.cs | 2 +- .../Ecs/Deploy/Cfn/ContainerDefinition.cs | 24 +++++++++---------- .../Integration/Ecs/Deploy/Cfn/Service.cs | 2 +- .../Ecs/Deploy/EcsDeployTemplate.cs | 10 ++++---- .../Ecs/Deploy/EcsDeployTemplateGenerator.cs | 19 ++++++++------- 6 files changed, 35 insertions(+), 34 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index 39a510b42..d2f1309e9 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -55,8 +55,8 @@ public static Cfn.EnvironmentEntry[] ParseEnvironmentVariables(this ContainerSpe public static Cfn.PortMapping[] ParsePortMappings(this ContainerSpec containerSpec) => containerSpec.ContainerPortMappings.Select(pm => new Cfn.PortMapping { - ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - HostPort = pm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + HostPort = pm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), Protocol = pm.Protocol.ToString().ToLowerInvariant() }) .ToArray(); @@ -93,8 +93,8 @@ public static Cfn.Ulimit[] ParseULimits(this ContainerSpec containerSpec) return containerSpec.Ulimits.Select(ul => new Cfn.Ulimit { Name = ul.LimitName, - HardLimit = double.Parse(ul.HardLimit, CultureInfo.InvariantCulture), - SoftLimit = double.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) + HardLimit = int.Parse(ul.HardLimit, CultureInfo.InvariantCulture), + SoftLimit = int.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) }) .ToArray(); } @@ -107,7 +107,7 @@ public static Cfn.MountPoint[] ParseMountPoints(this ContainerSpec containerSpec { SourceVolume = string.IsNullOrEmpty(mp.SourceVolume) ? null : mp.SourceVolume, ContainerPath = string.IsNullOrEmpty(mp.ContainerPath) ? null : mp.ContainerPath, - ReadOnly = mp.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + ReadOnly = mp.Readonly.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result) }) .ToArray(); } @@ -131,7 +131,7 @@ public static Cfn.VolumeFrom[] ParseVolumesFrom(this ContainerSpec containerSpec return containerSpec.ContainerStorage.VolumeFrom.Select(vf => new Cfn.VolumeFrom { SourceContainer = string.IsNullOrEmpty(vf.SourceContainer) ? null : vf.SourceContainer, - ReadOnly = vf.Readonly.ConvertedOrDefault(s => bool.Parse(s)) + ReadOnly = vf.Readonly.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result) }) .ToArray(); } diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs index 83e59e6bc..5a4b61d69 100644 --- a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -13,7 +13,7 @@ public static Cfn.LoadBalancer[] ToLoadBalancerProperties(this IEnumerable new Cfn.LoadBalancer { ContainerName = lbm.ContainerName, - ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), TargetGroupArn = lbm.TargetGroupArn }).ToArray(); diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs index 4cdf7e071..8dd2d9aeb 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs @@ -9,15 +9,15 @@ public sealed record ContainerDefinition public bool? Essential { get; init; } public bool? DisableNetworking { get; init; } public string WorkingDirectory { get; init; } - public double? Memory { get; init; } - public double? MemoryReservation { get; init; } - public double? Cpu { get; init; } + public int? Memory { get; init; } + public int? MemoryReservation { get; init; } + public int? Cpu { get; init; } public string User { get; init; } - public double? StartTimeout { get; init; } - public double? StopTimeout { get; init; } + public int? StartTimeout { get; init; } + public int? StopTimeout { get; init; } public string[] DnsServers { get; init; } public string[] DnsSearchDomains { get; init; } - public bool? ReadonlyRootFilesystem { get; init; } + public bool ReadonlyRootFilesystem { get; init; } public string[] Command { get; init; } public string[] EntryPoint { get; init; } public ResourceRequirement[] ResourceRequirements { get; init; } @@ -45,8 +45,8 @@ public sealed record ResourceRequirement public sealed record PortMapping { - public double? ContainerPort { get; init; } - public double? HostPort { get; init; } + public int? ContainerPort { get; init; } + public int? HostPort { get; init; } public string Protocol { get; init; } } @@ -73,15 +73,15 @@ public sealed record RepositoryCredentials public sealed record Ulimit { public string Name { get; init; } - public double HardLimit { get; init; } - public double SoftLimit { get; init; } + public int HardLimit { get; init; } + public int SoftLimit { get; init; } } public sealed record MountPoint { public string SourceVolume { get; init; } public string ContainerPath { get; init; } - public bool? ReadOnly { get; init; } + public bool ReadOnly { get; init; } } public sealed record ContainerDependency @@ -93,7 +93,7 @@ public sealed record ContainerDependency public sealed record VolumeFrom { public string SourceContainer { get; init; } - public bool? ReadOnly { get; init; } + public bool ReadOnly { get; init; } } // LogConfiguration.Options values can be either a literal string or a Ref intrinsic diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs index a7b53f87c..2710aadec 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Service.cs @@ -41,6 +41,6 @@ public sealed record AwsvpcConfiguration public sealed record LoadBalancer { public string ContainerName { get; init; } - public double? ContainerPort { get; init; } + public int? ContainerPort { get; init; } public string TargetGroupArn { get; init; } } diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index c0d47167a..cb385f67d 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -166,19 +166,19 @@ static Cfn.ContainerDefinition BuildContainerDefinition( { Name = c.ContainerName, Image = commandInputs.ResolveImageName(c.ContainerImageReference), - Essential = c.Essential.ConvertedOrDefault(bool.Parse), - DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(bool.Parse), + Essential = c.Essential.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result), + DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result), WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), User = string.IsNullOrEmpty(c.User) ? null : c.User, - StartTimeout = c.StartTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), - StopTimeout = c.StopTimeout.ConvertedOrDefault(s => double.Parse(s, CultureInfo.InvariantCulture)), + StartTimeout = c.StartTimeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), // SPF always emits these arrays even when empty — preserve that shape. DnsServers = c.NetworkSettings.DnsServers.ToArray(), DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), - ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(bool.Parse), + ReadonlyRootFilesystem = c.ContainerStorage.ReadOnlyRootFileSystem.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result), Command = c.Command.ConvertedOrDefault(s => [s], () => null), EntryPoint = c.EntryPoint.ConvertedOrDefault(s => s.Split(',').Select(x => x.Trim()).ToArray(), () => null), ResourceRequirements = c.ParseResourceRequirements(), diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs index af51b50e9..b79819c7b 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -16,11 +16,12 @@ public GeneratedTemplate Generate() var parameters = BuildParameters(); var template = new EcsDeployTemplate(commandInputs, parameters).Build(); - var body = JsonConvert.SerializeObject(template, new JsonSerializerSettings - { - Formatting = Formatting.Indented, - NullValueHandling = NullValueHandling.Ignore - }); + var body = JsonConvert.SerializeObject(template, + new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore + }); return new GeneratedTemplate( body, @@ -31,9 +32,9 @@ List BuildParameters() { var list = new List { - EcsTemplateParameter.Of(EcsTemplateParameterNames.ClusterName, commandInputs.ClusterName), - EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionName, commandInputs.ServiceTaskName), - EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionCpu, commandInputs.Cpu), + EcsTemplateParameter.Of(EcsTemplateParameterNames.ClusterName, commandInputs.ClusterName), + EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionName, commandInputs.ServiceTaskName), + EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionCpu, commandInputs.Cpu), EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskDefinitionMemory, commandInputs.Memory), }; @@ -73,4 +74,4 @@ List BuildParameters() return list; } -} +} \ No newline at end of file From 3e6f0a295436df80d1f700099df63f70fb2f1a0c Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 10:18:25 +1000 Subject: [PATCH 75/80] style: add explainer --- source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs index 8e433bebe..1b962aa9a 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs @@ -2,6 +2,8 @@ namespace Calamari.Aws.Integration.Ecs.Deploy.Cfn; +// Template format created from SPF out matched with AWS official API documentation here: https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/AWS_ECS.html + public sealed record Template { public string AWSTemplateFormatVersion { get; init; } = "2010-09-09"; From deb84692a1579a9d1e3c5604fa29a500ae598a34 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 10:26:29 +1000 Subject: [PATCH 76/80] Add parsing safety --- .../Integration/Ecs/Deploy/EcsDeployTemplate.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs index cb385f67d..2af846549 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -169,12 +169,12 @@ static Cfn.ContainerDefinition BuildContainerDefinition( Essential = c.Essential.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result), DisableNetworking = c.NetworkSettings.DisableNetworking.ConvertedOrDefault(s => bool.TryParse(s, out var result) && result), WorkingDirectory = string.IsNullOrEmpty(c.WorkingDirectory) ? null : c.WorkingDirectory, - Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - Cpu = c.Cpus.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + Memory = c.MemoryLimitHard.ConvertedOrDefault(s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : null), + MemoryReservation = c.MemoryLimitSoft.ConvertedOrDefault(s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : null), + Cpu = c.Cpus.ConvertedOrDefault(s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : null), User = string.IsNullOrEmpty(c.User) ? null : c.User, - StartTimeout = c.StartTimeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - StopTimeout = c.StopTimeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + StartTimeout = c.StartTimeout.ConvertedOrDefault(s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : null), + StopTimeout = c.StopTimeout.ConvertedOrDefault(s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : null), // SPF always emits these arrays even when empty — preserve that shape. DnsServers = c.NetworkSettings.DnsServers.ToArray(), DnsSearchDomains = c.NetworkSettings.DnsSearchDomains.ToArray(), From eb12c559760b704d7e6d8c4d0fa6642554068400 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 10:36:21 +1000 Subject: [PATCH 77/80] tests: fix --- .../SpfOutputs/complexSpfOutputTemplate.json | 22 +++++++++---------- .../multiContainerSpfOutputTemplate.json | 22 +++++++++---------- .../SpfOutputs/simpleSpfOutputTemplate.json | 4 ++-- .../ContainerSpecMappingExtensionsTests.cs | 8 +++---- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json index b016c4a45..23fa97dcb 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -60,12 +60,12 @@ "Essential": true, "DisableNetworking": false, "WorkingDirectory": "/tmp", - "Memory": 200.0, - "MemoryReservation": 47.0, - "Cpu": 2.0, + "Memory": 200, + "MemoryReservation": 47, + "Cpu": 2, "User": "test-user", - "StartTimeout": 40.0, - "StopTimeout": 60.0, + "StartTimeout": 40, + "StopTimeout": 60, "DnsServers": [], "DnsSearchDomains": [], "ReadonlyRootFilesystem": true, @@ -82,13 +82,13 @@ }, "PortMappings": [ { - "ContainerPort": 80.0, - "HostPort": 80.0, + "ContainerPort": 80, + "HostPort": 80, "Protocol": "tcp" }, { - "ContainerPort": 443.0, - "HostPort": 443.0, + "ContainerPort": 443, + "HostPort": 443, "Protocol": "tcp" } ], @@ -105,8 +105,8 @@ "Ulimits": [ { "Name": "core", - "HardLimit": 12.0, - "SoftLimit": 10.0 + "HardLimit": 12, + "SoftLimit": 10 } ], "MountPoints": [ diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json index 0971d825d..06a63dfe6 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json @@ -53,12 +53,12 @@ "Essential": true, "DisableNetworking": false, "WorkingDirectory": "/tmp", - "Memory": 200.0, - "MemoryReservation": 47.0, - "Cpu": 2.0, + "Memory": 200, + "MemoryReservation": 47, + "Cpu": 2, "User": "test-user", - "StartTimeout": 40.0, - "StopTimeout": 60.0, + "StartTimeout": 40, + "StopTimeout": 60, "DnsServers": [], "DnsSearchDomains": [], "ReadonlyRootFilesystem": true, @@ -75,13 +75,13 @@ }, "PortMappings": [ { - "ContainerPort": 80.0, - "HostPort": 80.0, + "ContainerPort": 80, + "HostPort": 80, "Protocol": "tcp" }, { - "ContainerPort": 443.0, - "HostPort": 443.0, + "ContainerPort": 443, + "HostPort": 443, "Protocol": "tcp" } ], @@ -98,8 +98,8 @@ "Ulimits": [ { "Name": "core", - "HardLimit": 12.0, - "SoftLimit": 10.0 + "HardLimit": 12, + "SoftLimit": 10 } ], "MountPoints": [ diff --git a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json index 7eb47b1a1..9a48e8541 100644 --- a/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json @@ -42,8 +42,8 @@ "ResourceRequirements": [], "PortMappings": [ { - "ContainerPort": 80.0, - "HostPort": 80.0, + "ContainerPort": 80, + "HostPort": 80, "Protocol": "tcp" } ], diff --git a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs index 214d5d6fa..363de1d1a 100644 --- a/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -55,7 +55,7 @@ public void ParseMountPoints_WithMountPoints_MapsAllProperties() } [Test] - public void ParseMountPoints_WithEmptyReadonly_IsNull() + public void ParseMountPoints_WithEmptyReadonly_IsFalse() { var spec = new ContainerSpec { @@ -75,7 +75,7 @@ public void ParseMountPoints_WithEmptyReadonly_IsNull() var result = spec.ParseMountPoints(); - result[0].ReadOnly.Should().BeNull(); + result[0].ReadOnly.Should().BeFalse(); } [Test] @@ -238,7 +238,7 @@ public void ParseVolumesFrom_WithEmptySourceContainer_ReturnsNull() } [Test] - public void ParseVolumesFrom_WithEmptyReadonly_IsNull() + public void ParseVolumesFrom_WithEmptyReadonly_IsFalse() { var spec = new ContainerSpec { @@ -257,7 +257,7 @@ public void ParseVolumesFrom_WithEmptyReadonly_IsNull() var result = spec.ParseVolumesFrom(); - result[0].ReadOnly.Should().BeNull(); + result[0].ReadOnly.Should().BeFalse(); } [Test] From 680d9067053c6dd4ee2052a5cd20780db779cdaf Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 11:02:39 +1000 Subject: [PATCH 78/80] Fix more int parsing --- source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs index 58afe3bc9..3dffd84ee 100644 --- a/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -81,9 +81,9 @@ public string CfStackName public string Memory => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory); - public int DesiredCount => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount)); - public int MinimumHealthyPercentage => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent)); - public int MaximumHealthyPercentage => int.Parse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent)); + public int DesiredCount => int.TryParse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.DesiredCount), out var result) ? result : EcsInputDefaults.DesiredCount; + public int MinimumHealthyPercentage => int.TryParse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MinimumHealthPercent), out var result) ? result : EcsInputDefaults.MinimumHealthPercent; + public int MaximumHealthyPercentage => int.TryParse(variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.MaximumHealthPercent), out var result) ? result : EcsInputDefaults.MaximumHealthPercent; public string AutoAssignPublicIp => variables.GetFlag(AwsSpecialVariables.Ecs.Deploy.AutoAssignPublicIp) ? "ENABLED" : "DISABLED"; @@ -130,7 +130,7 @@ public InputsValidityResult Validate() var variableNames = variables.GetNames(); var missingKeys = requiredVariableKeys.Except(variableNames); - // TODO: Validation of input values + // NOTE: Can we validate values are of the right type etc? return new InputsValidityResult(missingKeys); } From 4d18bdcdc4b828885d32d20ea34c60a9c9615ff7 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 11:03:53 +1000 Subject: [PATCH 79/80] fix int parse --- source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs index 5a4b61d69..ded0b5b2e 100644 --- a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -13,7 +13,7 @@ public static Cfn.LoadBalancer[] ToLoadBalancerProperties(this IEnumerable new Cfn.LoadBalancer { ContainerName = lbm.ContainerName, - ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.TryParse(s, out var result) ? result : null), TargetGroupArn = lbm.TargetGroupArn }).ToArray(); From aaf3cd7685d6481f2ce85ecee7891af28b5b256d Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 3 Jun 2026 11:09:06 +1000 Subject: [PATCH 80/80] replace remaining unsafe parses --- .../Inputs/Ecs/ContainerSpecMappingExtensions.cs | 16 ++++++++-------- .../Inputs/Ecs/LoadBalancerMappingExtensions.cs | 2 +- .../Ecs/Deploy/Cfn/ContainerDefinition.cs | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs index d2f1309e9..2a3b39a64 100644 --- a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -26,10 +26,10 @@ public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) return new Cfn.HealthCheck { Command = containerSpec.HealthCheck.Command.ToArray(), - Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + Interval = containerSpec.HealthCheck.Interval.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), + Retries = containerSpec.HealthCheck.Retries.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), + StartPeriod = containerSpec.HealthCheck.StartPeriod.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), + Timeout = containerSpec.HealthCheck.Timeout.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), }; } @@ -55,8 +55,8 @@ public static Cfn.EnvironmentEntry[] ParseEnvironmentVariables(this ContainerSpe public static Cfn.PortMapping[] ParsePortMappings(this ContainerSpec containerSpec) => containerSpec.ContainerPortMappings.Select(pm => new Cfn.PortMapping { - ContainerPort = pm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), - HostPort = pm.ContainerPort.ConvertedOrDefault(s => int.Parse(s, CultureInfo.InvariantCulture)), + ContainerPort = pm.ContainerPort.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), + HostPort = pm.ContainerPort.ConvertedOrDefault((s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null)), Protocol = pm.Protocol.ToString().ToLowerInvariant() }) .ToArray(); @@ -93,8 +93,8 @@ public static Cfn.Ulimit[] ParseULimits(this ContainerSpec containerSpec) return containerSpec.Ulimits.Select(ul => new Cfn.Ulimit { Name = ul.LimitName, - HardLimit = int.Parse(ul.HardLimit, CultureInfo.InvariantCulture), - SoftLimit = int.Parse(ul.SoftLimit, CultureInfo.InvariantCulture) + HardLimit = int.TryParse(ul.HardLimit, CultureInfo.InvariantCulture, out var hl) ? hl : null, + SoftLimit = int.TryParse(ul.SoftLimit, CultureInfo.InvariantCulture, out var sl) ? sl : null, }) .ToArray(); } diff --git a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs index ded0b5b2e..f5ad9bb14 100644 --- a/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -13,7 +13,7 @@ public static Cfn.LoadBalancer[] ToLoadBalancerProperties(this IEnumerable new Cfn.LoadBalancer { ContainerName = lbm.ContainerName, - ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.TryParse(s, out var result) ? result : null), + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null), TargetGroupArn = lbm.TargetGroupArn }).ToArray(); diff --git a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs index 8dd2d9aeb..1d285478a 100644 --- a/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/ContainerDefinition.cs @@ -73,8 +73,8 @@ public sealed record RepositoryCredentials public sealed record Ulimit { public string Name { get; init; } - public int HardLimit { get; init; } - public int SoftLimit { get; init; } + public int? HardLimit { get; init; } + public int? SoftLimit { get; init; } } public sealed record MountPoint