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 0bc15c409..2bda1b7d0 100644 --- a/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs +++ b/source/Calamari.Aws/Commands/DeployEcsServiceCommand.cs @@ -1,142 +1,44 @@ 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("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, IEcsImageNameResolver ecsImageNameResolver) : 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, ecsImageNameResolver, log); + var inputValidity = inputs.Validate(); + if (!inputValidity.IsValid) + { + throw new CommandException(inputValidity.MissingKeyList); + } - var stackArn = new StackArn(inputs.StackName); - var templateResolver = new TemplateResolver(fileSystem); new ConventionProcessor(new RunningDeployment(variables), [ new LogAwsUserInfoConvention(environment), - new DeployAwsCloudFormationConvention(ClientFactory, - TemplateFactory, - new StackEventLogger(log), - _ => stackArn, - inputs.WaitForComplete, - inputs.StackName, - environment, - log, - inputs.WaitTimeout), + new DeployEcsServiceConvention(inputs, environment, log, variables), new SetEcsOutputVariablesConvention(environment, - inputs.StackName, + inputs.CfStackName, inputs.ClusterName, - inputs.ServiceName, + inputs.ServiceTaskName, 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 diff --git a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs index 55a531a1f..5f4da9ca3 100644 --- a/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs +++ b/source/Calamari.Aws/Deployment/AwsSpecialVariables.cs @@ -22,9 +22,35 @@ public static class S3 public static class Ecs { + 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 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 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"; + 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 + 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"; + public static class Update { @@ -33,13 +59,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.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 diff --git a/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs new file mode 100644 index 000000000..42d3a3f97 --- /dev/null +++ b/source/Calamari.Aws/Deployment/Conventions/DeployEcsServiceConvention.cs @@ -0,0 +1,60 @@ +using System; +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.Integration.Ecs.Deploy; +using Calamari.Aws.Util; +using Calamari.CloudAccounts; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; +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(DeployEcsCommandInputs commandInputs, AwsEnvironmentGeneration awsEnvironment, ILog log, IVariables variables) + : IInstallConvention +{ + readonly EcsDeployTemplateGenerator templateGenerator = new(commandInputs); + + + public void Install(RunningDeployment deployment) + { + var generated = templateGenerator.Generate(); + var stackEventLogger = new StackEventLogger(log); + + 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(() => generated.Body, + new ListTemplateInputs(generated.Parameters), + commandInputs.CfStackName, + ["CAPABILITY_NAMED_IAM"], + false, + null, + commandInputs.Tags, + commandInputs.CfStackArn, + ClientFactory, + variables); + } + } +} \ No newline at end of file diff --git a/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs new file mode 100644 index 000000000..2a3b39a64 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/ContainerSpecMappingExtensions.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +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); + } + + public static Cfn.HealthCheck ParseHealthCheck(this ContainerSpec containerSpec) + { + if (containerSpec.HealthCheck.Command.Count == 0) return null; + + return new Cfn.HealthCheck + { + Command = containerSpec.HealthCheck.Command.ToArray(), + 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)), + }; + } + + public static Dictionary ParseDockerLabels(this ContainerSpec containerSpec) + { + var labels = containerSpec.DockerLabels + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.Last().Value); + return labels.Count == 0 ? null : labels; + } + + public static Cfn.EnvironmentEntry[] ParseEnvironmentVariables(this ContainerSpec containerSpec) + { + 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; + } + + // 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 => 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(); + + // 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(); + + public static Cfn.RepositoryCredentials ParseRepositoryCredentials(this ContainerSpec containerSpec) => + containerSpec.RepositoryAuthentication.Type switch + { + RepositoryAuthenticationType.Default => null, + _ => new Cfn.RepositoryCredentials + { + CredentialsParameter = containerSpec.RepositoryAuthentication.SecretName + } + }; + + // SPF always emits ResourceRequirements as an array — empty becomes [] not omitted. + public static Cfn.ResourceRequirement[] ParseResourceRequirements(this ContainerSpec containerSpec) => + string.IsNullOrEmpty(containerSpec.Gpus) + ? [] + : [new Cfn.ResourceRequirement { Type = "GPU", Value = containerSpec.Gpus }]; + + 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 = int.TryParse(ul.HardLimit, CultureInfo.InvariantCulture, out var hl) ? hl : null, + SoftLimit = int.TryParse(ul.SoftLimit, CultureInfo.InvariantCulture, out var sl) ? sl : null, + }) + .ToArray(); + } + + 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.TryParse(s, out var result) && result) + }) + .ToArray(); + } + + public static Cfn.ContainerDependency[] ParseDependencies(this ContainerSpec containerSpec) + { + 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(); + } + + 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.TryParse(s, out var result) && result) + }) + .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(); + + // 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, + 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; + // emit the standard awslogs configuration pointing at the task's log group. + return new Cfn.LogConfiguration + { + LogDriver = LogDriver.AwsLogs.ToString().ToLowerInvariant(), + Options = new Dictionary> + { + { "awslogs-group", logGroupNameRef }, + { "awslogs-region", awsRegionRef }, + { "awslogs-stream-prefix", "ecs" } + } + }; + + 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 Cfn.LogConfiguration + { + LogDriver = containerSpec.ContainerLogging.LogDriver.Value.ToString().ToLowerInvariant(), + Options = containerSpec.ContainerLogging.LogOptions + .Where(o => o.Type == KeyValueType.Plain) + .ToDictionary>( + o => o.Key, + o => o.Value), + SecretOptions = containerSpec.ContainerLogging.LogOptions + .Where(o => o.Type == KeyValueType.Secret) + .Select(o => new Cfn.Secret { Name = o.Key, ValueFrom = o.Value }) + .ToArray() + }; + } + } + + public static Cfn.FirelensConfiguration ParseFireLensConfiguration(this ContainerSpec containerSpec) + { + if (containerSpec.FirelensConfiguration.Type == FireLensConfigurationType.Disabled) return null; + + var options = new Dictionary + { + { "enable-ecs-log-metadata", containerSpec.FirelensConfiguration.EnableEcsLogMetadata?.ToLowerInvariant() } + }; + + if (containerSpec.FirelensConfiguration.CustomConfigSource is { Type: not FireLensCustomConfigSourceType.None } src) + { + 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(), + Options = options + }; + } + + public static Cfn.Secret[] ParseSecrets(this ContainerSpec containerSpec) + { + 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/DeployEcsCommandInputs.cs b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs new file mode 100644 index 000000000..3dffd84ee --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/DeployEcsCommandInputs.cs @@ -0,0 +1,170 @@ +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; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Aws.Inputs.Ecs; + +public class DeployEcsCommandInputs +{ + readonly IVariables variables; + readonly IEcsStackNameGenerator stackNameGenerator; + readonly IEcsImageNameResolver ecsImageResolver; + readonly ILog log; + readonly HashSet requiredVariableKeys = []; + + public DeployEcsCommandInputs(IVariables variables, IEcsStackNameGenerator stackNameGenerator, IEcsImageNameResolver ecsImageResolver, ILog log) + { + this.variables = variables; + this.stackNameGenerator = stackNameGenerator; + this.ecsImageResolver = ecsImageResolver; + this.log = log; + + // strings + 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.RuntimeArchitecturePlatform); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.DesiredCount); + 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); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.SubnetIds); + + // Objects + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.WaitOption); + requiredVariableKeys.Add(AwsSpecialVariables.Ecs.Deploy.Containers); + } + + public string ClusterName => variables.Get(AwsSpecialVariables.Ecs.ClusterName); + + public string ServiceTaskName => variables.Get(AwsSpecialVariables.Ecs.Deploy.ServiceTaskName); + + 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 StackArn(CfStackName); + + public string Environment => variables.GetMandatoryVariable(DeploymentEnvironment.Id); + + public string Tenant => variables.Get(DeploymentVariables.Tenant.Id, ""); + + public string Cpu => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Cpu); + + public string Memory => variables.GetMandatoryVariable(AwsSpecialVariables.Ecs.Deploy.Memory); + + 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"; + + 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 + }; + } + } + + 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 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 bool ShouldWaitForDeploymentCompletion => WaitOption.Type is WaitType.WaitUntilCompleted or WaitType.WaitWithTimeout; + + public ContainerSpec[] Containers => variables.GetValueDeserialisedAs(AwsSpecialVariables.Ecs.Deploy.Containers); + + public string ResolveImageName(ContainerImageReference imageReference) => ecsImageResolver.ResolveImageName(imageReference, variables); + + + public InputsValidityResult Validate() + { + var variableNames = variables.GetNames(); + var missingKeys = requiredVariableKeys.Except(variableNames); + + // NOTE: Can we validate values are of the right type etc? + + 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()}"; + + 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 +} + +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; + } + } +} + +public static class EcsInputDefaults +{ + public const int DesiredCount = 1; + public const int MinimumHealthPercent = 100; + public const int MaximumHealthPercent = 200; +} \ No newline at end of file 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.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs new file mode 100644 index 000000000..f5ad9bb14 --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/LoadBalancerMappingExtensions.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +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 Cfn.LoadBalancer[] ToLoadBalancerProperties(this IEnumerable loadBalancerMappings) + { + var mappings = loadBalancerMappings.Select(lbm => new Cfn.LoadBalancer + { + ContainerName = lbm.ContainerName, + ContainerPort = lbm.ContainerPort.ConvertedOrDefault(s => int.TryParse(s, CultureInfo.InvariantCulture, out var result) ? result : null), + TargetGroupArn = lbm.TargetGroupArn + }).ToArray(); + + return mappings.Length == 0 ? null : mappings; + } +} diff --git a/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs new file mode 100644 index 000000000..b80e7cace --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/TagMappingExtensions.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Linq; +using Cfn = Calamari.Aws.Integration.Ecs.Deploy.Cfn; + +namespace Calamari.Aws.Inputs.Ecs; + +public static class TagMappingExtensions +{ + // 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/VolumeMapper.cs b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs new file mode 100644 index 000000000..d778f382f --- /dev/null +++ b/source/Calamari.Aws/Inputs/Ecs/VolumeMapper.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Octopus.Calamari.Contracts.Aws.Ecs; +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 +{ + // SPF always emits Volumes as an array — empty becomes [] not omitted. + public static Cfn.Volume[] ParseVolumes(this InputVolume[] volumes) + { + 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, + 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 boundVolumes.Concat(efsVolumes).ToArray(); + } +} 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..1d285478a --- /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 int? Memory { get; init; } + public int? MemoryReservation { get; init; } + public int? Cpu { get; init; } + public string User { 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 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 int? ContainerPort { get; init; } + public int? HostPort { get; init; } + public string Protocol { get; init; } +} + +public sealed record HealthCheck +{ + public string[] Command { 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 +{ + 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 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 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..2710aadec --- /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 int? 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..1b962aa9a --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/Cfn/Template.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +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"; + 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 new file mode 100644 index 000000000..741b40d94 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployParameterGeneration.cs @@ -0,0 +1,50 @@ +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 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 + // 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 + }; + + // 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: +// 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/Deploy/EcsDeployTemplate.cs b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs new file mode 100644 index 000000000..2af846549 --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplate.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Calamari.Aws.Inputs.Ecs; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Aws.Integration.Ecs.Deploy; + +// 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 DeployEcsCommandInputs commandInputs; + readonly IReadOnlyList parameters; + readonly HashSet registeredParameterNames; + readonly bool createsInTemplateExecutionRole; + + public EcsDeployTemplate(DeployEcsCommandInputs commandInputs, IReadOnlyList parameters) + { + 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() + }; + + Dictionary BuildParametersSection() + { + var section = parameters.ToDictionary( + p => p.Name, + p => new Cfn.ParameterDef { Type = p.CfnType, Default = p.Default }); + + if (createsInTemplateExecutionRole) + { + 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) + { + 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"); + + Cfn.Value executionRoleArn = createsInTemplateExecutionRole + ? new Cfn.Ref(commandInputs.FallbackTaskExecutionRoleName) + : new Cfn.Ref(EcsTemplateParameterNames.TaskExecutionRole); + + return new Cfn.TaskDefinitionProperties + { + 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 + { + OperatingSystemFamily = LinuxOperatingSystemFamily, + CpuArchitecture = commandInputs.CpuArchitecture + }, + Volumes = commandInputs.Volumes.ParseVolumes(), + 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), + 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( + DeployEcsCommandInputs commandInputs, + ContainerSpec c, + Cfn.Value logGroupNameRef, + Cfn.Value awsRegionRef) => new() + { + Name = c.ContainerName, + Image = commandInputs.ResolveImageName(c.ContainerImageReference), + 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.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.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(), + 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(), + 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; + + 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 new file mode 100644 index 000000000..b79819c7b --- /dev/null +++ b/source/Calamari.Aws/Integration/Ecs/Deploy/EcsDeployTemplateGenerator.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Amazon.CloudFormation.Model; +using Calamari.Aws.Inputs.Ecs; +using Newtonsoft.Json; + +namespace Calamari.Aws.Integration.Ecs.Deploy; + +public record GeneratedTemplate(string Body, IReadOnlyList Parameters); + +public class EcsDeployTemplateGenerator(DeployEcsCommandInputs commandInputs) +{ + 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 + }); + + return new GeneratedTemplate( + body, + parameters.Select(p => new Parameter { ParameterKey = p.Name, ParameterValue = p.Value }).ToList()); + } + + 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.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)); + } + + if (!string.IsNullOrEmpty(commandInputs.TaskExecutionRole)) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.TaskExecutionRole, commandInputs.TaskExecutionRole)); + } + + if (commandInputs.DesiredCount != EcsInputDefaults.DesiredCount) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.DesiredCount, commandInputs.DesiredCount)); + } + + if (commandInputs.MinimumHealthyPercentage != EcsInputDefaults.MinimumHealthPercent) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MinimumHealthPercent, commandInputs.MinimumHealthyPercentage)); + } + + if (commandInputs.MaximumHealthyPercentage != EcsInputDefaults.MaximumHealthPercent) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.MaximumHealthPercent, commandInputs.MaximumHealthyPercentage)); + } + + if (commandInputs.RequiresLogGroup) + { + list.Add(EcsTemplateParameter.Of(EcsTemplateParameterNames.LogGroupName, commandInputs.DefaultLogGroupPath)); + } + + return list; + } +} \ No newline at end of file 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.Aws/Integration/Ecs/EcsStackNameGenerator.cs b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs index 1f10ae4bc..2c6695224 100644 --- a/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs +++ b/source/Calamari.Aws/Integration/Ecs/EcsStackNameGenerator.cs @@ -1,29 +1,24 @@ 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.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs b/source/Calamari.Common/Plumbing/Variables/VariablesDeserialisationExtensions.cs index d9de1f3f0..d5b77cee5 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)) diff --git a/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs b/source/Calamari.Tests/AWS/Ecs/DeployEcsServiceFixture.cs deleted file mode 100644 index 19a81bf0b..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, 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); - } -} diff --git a/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs new file mode 100644 index 000000000..574f818a5 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/EcsDeployTemplateGeneratorTests.cs @@ -0,0 +1,195 @@ +using System.IO; +using System.Reflection; +using Calamari.Aws.Deployment; +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; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Tests.AWS.Ecs; + +[TestFixture] +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"); + 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}" }, + { AwsSpecialVariables.Ecs.ClusterName, "TestCluster" }, + { 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" }, + + {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":"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":[]}] + """}, + + + {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"); + + 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" }, + { 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.SecurityGroupIds, """ + ["sg-0d5e06a4bde84daaa"] + """ + }, + { + 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":"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":[]}] + """}, + + + {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(); + } + + [Test] + 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"}, + {"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, fakeEcsImageResolver, fakeLog); + var template = new EcsDeployTemplateGenerator(inputs).Generate(); + 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/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-"); 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..23fa97dcb --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/complexSpfOutputTemplate.json @@ -0,0 +1,247 @@ +{ + "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": { + "AwsLogGrouptestBigCfTemplate": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Ref": "LogGroupName" + } + } + }, + "TaskDefinitiontestBigCfTemplate": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Name": "web-server", + "Image": "index.docker.io/nginx:latest", + "Essential": true, + "DisableNetworking": false, + "WorkingDirectory": "/tmp", + "Memory": 200, + "MemoryReservation": 47, + "Cpu": 2, + "User": "test-user", + "StartTimeout": 40, + "StopTimeout": 60, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": true, + "Command": [ + "echo 'Deployment successful" + ], + "EntryPoint": [ + "sh", + "-c" + ], + "ResourceRequirements": [], + "DockerLabels": { + "some-label": "label-value" + }, + "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 + }, + "ExtraHosts": [], + "Ulimits": [ + { + "Name": "core", + "HardLimit": 12, + "SoftLimit": 10 + } + ], + "MountPoints": [ + { + "SourceVolume": "efs-volume", + "ContainerPath": "/etc", + "ReadOnly": false + } + ], + "VolumesFrom": [ + { + "SourceContainer": "efs-volume", + "ReadOnly": true + } + ], + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" + } + }, + "EnvironmentFiles": [ + { + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + } + ], + "FirelensConfiguration": { + "Type": "fluentd", + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" + } + }, + "Environment": [ + { + "Name": "containerenv", + "Value": "some-otherovalue" + } + ] + } + ], + "Family": { + "Ref": "TaskDefinitionName" + }, + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "TaskRoleArn": { + "Ref": "TaskRole" + }, + "RequiresCompatibilities": [ + "FARGATE" + ], + "NetworkMode": "awsvpc", + "RuntimePlatform": { + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" + }, + "Volumes": [ + { + "Name": "efs-volume", + "EFSVolumeConfiguration": { + "FilesystemId": "efs-fs-id", + "RootDirectory": "/root", + "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" + }, + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestBigCfTemplate" + }, + "DesiredCount": { + "Ref": "DesiredCount" + }, + "EnableECSManagedTags": true, + "DeploymentConfiguration": { + "MinimumHealthyPercent": { + "Ref": "MinimumHealthPercent" + }, + "MaximumPercent": { + "Ref": "MaximumHealthPercent" + } + }, + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "Subnets": [ + "subnet-0650cd8a2119e8xxx" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84dxxx" + ] + } + }, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ] + } + } + } +} \ 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 new file mode 100644 index 000000000..06a63dfe6 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/multiContainerSpfOutputTemplate.json @@ -0,0 +1,266 @@ +{ + "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": [ + { + "Name": "web-server", + "Image": "docker.io/nginx:1.31.1", + "Essential": true, + "DisableNetworking": false, + "WorkingDirectory": "/tmp", + "Memory": 200, + "MemoryReservation": 47, + "Cpu": 2, + "User": "test-user", + "StartTimeout": 40, + "StopTimeout": 60, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": true, + "Command": [ + "echo 'Deployment successful" + ], + "EntryPoint": [ + "sh", + "-c" + ], + "ResourceRequirements": [], + "DockerLabels": { + "some-label": "label-value" + }, + "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 + }, + "ExtraHosts": [], + "Ulimits": [ + { + "Name": "core", + "HardLimit": 12, + "SoftLimit": 10 + } + ], + "MountPoints": [ + { + "SourceVolume": "efs-volume", + "ContainerPath": "/etc", + "ReadOnly": false + } + ], + "VolumesFrom": [ + { + "SourceContainer": "efs-volume", + "ReadOnly": true + } + ], + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" + } + }, + "EnvironmentFiles": [ + { + "Type": "s3", + "Value": "jttestc668db76/test/keyarm-packagev1.0.3.zip" + } + ], + "FirelensConfiguration": { + "Type": "fluentd", + "Options": { + "enable-ecs-log-metadata": "true", + "config-file-type": "file", + "config-file-value": "/home/config" + } + }, + "Environment": [ + { + "Name": "containerenv", + "Value": "some-otherovalue" + } + ] + }, + { + "Name": "cache", + "Image": "docker.io/bitnami/redis:sha256-fd997c4c52c0a0af686e5af2b671f4e3d538d26f28abd3b83a01ce57eea43752.sig", + "Essential": true, + "DisableNetworking": false, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [], + "PortMappings": [], + "HealthCheck": { + "Command": [ + " [ \"CMD-SHELL\", \"curl -f http://localhost/ || exit 1\" ]." + ] + }, + "ExtraHosts": [], + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroupName" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "ecs" + } + }, + "EnvironmentFiles": [] + } + ], + "Family": { + "Ref": "TaskDefinitionName" + }, + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "TaskRoleArn": { + "Ref": "TaskRole" + }, + "RequiresCompatibilities": [ + "FARGATE" + ], + "NetworkMode": "awsvpc", + "RuntimePlatform": { + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" + }, + "Volumes": [ + { + "Name": "efs-volume", + "EFSVolumeConfiguration": { + "FilesystemId": "efs-fs-id", + "RootDirectory": "/root", + "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" + }, + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestMultiContainerTemplate" + }, + "DesiredCount": { + "Ref": "DesiredCount" + }, + "EnableECSManagedTags": true, + "DeploymentConfiguration": { + "MinimumHealthyPercent": 100, + "MaximumPercent": 200 + }, + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "Subnets": [ + "subnet-0650cd8a2119e8aaa" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84daaa" + ] + } + }, + "Tags": [ + { + "Key": "my-tag", + "Value": "a great test value" + } + ] + } + } + } +} \ 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..9a48e8541 --- /dev/null +++ b/source/Calamari.Tests/AWS/Ecs/SpfOutputs/simpleSpfOutputTemplate.json @@ -0,0 +1,138 @@ +{ + "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": [ + { + "Name": "web-server-spf", + "Image": "docker.io/nginx:1.29", + "Essential": true, + "DisableNetworking": false, + "DnsServers": [], + "DnsSearchDomains": [], + "ReadonlyRootFilesystem": false, + "ResourceRequirements": [], + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp" + } + ], + "ExtraHosts": [], + "EnvironmentFiles": [], + "Environment": [ + { + "Name": "env", + "Value": "TestEnvironment" + } + ] + } + ], + "Family": { + "Ref": "TaskDefinitionName" + }, + "Cpu": { + "Ref": "TaskDefinitionCPU" + }, + "Memory": { + "Ref": "TaskDefinitionMemory" + }, + "ExecutionRoleArn": { + "Ref": "TaskExecutionRole" + }, + "TaskRoleArn": { + "Ref": "TaskRole" + }, + "RequiresCompatibilities": [ + "FARGATE" + ], + "NetworkMode": "awsvpc", + "RuntimePlatform": { + "OperatingSystemFamily": "LINUX", + "CpuArchitecture": "X86_64" + }, + "Volumes": [], + "Tags": [ + { + "Key": "owner", + "Value": "spfdeployment" + }, + { + "Key": "createdBy", + "Value": "test-project" + } + ] + } + }, + "ServicetestOctopusSpfdeployedTask": { + "Type": "AWS::ECS::Service", + "DependsOn": "TaskDefinitiontestOctopusSpfdeployedTask", + "Properties": { + "Cluster": { + "Ref": "ClusterName" + }, + "LaunchType": "FARGATE", + "TaskDefinition": { + "Ref": "TaskDefinitiontestOctopusSpfdeployedTask" + }, + "DesiredCount": 1, + "EnableECSManagedTags": false, + "DeploymentConfiguration": { + "MinimumHealthyPercent": 100, + "MaximumPercent": 200 + }, + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "Subnets": [ + "subnet-0650cd8a2119e829c", + "subnet-0067a165dd462cb39" + ], + "SecurityGroups": [ + "sg-0d5e06a4bde84d1d" + ] + } + }, + "Tags": [ + { + "Key": "owner", + "Value": "spfdeployment" + }, + { + "Key": "createdBy", + "Value": "test-project" + } + ] + } + } + } +} \ 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 new file mode 100644 index 000000000..363de1d1a --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/ContainerSpecMappingExtensionsTests.cs @@ -0,0 +1,946 @@ +using System; +using System.Collections.Generic; +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; +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; + +[TestFixture] +public class ContainerSpecMappingExtensionsTests +{ + [Test] + public void ParseMountPoints_WhenNoMountPoints_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseMountPoints(); + + result.Should().BeNull(); + } + + [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_IsFalse() + { + 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().BeFalse(); + } + + [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_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseDependencies(); + + result.Should().BeNull(); + } + + [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_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseVolumesFrom(); + + result.Should().BeNull(); + } + + [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_IsFalse() + { + var spec = new ContainerSpec + { + ContainerStorage = new ContainerStorage + { + VolumeFrom = + [ + new ContainerVolumeFrom + { + SourceContainer = "c", + Readonly = string.Empty + } + ] + } + }; + + var result = spec.ParseVolumesFrom(); + + result[0].ReadOnly.Should().BeFalse(); + } + + [Test] + public void ParseEnvironmentVariables_WhenNone_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseEnvironmentVariables(); + + 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] + 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.Should().Contain(x => x.Name == "LOG_LEVEL" && x.Value == "INFO"); + result.Should().Contain(x => x.Name == "REGION" && x.Value == "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.Should().Contain(x => x.Name == "LOG_LEVEL" && x.Value == "INFO"); + result.Should().Contain(x => x.Name == "REGION" && x.Value == "us-east-1"); + } + + [Test] + public void ParseEnvironmentVariables_ExcludesSecretEntries() + { + 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(1); + result.Should().Contain(x => x.Name == "PLAIN_KEY" && x.Value == "plain-value"); + result.Should().NotContain(x => x.Name =="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.Should().Contain(x => x.Name == "TOKEN" && x.Value == "plain-token"); + } + + [Test] + public void ParseSecrets_WhenNone_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseSecrets(); + + result.Should().BeNull(); + } + + [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] + public void ParseDockerLabels_WhenNone_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseDockerLabels(); + + result.Should().BeNull(); + } + + [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_WhenEmpty_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParsePortMappings(); + + result.Should().BeEmpty(); + } + + [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_ReturnsNull() + { + var spec = new ContainerSpec(); + + var result = spec.ParseULimits(); + + result.Should().BeNull(); + } + + [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_WhenEmpty_ReturnsEmptyArray() + { + var spec = new ContainerSpec(); + + var result = spec.ParseExtraHosts(); + + result.Should().BeEmpty(); + } + + [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"); + } + + const string TestLogGroupRef = "log-group-ref"; + const string TestRegionRef = "region-ref"; + + [Test] + public void ParseLogConfiguration_WhenManualAndLogDriverNull_ReturnsNull() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Manual, + LogDriver = null + } + }; + + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); + + result.Should().BeNull(); + } + + [Test] + public void ParseLogConfiguration_WhenManualAndLogDriverNone_ReturnsNull() + { + var spec = new ContainerSpec + { + ContainerLogging = new ContainerLogging + { + Type = ContainerLoggingType.Manual, + LogDriver = LogDriver.None + } + }; + + var result = spec.ParseLogConfiguration(TestLogGroupRef, TestRegionRef); + + result.Should().BeNull(); + } + + [Test] + 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, // 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().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(TestLogGroupRef, TestRegionRef); + + 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(TestLogGroupRef, TestRegionRef); + + result!.LogDriver.Should().Be("splunk"); + } + + [Test] + public void ParseLogConfiguration_WhenManual_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(TestLogGroupRef, TestRegionRef); + + 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] + 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().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(); + + result!.Options.Should().NotContainKey("config-file-type"); + result.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(); + + 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/DeployEcsCommandInputsFixture.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs new file mode 100644 index 000000000..077101b55 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/DeployEcsCommandInputsFixture.cs @@ -0,0 +1,705 @@ +using System; +using System.Linq; +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 FluentAssertions.Execution; +using NSubstitute; +using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var result = inputs.Validate().IsValid; + + result.Should().BeFalse(); + } + + [Test] + public void Validate_WithAllExpectedVariables_ReturnsTrue() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var clusterName = inputs.ClusterName; + + clusterName.Should().Be(expectedClusterName); + } + + [Test] + public void CfStackName_WhenNotInVariables_ReturnsValue() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var stackName = inputs.Environment; + + stackName.Should().Be(expectedEnvironmentId); + } + + [Test] + public void Tenant_WithNoTenantVariable_ReturnsEmptyString() + { + var variables = MinimumRequiredVariableSet(); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var stackName = inputs.Tenant; + + stackName.Should().Be(expectedTenantId); + } + + [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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var logGroupName = inputs.LogGroupName; + + logGroupName.Should().Be("AwsLogGroupmyNewEcsServiceTask"); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + 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, fakeImageNameResolver, fakeLog); + + var cpu = inputs.Cpu; + + cpu.Should().Be("0.5"); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + 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, fakeImageNameResolver, fakeLog); + + var memory = inputs.Memory; + + memory.Should().Be("0.5"); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + 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, fakeImageNameResolver, fakeLog); + + var architecture = inputs.CpuArchitecture; + + architecture.Should().Be("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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var result = inputs.MaximumHealthyPercentage; + + result.Should().Be(150); + } + + [Test] + public void WaitOption_IsDeserialisedAndReturned() + { + const string waitOptionInput = """{ "type": "waitUntilCompleted" }"""; + var variables = SetupVariable(AwsSpecialVariables.Ecs.WaitOption, waitOptionInput, false); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); + + var result = inputs.WaitOption; + + 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + inputs.ShouldWaitForDeploymentCompletion.Should().BeTrue(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + 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, fakeImageNameResolver, fakeLog); + + var result = inputs.AutoAssignPublicIp; + + result.Should().Be("ENABLED"); + } + + [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, fakeImageNameResolver, fakeLog); + + var result = inputs.EnableEcsManagedTags; + + result.Should().BeTrue(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void NetworkSecurityGroupIds_IsReturnedAsAStringArray(bool useExpression) + { + const string securityGroupsInput = """" + ["sg-0123abcd456789fgh", "sg-abcd1234abcdef567"] + """"; + var variables = SetupVariable(AwsSpecialVariables.Ecs.Deploy.SecurityGroupIds, securityGroupsInput, useExpression); + var inputs = new DeployEcsCommandInputs(variables, fakeStackNameGenerator, fakeImageNameResolver, fakeLog); + + var result = inputs.NetworkSecurityGroupIds; + + result.Length.Should().Be(2); + result.Should().Contain("sg-0123abcd456789fgh"); + result.Should().Contain("sg-abcd1234abcdef567"); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void SubnetIds_IsReturnedAsAStringArray(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, fakeImageNameResolver, fakeLog); + + var result = inputs.SubnetIDs; + + 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, fakeImageNameResolver, fakeLog); + + var roleId = inputs.TaskRole; + + roleId.Should().BeEmpty(); + } + + [Test] + public void TaskExecutionRole_WithValueUnspecified_ReturnsEmptyString() + { + var inputs = new DeployEcsCommandInputs(MinimumRequiredVariableSet(), fakeStackNameGenerator, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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":"#{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, fakeImageNameResolver, 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()); + + } + + } + + [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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var tags = inputs.Tags; + + tags.Should().NotBeNull(); + 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var mappings = inputs.LoadBalancerMappings; + + mappings.Should().NotBeNull(); + 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, 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, fakeImageNameResolver, fakeLog); + + var volumes = inputs.Volumes; + + volumes.Should().NotBeNull(); + volumes.Should().BeEmpty(); + } + + // Test Helpers + 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"] + """}, + + {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":[]}]"""} + + + + }; + } + + 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; + } +} 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..57fb5adf9 --- /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_ReturnsNull() + { + var result = Array.Empty().ToLoadBalancerProperties(); + + result.Should().BeNull(); + } + + [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/TagExtensionMappingTests.cs b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionMappingTests.cs new file mode 100644 index 000000000..16e8d07aa --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/TagExtensionMappingTests.cs @@ -0,0 +1,13 @@ +using NUnit.Framework; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class TagExtensionMappingTests +{ + [Test] + public void ToCloudFormationTags() + { + Assert.IsFalse(false); + } +} \ No newline at end of file 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..ba8dfd2e1 --- /dev/null +++ b/source/Calamari.Tests/AWS/Inputs/Ecs/VolumeMappingExtensionsTests.cs @@ -0,0 +1,133 @@ +using System; +using Calamari.Aws.Inputs.Ecs; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.Aws.Ecs; +using InputVolume = Octopus.Calamari.Contracts.Aws.Ecs.Volume; + +namespace Calamari.Tests.AWS.Inputs.Ecs; + +[TestFixture] +public class VolumeMappingExtensionsTests +{ + [Test] + public void ParseVolumes_WhenEmpty_ReturnsEmptyArray() + { + var result = Array.Empty().ParseVolumes(); + + result.Should().BeEmpty(); + } + + [Test] + public void ParseVolumes_WithBindVolume_MapsNameOnly() + { + var volumes = new[] + { + 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(); + } + + [Test] + public void ParseVolumes_WithEfsVolume_MapsFullEfsConfiguration() + { + var volumes = new[] + { + new InputVolume + { + 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; + efs.Should().NotBeNull(); + efs!.FilesystemId.Should().Be("fs-0123abcd"); + efs.RootDirectory.Should().Be("/data"); + efs.TransitEncryption.Should().Be("ENABLED"); + + efs.AuthorizationConfig.Should().NotBeNull(); + efs.AuthorizationConfig!.Iam.Should().Be("ENABLED"); + efs.AuthorizationConfig.AccessPointId.Should().Be("fsap-0123abcd"); + } + + [Test] + public void ParseVolumes_EfsVolume_DefaultsTransitEncryptionAndIamToDisabled() + { + var volumes = new[] + { + new InputVolume + { + Type = VolumeType.Efs, + Name = "shared-data", + FileSystemId = "fs-0123abcd", + EncryptionInTransit = string.Empty, + EfsIamAuthorization = string.Empty + } + }; + + var result = volumes.ParseVolumes(); + + var efs = result![0].EFSVolumeConfiguration; + efs!.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig!.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 InputVolume + { + Type = VolumeType.Efs, + Name = "shared-data", + FileSystemId = "fs-0123abcd", + EncryptionInTransit = "true", + EfsIamAuthorization = "true" + } + }; + + var result = volumes.ParseVolumes(); + + var efs = result![0].EFSVolumeConfiguration; + efs!.TransitEncryption.Should().Be("DISABLED"); + efs.AuthorizationConfig!.Iam.Should().Be("DISABLED"); + } + + [Test] + public void ParseVolumes_OrdersBindVolumesBeforeEfsVolumes() + { + var volumes = new[] + { + 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[1].Name.Should().Be("bind-2"); + result[2].Name.Should().Be("efs-1"); + result[3].Name.Should().Be("efs-2"); + } +} 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 + 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() {