internal/pkg/manifest/workload_ecs.go (345 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package manifest
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/copilot-cli/internal/pkg/docker/dockerengine"
"gopkg.in/yaml.v3"
)
// Defaults for Firelens configuration.
const (
FirelensContainerName = "firelens_log_router"
defaultFluentbitImage = "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable"
)
// Platform related settings.
const (
OSLinux = dockerengine.OSLinux
OSWindows = dockerengine.OSWindows
OSWindowsServer2019Core = "windows_server_2019_core"
OSWindowsServer2019Full = "windows_server_2019_full"
OSWindowsServer2022Core = "windows_server_2022_core"
OSWindowsServer2022Full = "windows_server_2022_full"
ArchAMD64 = dockerengine.ArchAMD64
ArchX86 = dockerengine.ArchX86
ArchARM = dockerengine.ArchARM
ArchARM64 = dockerengine.ArchARM64
// Minimum CPU and mem values required for Windows-based tasks.
MinWindowsTaskCPU = 1024
MinWindowsTaskMemory = 2048
// deployment strategies
ECSDefaultRollingUpdateStrategy = "default"
ECSRecreateRollingUpdateStrategy = "recreate"
)
// Platform related settings.
var (
defaultPlatform = platformString(OSLinux, ArchAMD64)
windowsOSFamilies = []string{OSWindows, OSWindowsServer2019Core, OSWindowsServer2019Full, OSWindowsServer2022Core, OSWindowsServer2022Full}
validShortPlatforms = []string{ // All of the os/arch combinations that the PlatformString field may accept.
dockerengine.PlatformString(OSLinux, ArchAMD64),
dockerengine.PlatformString(OSLinux, ArchX86),
dockerengine.PlatformString(OSLinux, ArchARM),
dockerengine.PlatformString(OSLinux, ArchARM64),
dockerengine.PlatformString(OSWindows, ArchAMD64),
dockerengine.PlatformString(OSWindows, ArchX86),
}
validAdvancedPlatforms = []PlatformArgs{ // All of the OsFamily/Arch combinations that the PlatformArgs field may accept.
{OSFamily: aws.String(OSLinux), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSLinux), Arch: aws.String(ArchAMD64)},
{OSFamily: aws.String(OSLinux), Arch: aws.String(ArchARM)},
{OSFamily: aws.String(OSLinux), Arch: aws.String(ArchARM64)},
{OSFamily: aws.String(OSWindows), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSWindows), Arch: aws.String(ArchAMD64)},
{OSFamily: aws.String(OSWindowsServer2019Core), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSWindowsServer2019Core), Arch: aws.String(ArchAMD64)},
{OSFamily: aws.String(OSWindowsServer2019Full), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSWindowsServer2019Full), Arch: aws.String(ArchAMD64)},
{OSFamily: aws.String(OSWindowsServer2022Core), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSWindowsServer2022Core), Arch: aws.String(ArchAMD64)},
{OSFamily: aws.String(OSWindowsServer2022Full), Arch: aws.String(ArchX86)},
{OSFamily: aws.String(OSWindowsServer2022Full), Arch: aws.String(ArchAMD64)},
}
)
// ImageWithHealthcheck represents a container image with health check.
type ImageWithHealthcheck struct {
Image Image `yaml:",inline"`
HealthCheck ContainerHealthCheck `yaml:"healthcheck"`
}
// ImageWithPortAndHealthcheck represents a container image with an exposed port and health check.
type ImageWithPortAndHealthcheck struct {
ImageWithPort `yaml:",inline"`
HealthCheck ContainerHealthCheck `yaml:"healthcheck"`
}
// AlarmArgs represents specs of CloudWatch alarms for deployment rollbacks.
type AlarmArgs struct {
CPUUtilization *float64 `yaml:"cpu_utilization"`
MemoryUtilization *float64 `yaml:"memory_utilization"`
}
// WorkerAlarmArgs represents specs of CloudWatch alarms for Worker Service deployment rollbacks.
type WorkerAlarmArgs struct {
AlarmArgs `yaml:",inline"`
MessagesDelayed *int `yaml:"messages_delayed"`
}
// DeploymentControllerConfig represents deployment strategies for a service.
type DeploymentControllerConfig struct {
Rolling *string `yaml:"rolling"`
}
// DeploymentConfig represents the deployment config for an ECS service.
type DeploymentConfig struct {
DeploymentControllerConfig `yaml:",inline"`
RollbackAlarms Union[[]string, AlarmArgs] `yaml:"rollback_alarms"`
}
// WorkerDeploymentConfig represents the deployment strategies for a worker service.
type WorkerDeploymentConfig struct {
DeploymentControllerConfig `yaml:",inline"`
WorkerRollbackAlarms Union[[]string, WorkerAlarmArgs] `yaml:"rollback_alarms"`
}
func (d *DeploymentConfig) isEmpty() bool {
return d == nil || (d.DeploymentControllerConfig.isEmpty() && d.RollbackAlarms.IsZero())
}
func (d *DeploymentControllerConfig) isEmpty() bool {
return d.Rolling == nil
}
func (w *WorkerDeploymentConfig) isEmpty() bool {
return w == nil || (w.DeploymentControllerConfig.Rolling == nil && w.WorkerRollbackAlarms.IsZero())
}
// ExposedPort will hold the port mapping configuration.
type ExposedPort struct {
ContainerName string // The name of the container that exposes this port.
Port uint16 // The port number.
Protocol string // Either "tcp" or "udp", empty means the default value that the underlying service provides.
isDefinedByContainer bool // Defines if the container port is exposed from "image.port" or "sidecar.port". defaults to false.
}
// ImageWithHealthcheckAndOptionalPort represents a container image with an optional exposed port and health check.
type ImageWithHealthcheckAndOptionalPort struct {
ImageWithOptionalPort `yaml:",inline"`
HealthCheck ContainerHealthCheck `yaml:"healthcheck"`
}
// ImageWithOptionalPort represents a container image with an optional exposed port.
type ImageWithOptionalPort struct {
Image Image `yaml:",inline"`
Port *uint16 `yaml:"port"`
}
// TaskConfig represents the resource boundaries and environment variables for the containers in the task.
type TaskConfig struct {
CPU *int `yaml:"cpu"`
Memory *int `yaml:"memory"`
Platform PlatformArgsOrString `yaml:"platform,omitempty"`
Count Count `yaml:"count"`
ExecuteCommand ExecuteCommand `yaml:"exec"`
Variables map[string]Variable `yaml:"variables"`
EnvFile *string `yaml:"env_file"`
Secrets map[string]Secret `yaml:"secrets"`
Storage Storage `yaml:"storage"`
}
// Variable represents an identifier for the value of an environment variable.
type Variable struct {
StringOrFromCFN
}
// UnmarshalYAML implements the yaml.Unmarshaler (v3) interface to override the default YAML unmarshalling logic.
func (v *Variable) UnmarshalYAML(value *yaml.Node) error {
if err := v.StringOrFromCFN.UnmarshalYAML(value); err != nil {
return fmt.Errorf(`unmarshal "variables": %w`, err)
}
return nil
}
// RequiresImport returns true if the value is imported from an environment.
func (v *Variable) RequiresImport() bool {
return !v.FromCFN.isEmpty()
}
// Value returns the value, whether it is used for import or not.
func (v *Variable) Value() string {
if v.RequiresImport() {
return aws.StringValue(v.FromCFN.Name)
}
return aws.StringValue(v.Plain)
}
// ContainerPlatform returns the platform for the service.
func (t *TaskConfig) ContainerPlatform() string {
if t.Platform.IsEmpty() {
return ""
}
if t.IsWindows() {
return platformString(OSWindows, t.Platform.Arch())
}
return platformString(t.Platform.OS(), t.Platform.Arch())
}
// IsWindows returns whether or not the service is building with a Windows OS.
func (t TaskConfig) IsWindows() bool {
return isWindowsPlatform(t.Platform)
}
// Secret represents an identifier for sensitive data stored in either SSM or SecretsManager.
type Secret struct {
from StringOrFromCFN // SSM Parameter name or ARN to a secret or secret ARN imported from another CloudFormation stack.
fromSecretsManager secretsManagerSecret // Conveniently fetch from a secretsmanager secret name instead of ARN.
}
// UnmarshalYAML implements the yaml.Unmarshaler (v3) interface to override the default YAML unmarshaling logic.
func (s *Secret) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&s.fromSecretsManager); err != nil {
switch err.(type) {
case *yaml.TypeError:
break
default:
return err
}
}
if !s.fromSecretsManager.IsEmpty() { // Successfully unmarshaled to a secretsmanager name.
return nil
}
if err := value.Decode(&s.from); err != nil { // Otherwise, try decoding the simple form.
return errors.New(`cannot marshal "secret" field to a string or "secretsmanager" object`)
}
return nil
}
// IsSecretsManagerName returns true if the secret refers to the name of a secret stored in SecretsManager.
func (s *Secret) IsSecretsManagerName() bool {
return !s.fromSecretsManager.IsEmpty()
}
// RequiresImport returns true if the SSM parameter name or secret ARN value is imported from CloudFormation stack.
func (s *Secret) RequiresImport() bool {
return !s.from.FromCFN.isEmpty()
}
// Value returns the secret value provided by clients.
func (s *Secret) Value() string {
if !s.fromSecretsManager.IsEmpty() {
return aws.StringValue(s.fromSecretsManager.Name)
} else if s.RequiresImport() {
return aws.StringValue(s.from.FromCFN.Name)
}
return aws.StringValue(s.from.Plain)
}
// secretsManagerSecret represents the name of a secret stored in SecretsManager.
type secretsManagerSecret struct {
Name *string `yaml:"secretsmanager"`
}
// IsEmpty returns true if all the fields in secretsManagerSecret have the zero value.
func (s secretsManagerSecret) IsEmpty() bool {
return s.Name == nil
}
// Logging holds configuration for Firelens to route your logs.
type Logging struct {
Retention *int `yaml:"retention"`
Image *string `yaml:"image"`
Destination map[string]string `yaml:"destination,flow"`
EnableMetadata *bool `yaml:"enableMetadata"`
SecretOptions map[string]Secret `yaml:"secretOptions"`
ConfigFile *string `yaml:"configFilePath"`
Variables map[string]Variable `yaml:"variables"`
Secrets map[string]Secret `yaml:"secrets"`
EnvFile *string `yaml:"env_file"`
}
// IsEmpty returns empty if the struct has all zero members.
func (lc *Logging) IsEmpty() bool {
return lc.Image == nil && lc.Destination == nil && lc.EnableMetadata == nil && lc.SecretOptions == nil &&
lc.ConfigFile == nil && lc.Variables == nil && lc.Secrets == nil && lc.EnvFile == nil
}
// LogImage returns the default Fluent Bit image if not otherwise configured.
func (lc *Logging) LogImage() *string {
if lc.Image == nil {
return aws.String(defaultFluentbitImage)
}
return lc.Image
}
// GetEnableMetadata returns the configuration values and sane default for the EnableMEtadata field
func (lc *Logging) GetEnableMetadata() *string {
if lc.EnableMetadata == nil {
// Enable ecs log metadata by default.
return aws.String("true")
}
return aws.String(strconv.FormatBool(*lc.EnableMetadata))
}
// SidecarConfig represents the configurable options for setting up a sidecar container.
type SidecarConfig struct {
Port *string `yaml:"port"`
Image Union[*string, ImageLocationOrBuild] `yaml:"image"`
Essential *bool `yaml:"essential"`
CredsParam *string `yaml:"credentialsParameter"`
Variables map[string]Variable `yaml:"variables"`
EnvFile *string `yaml:"env_file"`
Secrets map[string]Secret `yaml:"secrets"`
MountPoints []SidecarMountPoint `yaml:"mount_points"`
DockerLabels map[string]string `yaml:"labels"`
DependsOn DependsOn `yaml:"depends_on"`
HealthCheck ContainerHealthCheck `yaml:"healthcheck"`
ImageOverride `yaml:",inline"`
}
// ImageURI returns the location of the image if one is set.
// If the image needs to be build, return "" and false.
func (cfg *SidecarConfig) ImageURI() (string, bool) {
if cfg.Image.Basic != nil {
return aws.StringValue(cfg.Image.Basic), true
}
if cfg.Image.Advanced.Location != nil {
return aws.StringValue(cfg.Image.Advanced.Location), true
}
return "", false
}
// OverrideRule holds the manifest overriding rule for CloudFormation template.
type OverrideRule struct {
Path string `yaml:"path"`
Value yaml.Node `yaml:"value"`
}
// ExecuteCommand is a custom type which supports unmarshaling yaml which
// can either be of type bool or type ExecuteCommandConfig.
type ExecuteCommand struct {
Enable *bool
Config ExecuteCommandConfig
}
// UnmarshalYAML overrides the default YAML unmarshaling logic for the ExecuteCommand
// struct, allowing it to perform more complex unmarshaling behavior.
// This method implements the yaml.Unmarshaler (v3) interface.
func (e *ExecuteCommand) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&e.Config); err != nil {
switch err.(type) {
case *yaml.TypeError:
break
default:
return err
}
}
if !e.Config.IsEmpty() {
return nil
}
if err := value.Decode(&e.Enable); err != nil {
return errUnmarshalExec
}
return nil
}
// ExecuteCommandConfig represents the configuration for ECS Execute Command.
type ExecuteCommandConfig struct {
Enable *bool `yaml:"enable"`
}
// IsEmpty returns whether ExecuteCommandConfig is empty.
func (e ExecuteCommandConfig) IsEmpty() bool {
return e.Enable == nil
}
// ContainerHealthCheck holds the configuration to determine if the service container is healthy.
// See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ecs-taskdefinition-healthcheck.html
type ContainerHealthCheck struct {
Command []string `yaml:"command"`
Interval *time.Duration `yaml:"interval"`
Retries *int `yaml:"retries"`
Timeout *time.Duration `yaml:"timeout"`
StartPeriod *time.Duration `yaml:"start_period"`
}
// NewDefaultContainerHealthCheck returns container health check configuration
// that's identical to a load balanced web service's defaults.
func NewDefaultContainerHealthCheck() *ContainerHealthCheck {
return &ContainerHealthCheck{
Command: []string{"CMD-SHELL", "curl -f http://localhost/ || exit 1"},
Interval: durationp(10 * time.Second),
Retries: aws.Int(2),
Timeout: durationp(5 * time.Second),
StartPeriod: durationp(0 * time.Second),
}
}
// IsEmpty checks if the health check is empty.
func (hc ContainerHealthCheck) IsEmpty() bool {
return hc.Command == nil && hc.Interval == nil && hc.Retries == nil && hc.Timeout == nil && hc.StartPeriod == nil
}
// ApplyIfNotSet changes the healthcheck's fields only if they were not set and the other healthcheck has them set.
func (hc *ContainerHealthCheck) ApplyIfNotSet(other *ContainerHealthCheck) {
if hc.Command == nil && other.Command != nil {
hc.Command = other.Command
}
if hc.Interval == nil && other.Interval != nil {
hc.Interval = other.Interval
}
if hc.Retries == nil && other.Retries != nil {
hc.Retries = other.Retries
}
if hc.Timeout == nil && other.Timeout != nil {
hc.Timeout = other.Timeout
}
if hc.StartPeriod == nil && other.StartPeriod != nil {
hc.StartPeriod = other.StartPeriod
}
}
func envFiles(name *string, tc TaskConfig, lc Logging, sc map[string]*SidecarConfig) map[string]string {
envFiles := make(map[string]string)
// Grab the workload container's env file, if present.
envFiles[aws.StringValue(name)] = aws.StringValue(tc.EnvFile)
// Grab sidecar env files, if present.
for sidecarName, sidecar := range sc {
envFiles[sidecarName] = aws.StringValue(sidecar.EnvFile)
}
// If the Firelens Sidecar Pattern has an env file specified, get it as well.
envFiles[FirelensContainerName] = aws.StringValue(lc.EnvFile)
return envFiles
}
func buildArgs(contextDir string, buildArgs map[string]*DockerBuildArgs, sc map[string]*SidecarConfig) (map[string]*DockerBuildArgs, error) {
for name, config := range sc {
if _, ok := config.ImageURI(); !ok {
buildArgs[name] = config.Image.Advanced.BuildConfig(contextDir)
}
}
return buildArgs, nil
}
// ContainerDependency represents order of container startup and shutdown.
// Also indicates if a container is marked as essential or not.
type ContainerDependency struct {
IsEssential bool
DependsOn DependsOn
}
func containerDependencies(name string, img Image, lc Logging, sc map[string]*SidecarConfig) map[string]ContainerDependency {
containerDependencies := make(map[string]ContainerDependency)
containerDependencies[name] = ContainerDependency{
DependsOn: img.DependsOn,
IsEssential: true,
}
if !lc.IsEmpty() {
containerDependencies[FirelensContainerName] = ContainerDependency{}
}
for name, config := range sc {
containerDependencies[name] = ContainerDependency{
DependsOn: config.DependsOn,
IsEssential: config.Essential == nil || aws.BoolValue(config.Essential),
}
}
return containerDependencies
}