github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/clouds/aws/ecs/cfn/template.go (about)

     1  package cfn
     2  
     3  import (
     4  	"crypto/sha256"
     5  	"encoding/hex"
     6  	"encoding/json"
     7  	"math"
     8  	"os"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"github.com/aws/smithy-go/ptr"
    13  	"github.com/awslabs/goformation/v7/cloudformation"
    14  	"github.com/awslabs/goformation/v7/cloudformation/ec2"
    15  	"github.com/awslabs/goformation/v7/cloudformation/ecr"
    16  	"github.com/awslabs/goformation/v7/cloudformation/ecs"
    17  	"github.com/awslabs/goformation/v7/cloudformation/iam"
    18  	"github.com/awslabs/goformation/v7/cloudformation/logs"
    19  	"github.com/awslabs/goformation/v7/cloudformation/policies"
    20  	"github.com/awslabs/goformation/v7/cloudformation/s3"
    21  	"github.com/awslabs/goformation/v7/cloudformation/secretsmanager"
    22  	"github.com/awslabs/goformation/v7/cloudformation/tags"
    23  	awsecs "github.com/defang-io/defang/src/pkg/clouds/aws/ecs"
    24  	"github.com/defang-io/defang/src/pkg/clouds/aws/ecs/cfn/outputs"
    25  	"github.com/defang-io/defang/src/pkg/types"
    26  )
    27  
    28  const (
    29  	createVpcResources   = true // TODO: make this configurable, add an option to use the default VPC
    30  	maxCachePrefixLength = 20   // prefix must be 2-20 characters long; should be 30 https://github.com/hashicorp/terraform-provider-aws/pull/34716
    31  )
    32  
    33  var (
    34  	dockerHubUsername    = os.Getenv("DOCKERHUB_USERNAME") // TODO: support DOCKER_AUTH_CONFIG
    35  	dockerHubAccessToken = os.Getenv("DOCKERHUB_ACCESS_TOKEN")
    36  	retainBucket         = true // set to false in unit tests
    37  )
    38  
    39  func getCacheRepoPrefix(prefix, suffix string) string {
    40  	repo := prefix + suffix
    41  	if len(repo) > maxCachePrefixLength {
    42  		// Cache repo name is too long; hash it and use the first 6 chars
    43  		hash := sha256.Sum256([]byte(prefix))
    44  		return hex.EncodeToString(hash[:])[:6] + "-" + suffix
    45  	}
    46  	return repo
    47  }
    48  
    49  type TemplateOverrides struct {
    50  	VpcID string
    51  }
    52  
    53  func createTemplate(stack string, containers []types.Container, overrides TemplateOverrides, spot bool) *cloudformation.Template {
    54  	prefix := stack + "-"
    55  
    56  	defaultTags := []tags.Tag{
    57  		{
    58  			Key:   "CreatedBy",
    59  			Value: awsecs.ProjectName,
    60  		},
    61  	}
    62  
    63  	template := cloudformation.NewTemplate()
    64  
    65  	// 1. bucket (for deployment state)
    66  	const _bucket = "Bucket"
    67  	var bucketDeletionPolicy policies.DeletionPolicy
    68  	if retainBucket {
    69  		bucketDeletionPolicy = "RetainExceptOnCreate"
    70  	}
    71  	template.Resources[_bucket] = &s3.Bucket{
    72  		Tags: defaultTags,
    73  		// BucketName: ptr.String(PREFIX + "bucket" + SUFFIX), // optional; TODO: might want to fix this name to allow Pulumi destroy after stack deletion
    74  		AWSCloudFormationDeletionPolicy: bucketDeletionPolicy,
    75  		VersioningConfiguration: &s3.Bucket_VersioningConfiguration{
    76  			Status: "Enabled",
    77  		},
    78  	}
    79  
    80  	// 2. ECS cluster
    81  	const _cluster = "Cluster"
    82  	template.Resources[_cluster] = &ecs.Cluster{
    83  		Tags: defaultTags,
    84  		// ClusterName: ptr.String(PREFIX + "cluster" + SUFFIX), // optional
    85  	}
    86  
    87  	// 3. ECS capacity provider
    88  	capacityProvider := "FARGATE"
    89  	if spot {
    90  		capacityProvider = "FARGATE_SPOT"
    91  	}
    92  	const _capacityProvider = "CapacityProvider"
    93  	template.Resources[_capacityProvider] = &ecs.ClusterCapacityProviderAssociations{
    94  		Cluster: cloudformation.Ref(_cluster),
    95  		CapacityProviders: []string{
    96  			capacityProvider,
    97  		},
    98  		DefaultCapacityProviderStrategy: []ecs.ClusterCapacityProviderAssociations_CapacityProviderStrategy{
    99  			{
   100  				CapacityProvider: capacityProvider,
   101  				Weight:           ptr.Int(1),
   102  			},
   103  		},
   104  	}
   105  
   106  	// 4. CloudWatch log group
   107  	const _logGroup = "LogGroup"
   108  	template.Resources[_logGroup] = &logs.LogGroup{
   109  		Tags: defaultTags,
   110  		// LogGroupName:    ptr.String(PREFIX + "log-group-test" + SUFFIX), // optional
   111  		RetentionInDays: ptr.Int(1),
   112  		// Make sure the log group cannot be deleted while the cluster is up
   113  		AWSCloudFormationDependsOn: []string{
   114  			_cluster,
   115  		},
   116  	}
   117  
   118  	const _privateRepoSecret = "PrivateRepoSecret"
   119  	// 5. ECR pull-through cache rules
   120  	// TODO: Creating pull through cache rules isn't supported in the following Regions:
   121  	// * China (Beijing) (cn-north-1)
   122  	// * China (Ningxia) (cn-northwest-1)
   123  	// * AWS GovCloud (US-East) (us-gov-east-1)
   124  	// * AWS GovCloud (US-West) (us-gov-west-1)
   125  	images := make([]string, 0, len(containers))
   126  	for _, task := range containers {
   127  		image := task.Image
   128  		if repo, ok := strings.CutPrefix(image, awsecs.EcrPublicRegistry); ok {
   129  			const _pullThroughCache = "PullThroughCache"
   130  			ecrPublicPrefix := getCacheRepoPrefix(prefix, "ecr-public")
   131  
   132  			// 5. The pull-through cache rule
   133  			template.Resources[_pullThroughCache] = &ecr.PullThroughCacheRule{
   134  				EcrRepositoryPrefix: ptr.String(ecrPublicPrefix),
   135  				UpstreamRegistryUrl: ptr.String(awsecs.EcrPublicRegistry),
   136  			}
   137  
   138  			image = cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/" + ecrPublicPrefix + repo)
   139  		} else if repo, ok := strings.CutPrefix(image, awsecs.DockerRegistry); ok && dockerHubUsername != "" && dockerHubAccessToken != "" {
   140  			const _pullThroughCache = "PullThroughCacheDocker"
   141  			dockerPublicPrefix := getCacheRepoPrefix(prefix, "docker-public")
   142  
   143  			// 5a. When creating the Secrets Manager secret that contains the upstream registry credentials, the secret name must use the `ecr-pullthroughcache/` prefix.
   144  			// This is the struct AWS wants, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache-creating-secret.html
   145  			bytes, err := json.Marshal(struct {
   146  				Username    string `json:"username"`
   147  				AccessToken string `json:"accessToken"`
   148  			}{dockerHubUsername, dockerHubAccessToken})
   149  			if err != nil {
   150  				panic(err) // there's no reason this should ever fail
   151  			}
   152  
   153  			// This is $0.40 per secret per month, so make it opt-in (only done when DOCKERHUB_* env vars are set)
   154  			template.Resources[_privateRepoSecret] = &secretsmanager.Secret{
   155  				Tags:         defaultTags,
   156  				Description:  ptr.String("Docker Hub credentials for the ECR pull-through cache rule"),
   157  				Name:         ptr.String("ecr-pullthroughcache/" + dockerPublicPrefix),
   158  				SecretString: ptr.String(string(bytes)),
   159  			}
   160  
   161  			// 5b. The pull-through cache rule
   162  			template.Resources[_pullThroughCache] = &ecr.PullThroughCacheRule{
   163  				EcrRepositoryPrefix: ptr.String(dockerPublicPrefix),
   164  				UpstreamRegistryUrl: ptr.String("registry-1.docker.io"),
   165  				CredentialArn:       cloudformation.RefPtr(_privateRepoSecret),
   166  			}
   167  
   168  			image = cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/" + dockerPublicPrefix + repo)
   169  		} else {
   170  			// TODO: support pull through cache for other registries
   171  			// TODO: support private repos (with or without pull-through cache)
   172  		}
   173  		images = append(images, image)
   174  	}
   175  
   176  	// 6. IAM roles for ECS task
   177  	assumeRolePolicyDocumentECS := map[string]any{
   178  		"Version": "2012-10-17",
   179  		"Statement": []map[string]any{
   180  			{
   181  				"Effect": "Allow",
   182  				"Principal": map[string]any{
   183  					"Service": []string{
   184  						"ecs-tasks.amazonaws.com",
   185  					},
   186  				},
   187  				"Action": []string{
   188  					"sts:AssumeRole",
   189  				},
   190  			},
   191  		},
   192  	}
   193  
   194  	// 6a. IAM exec role for task
   195  	execPolicies := []iam.Role_Policy{{
   196  		// From https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache.html#pull-through-cache-iam
   197  		PolicyName: "AllowECRPassThrough",
   198  		PolicyDocument: map[string]any{
   199  			"Version": "2012-10-17",
   200  			"Statement": []map[string]any{
   201  				{
   202  					"Effect": "Allow",
   203  					"Action": []string{
   204  						"ecr:CreatePullThroughCacheRule",
   205  						"ecr:BatchImportUpstreamImage", // should be registry permission instead
   206  						"ecr:CreateRepository",         // can be registry permission instead
   207  					},
   208  					"Resource": "*", // FIXME: restrict cloudformation.Sub("arn:${AWS::Partition}:ecr:${AWS::Region}:${AWS::AccountId}:repository/${PullThroughCache}:*"),
   209  				},
   210  			},
   211  		},
   212  	}}
   213  	if _, ok := template.Resources[_privateRepoSecret]; ok {
   214  		execPolicies = append(execPolicies, iam.Role_Policy{
   215  			PolicyName: "AllowGetRepoSecret",
   216  			PolicyDocument: map[string]any{
   217  				"Version": "2012-10-17",
   218  				"Statement": []map[string]any{
   219  					{
   220  						"Effect": "Allow",
   221  						"Action": []string{
   222  							"secretsmanager:GetSecretValue",
   223  							"ssm:GetParameters",
   224  							// "kms:Decrypt", Required only if your key uses a custom KMS key and not the default key
   225  						},
   226  						"Resource": cloudformation.Ref(_privateRepoSecret),
   227  					},
   228  				},
   229  			},
   230  		})
   231  	}
   232  	const _executionRole = "ExecutionRole"
   233  	template.Resources[_executionRole] = &iam.Role{
   234  		Tags: defaultTags,
   235  		// RoleName: ptr.String(PREFIX + "execution-role" + SUFFIX), // optional
   236  		ManagedPolicyArns: []string{
   237  			"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
   238  		},
   239  		AssumeRolePolicyDocument: assumeRolePolicyDocumentECS,
   240  		Policies:                 execPolicies,
   241  	}
   242  
   243  	// 6b. IAM role for task (optional)
   244  	const _taskRole = "TaskRole"
   245  	template.Resources[_taskRole] = &iam.Role{
   246  		Tags: defaultTags,
   247  		// RoleName: ptr.String(PREFIX + "task-role" + SUFFIX), // optional
   248  		ManagedPolicyArns: []string{
   249  			"arn:aws:iam::aws:policy/AdministratorAccess", // TODO: make this configurable
   250  		},
   251  		AssumeRolePolicyDocument: assumeRolePolicyDocumentECS,
   252  		Policies: []iam.Role_Policy{
   253  			{
   254  				// From https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#ecs-exec-required-iam-permissions
   255  				PolicyName: "AllowExecuteCommand",
   256  				PolicyDocument: map[string]any{
   257  					"Version": "2012-10-17",
   258  					"Statement": []map[string]any{
   259  						{
   260  							"Effect": "Allow",
   261  							"Action": []string{
   262  								"ssmmessages:CreateDataChannel",
   263  								"ssmmessages:OpenDataChannel",
   264  								"ssmmessages:OpenControlChannel",
   265  								"ssmmessages:CreateControlChannel",
   266  							},
   267  							"Resource": "*", // TODO: restrict
   268  						},
   269  					},
   270  				},
   271  			},
   272  			{
   273  				PolicyName: "AllowPassRole",
   274  				PolicyDocument: map[string]any{
   275  					"Version": "2012-10-17",
   276  					"Statement": []map[string]any{
   277  						{
   278  							"Effect": "Allow",
   279  							"Action": []string{
   280  								"iam:PassRole",
   281  							},
   282  							"Resource": "*", // TODO: restrict to roles that are needed/created by the task
   283  						},
   284  					},
   285  				},
   286  			},
   287  			{
   288  				PolicyName: "AllowAssumeRole",
   289  				PolicyDocument: map[string]any{
   290  					"Version": "2012-10-17",
   291  					"Statement": []map[string]any{
   292  						{
   293  							"Effect": "Allow",
   294  							"Action": []string{
   295  								"sts:AssumeRole",
   296  							},
   297  							"Resource": "*",
   298  						},
   299  					},
   300  				},
   301  			},
   302  		},
   303  	}
   304  
   305  	// 7. ECS task definition
   306  	var totalCpu, totalMiB float64
   307  	var platform string
   308  	for _, task := range containers {
   309  		totalCpu += float64(task.Cpus)
   310  		totalMiB += math.Max(float64(task.Memory)/1024/1024, 6) // 6MiB min for the container
   311  		if platform == "" {
   312  			platform = task.Platform
   313  		} else if platform != task.Platform {
   314  			panic("all containers must have the same platform")
   315  		}
   316  	}
   317  	mCpu, mib := awsecs.FixupFargateConfig(totalCpu, totalMiB)
   318  	arch, os := awsecs.PlatformToArchOS(platform)
   319  	var archP, osP *string
   320  	if arch != "" {
   321  		archP = ptr.String(arch)
   322  	}
   323  	if os != "" {
   324  		osP = ptr.String(os)
   325  	}
   326  
   327  	var volumes []ecs.TaskDefinition_Volume
   328  	var containerDefinitions []ecs.TaskDefinition_ContainerDefinition
   329  	for i, container := range containers {
   330  		for _, v := range container.Volumes {
   331  			volumes = append(volumes, ecs.TaskDefinition_Volume{
   332  				Name: ptr.String(v.Source),
   333  			})
   334  		}
   335  
   336  		volumesFrom := make([]ecs.TaskDefinition_VolumeFrom, 0, len(container.VolumesFrom))
   337  		for _, v := range container.VolumesFrom {
   338  			parts := strings.SplitN(v, ":", 2)
   339  			ro := false
   340  			if len(parts) == 2 && parts[1] == "ro" {
   341  				ro = true
   342  			}
   343  			volumesFrom = append(volumesFrom, ecs.TaskDefinition_VolumeFrom{
   344  				ReadOnly:        ptr.Bool(ro),
   345  				SourceContainer: ptr.String(parts[0]),
   346  			})
   347  		}
   348  
   349  		mountPoints := make([]ecs.TaskDefinition_MountPoint, 0, len(container.Volumes))
   350  		for _, v := range container.Volumes {
   351  			mountPoints = append(mountPoints, ecs.TaskDefinition_MountPoint{
   352  				ContainerPath: ptr.String(v.Target),
   353  				SourceVolume:  ptr.String(v.Source),
   354  				ReadOnly:      ptr.Bool(v.ReadOnly),
   355  			})
   356  		}
   357  
   358  		var cpuShares *int
   359  		if container.Cpus > 0 {
   360  			cpuShares = ptr.Int(int(container.Cpus * 1024))
   361  		}
   362  		name := container.Name
   363  		if name == "" {
   364  			name = awsecs.ContainerName // TODO: backwards compat; remove this
   365  		}
   366  
   367  		var dependsOn []ecs.TaskDefinition_ContainerDependency
   368  		if container.DependsOn != nil {
   369  			for name, condition := range container.DependsOn {
   370  				dependsOn = append(dependsOn, ecs.TaskDefinition_ContainerDependency{
   371  					Condition:     ptr.String(string(condition)),
   372  					ContainerName: ptr.String(name),
   373  				})
   374  			}
   375  		}
   376  
   377  		def := ecs.TaskDefinition_ContainerDefinition{
   378  			Name:        name,
   379  			Image:       images[i],
   380  			StopTimeout: ptr.Int(120), // TODO: make this configurable
   381  			Essential:   container.Essential,
   382  			Cpu:         cpuShares,
   383  			LogConfiguration: &ecs.TaskDefinition_LogConfiguration{
   384  				LogDriver: "awslogs",
   385  				Options: map[string]string{
   386  					"awslogs-group":         cloudformation.Ref(_logGroup),
   387  					"awslogs-region":        cloudformation.Ref("AWS::Region"),
   388  					"awslogs-stream-prefix": awsecs.AwsLogsStreamPrefix,
   389  				},
   390  			},
   391  			VolumesFrom:      volumesFrom,
   392  			MountPoints:      mountPoints,
   393  			EntryPoint:       container.EntryPoint,
   394  			Command:          container.Command,
   395  			WorkingDirectory: container.WorkDir,
   396  			DependsOnProp:    dependsOn,
   397  		}
   398  		containerDefinitions = append(containerDefinitions, def)
   399  	}
   400  
   401  	const _taskDefinition = "TaskDefinition"
   402  	template.Resources[_taskDefinition] = &ecs.TaskDefinition{
   403  		Tags: defaultTags,
   404  		RuntimePlatform: &ecs.TaskDefinition_RuntimePlatform{
   405  			CpuArchitecture:       archP,
   406  			OperatingSystemFamily: osP,
   407  		},
   408  		Volumes:                 volumes,
   409  		ContainerDefinitions:    containerDefinitions,
   410  		Cpu:                     ptr.String(strconv.FormatUint(uint64(mCpu), 10)), // MilliCPU
   411  		ExecutionRoleArn:        cloudformation.RefPtr(_executionRole),
   412  		Memory:                  ptr.String(strconv.FormatUint(uint64(mib), 10)), // MiB
   413  		NetworkMode:             ptr.String("awsvpc"),
   414  		RequiresCompatibilities: []string{"FARGATE"},
   415  		TaskRoleArn:             cloudformation.RefPtr(_taskRole),
   416  		// Family:                  cloudformation.SubPtr("${AWS::StackName}-TaskDefinition"), // optional, but needed to avoid TaskDef replacement
   417  	}
   418  
   419  	var vpcId *string
   420  	if overrides.VpcID == "" && createVpcResources {
   421  		// 8a. a VPC
   422  		const _vpc = "VPC"
   423  		template.Resources[_vpc] = &ec2.VPC{
   424  			Tags:      append([]tags.Tag{{Key: "Name", Value: prefix + "vpc"}}, defaultTags...),
   425  			CidrBlock: ptr.String("10.0.0.0/16"),
   426  		}
   427  		vpcId = cloudformation.RefPtr(_vpc)
   428  		// 8b. an internet gateway; FIXME: make internet access optional
   429  		const _internetGateway = "InternetGateway"
   430  		template.Resources[_internetGateway] = &ec2.InternetGateway{
   431  			Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "igw"}}, defaultTags...),
   432  		}
   433  		// 8c. an internet gateway attachment for the VPC
   434  		const _internetGatewayAttachment = "InternetGatewayAttachment"
   435  		template.Resources[_internetGatewayAttachment] = &ec2.VPCGatewayAttachment{
   436  			VpcId:             cloudformation.Ref(_vpc),
   437  			InternetGatewayId: cloudformation.RefPtr(_internetGateway),
   438  		}
   439  		// 8d. a route table
   440  		const _routeTable = "RouteTable"
   441  		template.Resources[_routeTable] = &ec2.RouteTable{
   442  			Tags:  append([]tags.Tag{{Key: "Name", Value: prefix + "routetable"}}, defaultTags...),
   443  			VpcId: cloudformation.Ref(_vpc),
   444  		}
   445  		// 8e. a route for the route table and internet gateway
   446  		const _route = "Route"
   447  		template.Resources[_route] = &ec2.Route{
   448  			RouteTableId:         cloudformation.Ref(_routeTable),
   449  			DestinationCidrBlock: ptr.String("0.0.0.0/0"),
   450  			GatewayId:            cloudformation.RefPtr(_internetGateway),
   451  		}
   452  		// 8f. a public subnet
   453  		const _subnet = "Subnet"
   454  		template.Resources[_subnet] = &ec2.Subnet{
   455  			Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "subnet"}}, defaultTags...),
   456  			// AvailabilityZone:; TODO: parse region suffix
   457  			CidrBlock:           ptr.String("10.0.0.0/20"),
   458  			VpcId:               cloudformation.Ref(_vpc),
   459  			MapPublicIpOnLaunch: ptr.Bool(true),
   460  		}
   461  		// 8g. a subnet / route table association
   462  		const _subnetRouteTableAssociation = "SubnetRouteTableAssociation"
   463  		template.Resources[_subnetRouteTableAssociation] = &ec2.SubnetRouteTableAssociation{
   464  			SubnetId:     cloudformation.Ref(_subnet),
   465  			RouteTableId: cloudformation.Ref(_routeTable),
   466  		}
   467  		// 8h. S3 gateway endpoint (to avoid S3 bandwidth charges)
   468  		const _s3GatewayEndpoint = "S3GatewayEndpoint"
   469  		template.Resources[_s3GatewayEndpoint] = &ec2.VPCEndpoint{
   470  			VpcEndpointType: ptr.String("Gateway"),
   471  			VpcId:           cloudformation.Ref(_vpc),
   472  			ServiceName:     cloudformation.Sub("com.amazonaws.${AWS::Region}.s3"),
   473  		}
   474  
   475  		template.Outputs[outputs.SubnetID] = cloudformation.Output{
   476  			Value:       cloudformation.Ref(_subnet),
   477  			Description: ptr.String("ID of the subnet"),
   478  		}
   479  	}
   480  
   481  	if overrides.VpcID != "" {
   482  		vpcId = ptr.String(overrides.VpcID)
   483  	}
   484  
   485  	const _securityGroup = "SecurityGroup"
   486  	template.Resources[_securityGroup] = &ec2.SecurityGroup{
   487  		Tags:             defaultTags, // Name tag is ignored
   488  		GroupDescription: "Security group for the ECS task that allows all outbound and inbound traffic",
   489  		VpcId:            vpcId,
   490  		SecurityGroupIngress: []ec2.SecurityGroup_Ingress{
   491  			{
   492  				IpProtocol: "tcp",
   493  				FromPort:   ptr.Int(1),
   494  				ToPort:     ptr.Int(65535),
   495  				CidrIp:     ptr.String("0.0.0.0/0"), // from anywhere; FIXME: make optional and/or restrict to "my ip"
   496  			},
   497  		},
   498  		// SecurityGroupEgress: []ec2.SecurityGroup_Egress{; FIXME: add ability to restrict outbound traffic
   499  		// 	{
   500  		// 		IpProtocol: "tcp",
   501  		// 		FromPort:   ptr.Int(1),
   502  		// 		ToPort:     ptr.Int(65535),
   503  		// 		// CidrIp:     ptr.String("
   504  		// 	},
   505  		// },
   506  	}
   507  
   508  	// Declare stack outputs
   509  	template.Outputs[outputs.TaskDefArn] = cloudformation.Output{
   510  		Value:       cloudformation.Ref(_taskDefinition),
   511  		Description: ptr.String("ARN of the ECS task definition"),
   512  	}
   513  	template.Outputs[outputs.ClusterName] = cloudformation.Output{
   514  		Value:       cloudformation.Ref(_cluster),
   515  		Description: ptr.String("Name of the ECS cluster"),
   516  	}
   517  	template.Outputs[outputs.LogGroupARN] = cloudformation.Output{
   518  		Value:       cloudformation.GetAtt(_logGroup, "Arn"),
   519  		Description: ptr.String("ARN of the CloudWatch log group"),
   520  	}
   521  	template.Outputs[outputs.SecurityGroupID] = cloudformation.Output{
   522  		Value:       cloudformation.Ref(_securityGroup),
   523  		Description: ptr.String("ID of the security group"),
   524  	}
   525  	template.Outputs[outputs.BucketName] = cloudformation.Output{
   526  		Value:       cloudformation.Ref(_bucket),
   527  		Description: ptr.String("Name of the S3 bucket"),
   528  	}
   529  
   530  	return template
   531  }