internal/pkg/manifest/env.go (417 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"
"sort"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/copilot-cli/internal/pkg/config"
"github.com/aws/copilot-cli/internal/pkg/template"
"gopkg.in/yaml.v3"
)
// Environmentmanifestinfo identifies that the type of manifest is environment manifest.
const Environmentmanifestinfo = "Environment"
var environmentManifestPath = "environment/manifest.yml"
// Error definitions.
var (
errUnmarshalPortsConfig = errors.New(`unable to unmarshal ports field into int or a range`)
errUnmarshalEnvironmentCDNConfig = errors.New(`unable to unmarshal cdn field into bool or composite-style map`)
errUnmarshalELBAccessLogs = errors.New(`unable to unmarshal access_logs field into bool or ELB Access logs config`)
)
// Environment is the manifest configuration for an environment.
type Environment struct {
Workload `yaml:",inline"`
EnvironmentConfig `yaml:",inline"`
parser template.Parser
}
// EnvironmentProps contains properties for creating a new environment manifest.
type EnvironmentProps struct {
Name string
CustomConfig *config.CustomizeEnv
Telemetry *config.Telemetry
}
// NewEnvironment creates a new environment manifest object.
func NewEnvironment(props *EnvironmentProps) *Environment {
return FromEnvConfig(&config.Environment{
Name: props.Name,
CustomConfig: props.CustomConfig,
Telemetry: props.Telemetry,
}, template.New())
}
// FromEnvConfig transforms an environment configuration into a manifest.
func FromEnvConfig(cfg *config.Environment, parser template.Parser) *Environment {
var vpc environmentVPCConfig
vpc.loadVPCConfig(cfg.CustomConfig)
var http EnvironmentHTTPConfig
http.loadLBConfig(cfg.CustomConfig)
var obs environmentObservability
obs.loadObsConfig(cfg.Telemetry)
return &Environment{
Workload: Workload{
Name: stringP(cfg.Name),
Type: stringP(Environmentmanifestinfo),
},
EnvironmentConfig: EnvironmentConfig{
Network: environmentNetworkConfig{
VPC: vpc,
},
HTTPConfig: http,
Observability: obs,
},
parser: parser,
}
}
// MarshalBinary serializes the manifest object into a binary YAML document.
// Implements the encoding.BinaryMarshaler interface.
func (e *Environment) MarshalBinary() ([]byte, error) {
content, err := e.parser.Parse(environmentManifestPath, *e, template.WithFuncs(map[string]interface{}{
"fmtStringSlice": template.FmtSliceFunc,
}))
if err != nil {
return nil, err
}
return content.Bytes(), nil
}
// EnvironmentConfig defines the configuration settings for an environment manifest
type EnvironmentConfig struct {
Network environmentNetworkConfig `yaml:"network,omitempty,flow"`
Observability environmentObservability `yaml:"observability,omitempty,flow"`
HTTPConfig EnvironmentHTTPConfig `yaml:"http,omitempty,flow"`
CDNConfig EnvironmentCDNConfig `yaml:"cdn,omitempty,flow"`
}
// IsPublicLBIngressRestrictedToCDN returns whether an environment has its
// Public Load Balancer ingress restricted to a Content Delivery Network.
func (mft *EnvironmentConfig) IsPublicLBIngressRestrictedToCDN() bool {
// Check the fixed manifest first. This would be `http.public.ingress.cdn`.
// For more information, see https://github.com/aws/copilot-cli/pull/4068#issuecomment-1275080333
if !mft.HTTPConfig.Public.Ingress.IsEmpty() {
return aws.BoolValue(mft.HTTPConfig.Public.Ingress.CDNIngress)
}
// Fall through to the old manifest: `http.public.security_groups.ingress.cdn`.
return aws.BoolValue(mft.HTTPConfig.Public.DeprecatedSG.DeprecatedIngress.RestrictiveIngress.CDNIngress)
}
// GetPublicALBSourceIPs returns list of IPNet.
func (mft *EnvironmentConfig) GetPublicALBSourceIPs() []IPNet {
return mft.HTTPConfig.Public.Ingress.SourceIPs
}
type environmentNetworkConfig struct {
VPC environmentVPCConfig `yaml:"vpc,omitempty"`
}
type environmentVPCConfig struct {
ID *string `yaml:"id,omitempty"`
CIDR *IPNet `yaml:"cidr,omitempty"`
Subnets subnetsConfiguration `yaml:"subnets,omitempty"`
SecurityGroupConfig securityGroupConfig `yaml:"security_group,omitempty"`
FlowLogs Union[*bool, VPCFlowLogsArgs] `yaml:"flow_logs,omitempty"`
}
type securityGroupConfig struct {
Ingress []securityGroupRule `yaml:"ingress,omitempty"`
Egress []securityGroupRule `yaml:"egress,omitempty"`
}
func (cfg securityGroupConfig) isEmpty() bool {
return len(cfg.Ingress) == 0 && len(cfg.Egress) == 0
}
// securityGroupRule holds the security group ingress and egress configs.
type securityGroupRule struct {
CidrIP string `yaml:"cidr"`
Ports portsConfig `yaml:"ports"`
IpProtocol string `yaml:"ip_protocol"`
}
// portsConfig represents a range of ports [from:to] inclusive.
// The simple form allow represents from and to ports as a single value, whereas the advanced form is for different values.
type portsConfig struct {
Port *int // 0 is a valid value, so we want the default value to be nil.
Range *IntRangeBand // Mutually exclusive with port.
}
// IsEmpty returns whether PortsConfig is empty.
func (cfg *portsConfig) IsEmpty() bool {
return cfg.Port == nil && cfg.Range == nil
}
// GetPorts returns the from and to ports of a security group rule.
func (r securityGroupRule) GetPorts() (from, to int, err error) {
if r.Ports.Range == nil {
return aws.IntValue(r.Ports.Port), aws.IntValue(r.Ports.Port), nil // a single value is provided for ports.
}
return r.Ports.Range.Parse()
}
// UnmarshalYAML overrides the default YAML unmarshaling logic for the Ports
// struct, allowing it to perform more complex unmarshaling behavior.
// This method implements the yaml.Unmarshaler (v3) interface.
func (cfg *portsConfig) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&cfg.Port); err != nil {
switch err.(type) {
case *yaml.TypeError:
cfg.Port = nil
default:
return err
}
}
if cfg.Port != nil {
// Successfully unmarshalled Port field and unset Ports field, return
cfg.Range = nil
return nil
}
if err := value.Decode(&cfg.Range); err != nil {
return errUnmarshalPortsConfig
}
return nil
}
// VPCFlowLogsArgs holds the flow logs configuration.
type VPCFlowLogsArgs struct {
Retention *int `yaml:"retention,omitempty"`
}
// IsZero implements yaml.IsZeroer.
func (fl *VPCFlowLogsArgs) IsZero() bool {
return fl.Retention == nil
}
// EnvSecurityGroup returns the security group config if the user has set any values.
// If there is no env security group settings, then returns nil and false.
func (cfg *EnvironmentConfig) EnvSecurityGroup() (*securityGroupConfig, bool) {
if isEmpty := cfg.Network.VPC.SecurityGroupConfig.isEmpty(); !isEmpty {
return &cfg.Network.VPC.SecurityGroupConfig, true
}
return nil, false
}
// EnvironmentCDNConfig represents configuration of a CDN.
type EnvironmentCDNConfig struct {
Enabled *bool
Config AdvancedCDNConfig // mutually exclusive with Enabled
}
// AdvancedCDNConfig represents an advanced configuration for a Content Delivery Network.
type AdvancedCDNConfig struct {
Certificate *string `yaml:"certificate,omitempty"`
TerminateTLS *bool `yaml:"terminate_tls,omitempty"`
Static CDNStaticConfig `yaml:"static_assets,omitempty"`
}
// IsEmpty returns whether environmentCDNConfig is empty.
func (cfg *EnvironmentCDNConfig) IsEmpty() bool {
return cfg.Enabled == nil && cfg.Config.isEmpty()
}
// isEmpty returns whether advancedCDNConfig is empty.
func (cfg *AdvancedCDNConfig) isEmpty() bool {
return cfg.Certificate == nil && cfg.TerminateTLS == nil && cfg.Static.IsEmpty()
}
// CDNEnabled returns whether a CDN configuration has been enabled in the environment manifest.
func (cfg *EnvironmentConfig) CDNEnabled() bool {
if !cfg.CDNConfig.Config.isEmpty() {
return true
}
return aws.BoolValue(cfg.CDNConfig.Enabled)
}
// HasImportedPublicALBCerts returns true when the environment's ALB
// is configured with certs for the public listener.
func (cfg *EnvironmentConfig) HasImportedPublicALBCerts() bool {
return cfg.HTTPConfig.Public.Certificates != nil
}
// CDNDoesTLSTermination returns true when the environment's CDN
// is configured to terminate incoming TLS connections.
func (cfg *EnvironmentConfig) CDNDoesTLSTermination() bool {
return aws.BoolValue(cfg.CDNConfig.Config.TerminateTLS)
}
// UnmarshalYAML overrides the default YAML unmarshaling logic for the environmentCDNConfig
// struct, allowing it to perform more complex unmarshaling behavior.
// This method implements the yaml.Unmarshaler (v3) interface.
func (cfg *EnvironmentCDNConfig) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&cfg.Config); err != nil {
var yamlTypeErr *yaml.TypeError
if !errors.As(err, &yamlTypeErr) {
return err
}
}
if !cfg.Config.isEmpty() {
// Successfully unmarshalled CDNConfig fields, return
return nil
}
if err := value.Decode(&cfg.Enabled); err != nil {
return errUnmarshalEnvironmentCDNConfig
}
return nil
}
// CDNStaticConfig represents the static config for CDN.
type CDNStaticConfig struct {
Location string `yaml:"location,omitempty"`
Alias string `yaml:"alias,omitempty"`
Path string `yaml:"path,omitempty"`
}
// IsEmpty returns true if CDNStaticConfig is not configured.
func (cfg CDNStaticConfig) IsEmpty() bool {
return cfg.Location == "" && cfg.Alias == "" && cfg.Path == ""
}
// IsEmpty returns true if environmentVPCConfig is not configured.
func (cfg environmentVPCConfig) IsEmpty() bool {
return cfg.ID == nil && cfg.CIDR == nil && cfg.Subnets.IsEmpty() && cfg.FlowLogs.IsZero()
}
func (cfg *environmentVPCConfig) loadVPCConfig(env *config.CustomizeEnv) {
if env.IsEmpty() {
return
}
if adjusted := env.VPCConfig; adjusted != nil {
cfg.loadAdjustedVPCConfig(adjusted)
}
if imported := env.ImportVPC; imported != nil {
cfg.loadImportedVPCConfig(imported)
}
}
func (cfg *environmentVPCConfig) loadAdjustedVPCConfig(vpc *config.AdjustVPC) {
cfg.CIDR = ipNetP(vpc.CIDR)
cfg.Subnets.Public = make([]subnetConfiguration, len(vpc.PublicSubnetCIDRs))
cfg.Subnets.Private = make([]subnetConfiguration, len(vpc.PrivateSubnetCIDRs))
for i, cidr := range vpc.PublicSubnetCIDRs {
cfg.Subnets.Public[i].CIDR = ipNetP(cidr)
if len(vpc.AZs) > i {
cfg.Subnets.Public[i].AZ = stringP(vpc.AZs[i])
}
}
for i, cidr := range vpc.PrivateSubnetCIDRs {
cfg.Subnets.Private[i].CIDR = ipNetP(cidr)
if len(vpc.AZs) > i {
cfg.Subnets.Private[i].AZ = stringP(vpc.AZs[i])
}
}
}
func (cfg *environmentVPCConfig) loadImportedVPCConfig(vpc *config.ImportVPC) {
cfg.ID = stringP(vpc.ID)
cfg.Subnets.Public = make([]subnetConfiguration, len(vpc.PublicSubnetIDs))
for i, subnet := range vpc.PublicSubnetIDs {
cfg.Subnets.Public[i].SubnetID = stringP(subnet)
}
cfg.Subnets.Private = make([]subnetConfiguration, len(vpc.PrivateSubnetIDs))
for i, subnet := range vpc.PrivateSubnetIDs {
cfg.Subnets.Private[i].SubnetID = stringP(subnet)
}
}
// UnmarshalEnvironment deserializes the YAML input stream into an environment manifest object.
// If an error occurs during deserialization, then returns the error.
func UnmarshalEnvironment(in []byte) (*Environment, error) {
var m Environment
if err := yaml.Unmarshal(in, &m); err != nil {
return nil, fmt.Errorf("unmarshal environment manifest: %w", err)
}
return &m, nil
}
func (cfg *environmentVPCConfig) imported() bool {
return aws.StringValue(cfg.ID) != ""
}
func (cfg *environmentVPCConfig) managedVPCCustomized() bool {
return aws.StringValue((*string)(cfg.CIDR)) != ""
}
// ImportedVPC returns configurations that import VPC resources if there is any.
func (cfg *environmentVPCConfig) ImportedVPC() *template.ImportVPC {
if !cfg.imported() {
return nil
}
var publicSubnetIDs, privateSubnetIDs []string
for _, subnet := range cfg.Subnets.Public {
publicSubnetIDs = append(publicSubnetIDs, aws.StringValue(subnet.SubnetID))
}
for _, subnet := range cfg.Subnets.Private {
privateSubnetIDs = append(privateSubnetIDs, aws.StringValue(subnet.SubnetID))
}
return &template.ImportVPC{
ID: aws.StringValue(cfg.ID),
PublicSubnetIDs: publicSubnetIDs,
PrivateSubnetIDs: privateSubnetIDs,
}
}
// ManagedVPC returns configurations that configure VPC resources if there is any.
func (cfg *environmentVPCConfig) ManagedVPC() *template.ManagedVPC {
// ASSUMPTION: If the VPC is configured, both pub and private are explicitly configured.
// az is optional. However, if it's configured, it is configured for all subnets.
// In summary:
// 0 = #pub = #priv = #azs (not managed)
// #pub = #priv, #azs = 0 (managed, without configured azs)
// #pub = #priv = #azs (managed, all configured)
if !cfg.managedVPCCustomized() {
return nil
}
publicSubnetCIDRs := make([]string, len(cfg.Subnets.Public))
privateSubnetCIDRs := make([]string, len(cfg.Subnets.Public))
var azs []string
// NOTE: sort based on `az`s to preserve the mappings between azs and public subnets, private subnets.
// For example, if we have two subnets defined: public-subnet-1 ~ us-east-1a, and private-subnet-1 ~ us-east-1a.
// We want to make sure that public-subnet-1, us-east-1a and private-subnet-1 are all at index 0 of in perspective lists.
sort.SliceStable(cfg.Subnets.Public, func(i, j int) bool {
return aws.StringValue(cfg.Subnets.Public[i].AZ) < aws.StringValue(cfg.Subnets.Public[j].AZ)
})
sort.SliceStable(cfg.Subnets.Private, func(i, j int) bool {
return aws.StringValue(cfg.Subnets.Private[i].AZ) < aws.StringValue(cfg.Subnets.Private[j].AZ)
})
for idx, subnet := range cfg.Subnets.Public {
publicSubnetCIDRs[idx] = aws.StringValue((*string)(subnet.CIDR))
privateSubnetCIDRs[idx] = aws.StringValue((*string)(cfg.Subnets.Private[idx].CIDR))
if az := aws.StringValue(subnet.AZ); az != "" {
azs = append(azs, az)
}
}
return &template.ManagedVPC{
CIDR: aws.StringValue((*string)(cfg.CIDR)),
AZs: azs,
PublicSubnetCIDRs: publicSubnetCIDRs,
PrivateSubnetCIDRs: privateSubnetCIDRs,
}
}
type subnetsConfiguration struct {
Public []subnetConfiguration `yaml:"public,omitempty"`
Private []subnetConfiguration `yaml:"private,omitempty"`
}
// IsEmpty returns true if neither public subnets nor private subnets are configured.
func (cs subnetsConfiguration) IsEmpty() bool {
return len(cs.Public) == 0 && len(cs.Private) == 0
}
type subnetConfiguration struct {
SubnetID *string `yaml:"id,omitempty"`
CIDR *IPNet `yaml:"cidr,omitempty"`
AZ *string `yaml:"az,omitempty"`
}
type environmentObservability struct {
ContainerInsights *bool `yaml:"container_insights,omitempty"`
}
// IsEmpty returns true if there is no configuration to the environment's observability.
func (o *environmentObservability) IsEmpty() bool {
return o == nil || o.ContainerInsights == nil
}
func (o *environmentObservability) loadObsConfig(tele *config.Telemetry) {
if tele == nil {
return
}
o.ContainerInsights = &tele.EnableContainerInsights
}
// EnvironmentHTTPConfig defines the configuration settings for an environment group's HTTP connections.
type EnvironmentHTTPConfig struct {
Public PublicHTTPConfig `yaml:"public,omitempty"`
Private privateHTTPConfig `yaml:"private,omitempty"`
}
// IsEmpty returns true if neither the public ALB nor the internal ALB is configured.
func (cfg EnvironmentHTTPConfig) IsEmpty() bool {
return cfg.Public.IsEmpty() && cfg.Private.IsEmpty()
}
func (cfg *EnvironmentHTTPConfig) loadLBConfig(env *config.CustomizeEnv) {
if env.IsEmpty() {
return
}
if env.ImportVPC != nil && len(env.ImportVPC.PublicSubnetIDs) == 0 {
cfg.Private.InternalALBSubnets = env.InternalALBSubnets
cfg.Private.Certificates = env.ImportCertARNs
if env.EnableInternalALBVPCIngress { // NOTE: Do not load the configuration unless it's positive, so that the default manifest does not contain the unnecessary line `http.private.ingress.vpc: false`.
cfg.Private.Ingress.VPCIngress = aws.Bool(true)
}
return
}
cfg.Public.Certificates = env.ImportCertARNs
}
// PublicHTTPConfig represents the configuration settings for an environment public ALB.
type PublicHTTPConfig struct {
DeprecatedSG DeprecatedALBSecurityGroupsConfig `yaml:"security_groups,omitempty"` // Deprecated. This configuration is now available inside Ingress field.
Certificates []string `yaml:"certificates,omitempty"`
ELBAccessLogs ELBAccessLogsArgsOrBool `yaml:"access_logs,omitempty"`
Ingress RestrictiveIngress `yaml:"ingress,omitempty"`
SSLPolicy *string `yaml:"ssl_policy,omitempty"`
}
// ELBAccessLogsArgsOrBool is a custom type which supports unmarshaling yaml which
// can either be of type bool or type ELBAccessLogsArgs.
type ELBAccessLogsArgsOrBool struct {
Enabled *bool
AdvancedConfig ELBAccessLogsArgs
}
func (al *ELBAccessLogsArgsOrBool) isEmpty() bool {
return al.Enabled == nil && al.AdvancedConfig.isEmpty()
}
// UnmarshalYAML overrides the default YAML unmarshaling logic for the ELBAccessLogsArgsOrBool
// struct, allowing it to perform more complex unmarshaling behavior.
// This method implements the yaml.Unmarshaler (v3) interface.
func (al *ELBAccessLogsArgsOrBool) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&al.AdvancedConfig); err != nil {
switch err.(type) {
case *yaml.TypeError:
break
default:
return err
}
}
if !al.AdvancedConfig.isEmpty() {
// Unmarshaled successfully to al.AccessLogsArgs, reset al.EnableAccessLogs, and return.
al.Enabled = nil
return nil
}
if err := value.Decode(&al.Enabled); err != nil {
return errUnmarshalELBAccessLogs
}
return nil
}
// ELBAccessLogsArgs holds the access logs configuration.
type ELBAccessLogsArgs struct {
BucketName *string `yaml:"bucket_name,omitempty"`
Prefix *string `yaml:"prefix,omitempty"`
}
func (al *ELBAccessLogsArgs) isEmpty() bool {
return al.BucketName == nil && al.Prefix == nil
}
// ELBAccessLogs returns the access logs config if the user has set any values.
// If there is no access logs settings, then returns nil and false.
func (cfg *EnvironmentConfig) ELBAccessLogs() (*ELBAccessLogsArgs, bool) {
accessLogs := cfg.HTTPConfig.Public.ELBAccessLogs
if accessLogs.isEmpty() {
return nil, false
}
if accessLogs.Enabled != nil {
return nil, aws.BoolValue(accessLogs.Enabled)
}
return &accessLogs.AdvancedConfig, true
}
// RestrictiveIngress represents ingress fields which restrict
// default behavior of allowing all public ingress.
type RestrictiveIngress struct {
CDNIngress *bool `yaml:"cdn"`
SourceIPs []IPNet `yaml:"source_ips"`
}
// RelaxedIngress contains ingress configuration to add to a security group.
type RelaxedIngress struct {
VPCIngress *bool `yaml:"vpc"`
}
// IsEmpty returns true if there are no specified fields for relaxed ingress.
func (i RelaxedIngress) IsEmpty() bool {
return i.VPCIngress == nil
}
// IsEmpty returns true if there are no specified fields for restrictive ingress.
func (i RestrictiveIngress) IsEmpty() bool {
return i.CDNIngress == nil && len(i.SourceIPs) == 0
}
// IsEmpty returns true if there is no customization to the public ALB.
func (cfg PublicHTTPConfig) IsEmpty() bool {
return len(cfg.Certificates) == 0 && cfg.DeprecatedSG.IsEmpty() && cfg.ELBAccessLogs.isEmpty() && cfg.Ingress.IsEmpty() && cfg.SSLPolicy == nil
}
type privateHTTPConfig struct {
InternalALBSubnets []string `yaml:"subnets,omitempty"`
Certificates []string `yaml:"certificates,omitempty"`
DeprecatedSG DeprecatedALBSecurityGroupsConfig `yaml:"security_groups,omitempty"` // Deprecated. This field is now available in Ingress.
Ingress RelaxedIngress `yaml:"ingress,omitempty"`
SSLPolicy *string `yaml:"ssl_policy,omitempty"`
}
// IsEmpty returns true if there is no customization to the internal ALB.
func (cfg privateHTTPConfig) IsEmpty() bool {
return len(cfg.InternalALBSubnets) == 0 && len(cfg.Certificates) == 0 && cfg.DeprecatedSG.IsEmpty() && cfg.Ingress.IsEmpty() && cfg.SSLPolicy == nil
}
// HasVPCIngress returns true if the private ALB allows ingress from within the VPC.
func (cfg privateHTTPConfig) HasVPCIngress() bool {
return aws.BoolValue(cfg.Ingress.VPCIngress) || aws.BoolValue(cfg.DeprecatedSG.DeprecatedIngress.VPCIngress)
}