package devops import ( "compress/gzip" "context" "crypto/md5" "encoding/base64" "encoding/json" "fmt" "io" "io/ioutil" "log" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/aws/aws-sdk-go/service/rds" "github.com/pborman/uuid" "github.com/aws/aws-sdk-go/service/elasticache" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests" "github.com/aws/aws-sdk-go/service/route53" "github.com/bobesa/go-domain-util/domainutil" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/acm" "github.com/aws/aws-sdk-go/service/cloudwatchlogs" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/ecs" "github.com/aws/aws-sdk-go/service/elbv2" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/secretsmanager" dockerTypes "github.com/docker/docker/api/types" dockerClient "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" "github.com/iancoleman/strcase" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // baseServicePolicyDocument defines the default permissions required to access AWS services for all deployed services. var baseServicePolicyDocument = IamPolicyDocument{ Version: "2012-10-17", Statement: []IamStatementEntry{ IamStatementEntry{ Sid: "DefaultServiceAccess", Effect: "Allow", Action: []string{ "s3:HeadBucket", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface", "ecs:ListTasks", "ecs:DescribeTasks", "ec2:DescribeNetworkInterfaces", "route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ChangeResourceRecordSets", "ecs:UpdateService", "ses:SendEmail", }, Resource: "*", }, IamStatementEntry{ Sid: "ServiceInvokeLambda", Effect: "Allow", Action: []string{ "iam:GetRole", "lambda:InvokeFunction", "lambda:ListVersionsByFunction", "lambda:GetFunction", "lambda:InvokeAsync", "lambda:GetFunctionConfiguration", "iam:PassRole", "lambda:GetAlias", "lambda:GetPolicy", }, Resource: []string{ "arn:aws:iam:::role/*", "arn:aws:lambda:::function:*", }, }, IamStatementEntry{ Sid: "datadoglambda", Effect: "Allow", Action: []string{ "cloudwatch:Get*", "cloudwatch:List*", "ec2:Describe*", "support:*", "tag:GetResources", "tag:GetTagKeys", "tag:GetTagValues", }, Resource: "*", }, }, } /* // requiredCmdsBuild proves a list of required executables for completing build. var requiredCmdsDeploy = [][]string{ []string{"docker", "version", "-f", "{{.Client.Version}}"}, } */ // NewServiceDeployRequest generated a new request for executing deploy for a given set of flags. func NewServiceDeployRequest(log *log.Logger, flags ServiceDeployFlags) (*serviceDeployRequest, error) { log.Println("Validate flags.") { errs := validator.New().Struct(flags) if errs != nil { return nil, errs } log.Printf("\t%s\tFlags ok.", tests.Success) } log.Println("\tVerify AWS credentials.") var awsCreds awsCredentials { var err error awsCreds, err = GetAwsCredentials(flags.Env) if err != nil { return nil, err } log.Printf("\t\t\tAccessKeyID: '%s'", awsCreds.AccessKeyID) log.Printf("\t\t\tRegion: '%s'", awsCreds.Region) log.Printf("\t%s\tAWS credentials valid.", tests.Success) } log.Println("Generate deploy request.") var req serviceDeployRequest { req = serviceDeployRequest{ // Required flags. ServiceName: flags.ServiceName, Env: flags.Env, AwsCreds: awsCreds, // Optional flags. ProjectRoot: flags.ProjectRoot, ProjectName: flags.ProjectName, DockerFile: flags.DockerFile, EnableHTTPS: flags.EnableHTTPS, ServiceDomainName: flags.ServiceDomainName, ServiceDomainNameAliases: flags.ServiceDomainNameAliases, S3BucketPrivateName : flags.S3BucketPrivateName, S3BucketPublicName: flags.S3BucketPublicName, EnableLambdaVPC: flags.EnableLambdaVPC, EnableEcsElb: flags.EnableEcsElb, NoBuild: flags.NoBuild, NoDeploy: flags.NoDeploy, NoCache: flags.NoCache, NoPush: flags.NoPush, RecreateService: flags.RecreateService, flags: flags, } // When project root directory is empty or set to current working path, then search for the project root by locating // the go.mod file. log.Println("\tDetermining the project root directory.") { if flags.ProjectRoot == "" || flags.ProjectRoot == "." { log.Println("\tAttempting to location project root directory from current working directory.") var err error req.GoModFile, err = findProjectGoModFile() if err != nil { return nil, err } req.ProjectRoot = filepath.Dir(req.GoModFile) } else { log.Println("\t\tUsing supplied project root directory.") req.GoModFile = filepath.Join(flags.ProjectRoot, "go.mod") } log.Printf("\t\t\tproject root: %s", req.ProjectRoot) log.Printf("\t\t\tgo.mod: %s", req.GoModFile) } log.Println("\tExtracting go module name from go.mod.") { var err error req.GoModName, err = loadGoModName(req.GoModFile) if err != nil { return nil, err } log.Printf("\t\t\tmodule name: %s", req.GoModName) } log.Println("\tDetermining the project name.") { if flags.ProjectName != "" { req.ProjectName = flags.ProjectName log.Printf("\t\tUse provided value.") } else { req.ProjectName = filepath.Base(req.GoModName) log.Printf("\t\tSet from go module.") } log.Printf("\t\t\tproject name: %s", req.ProjectName) } log.Println("\tAttempting to locate service directory from project root directory.") { if flags.DockerFile != "" { req.DockerFile = flags.DockerFile log.Printf("\t\tUse provided value.") } else { log.Printf("\t\tFind from project root looking for Dockerfile.") var err error req.DockerFile, err = findServiceDockerFile(req.ProjectRoot, req.ServiceName) if err != nil { return nil, err } } req.ServiceDir = filepath.Dir(req.DockerFile) log.Printf("\t\t\tservice directory: %s", req.ServiceDir) log.Printf("\t\t\tdockerfile: %s", req.DockerFile) } log.Println("\tSet defaults not defined in env vars.") { // Set default AWS ECR Repository Name. req.EcrRepositoryName = req.ProjectName log.Printf("\t\t\tSet ECR Repository Name to '%s'.", req.EcrRepositoryName) // Set default AWS ECR Regsistry Max Images. req.EcrRepositoryMaxImages = defaultAwsRegistryMaxImages log.Printf("\t\t\tSet ECR Regsistry Max Images to '%d'.", req.EcrRepositoryMaxImages) // Set default AWS ECS Cluster Name. req.EcsClusterName = req.ProjectName + "-" + req.Env log.Printf("\t\t\tSet ECS Cluster Name to '%s'.", req.EcsClusterName) // Set default AWS ECS Service Name. req.EcsServiceName = req.ServiceName + "-" + req.Env log.Printf("\t\t\tSet ECS Service Name to '%s'.", req.EcsServiceName) // Set default AWS ECS Execution Role Name. req.EcsExecutionRoleName = fmt.Sprintf("ecsExecutionRole%s%s", req.ProjectNameCamel(), strcase.ToCamel(req.Env)) log.Printf("\t\t\tSet ECS Execution Role Name to '%s'.", req.EcsExecutionRoleName) // Set default AWS ECS Task Role Name. req.EcsTaskRoleName = fmt.Sprintf("ecsTaskRole%s%s", req.ProjectNameCamel(), strcase.ToCamel(req.Env)) log.Printf("\t\t\tSet ECS Task Role Name to '%s'.", req.EcsTaskRoleName) // Set default AWS ECS Task Policy Name. req.EcsTaskPolicyName = fmt.Sprintf("%s%sServices", req.ProjectNameCamel(), strcase.ToCamel(req.Env)) log.Printf("\t\t\tSet ECS Task Policy Name to '%s'.", req.EcsTaskPolicyName) // Set default Cloudwatch Log Group Name. req.CloudWatchLogGroupName = fmt.Sprintf("logs/env_%s/aws/ecs/cluster_%s/service_%s", req.Env, req.EcsClusterName, req.ServiceName) log.Printf("\t\t\tSet CloudWatch Log Group Name to '%s'.", req.CloudWatchLogGroupName) // Set default EC2 Security Group Name. req.Ec2SecurityGroupName = req.EcsClusterName log.Printf("\t\t\tSet ECS Security Group Name to '%s'.", req.Ec2SecurityGroupName) // Set default ELB Load Balancer Name when ELB is enabled. if req.EnableEcsElb { if !strings.Contains(req.EcsClusterName, req.Env) && !strings.Contains(req.ServiceName, req.Env) { // When a custom cluster name is provided and/or service name, ensure the ELB contains the current env. req.ElbLoadBalancerName = fmt.Sprintf("%s-%s-%s", req.EcsClusterName, req.ServiceName, req.Env) } else { // Default value when when custom cluster/service name is supplied. req.ElbLoadBalancerName = fmt.Sprintf("%s-%s", req.EcsClusterName, req.ServiceName) } log.Printf("\t\t\tSet ELB Name to '%s'.", req.ElbLoadBalancerName) } // Set ECS configs based on specified env. if flags.Env == "prod" { req.EcsServiceMinimumHealthyPercent = aws.Int64(100) req.EcsServiceMaximumPercent = aws.Int64(200) req.ElbDeregistrationDelay = aws.Int(300) } else { req.EcsServiceMinimumHealthyPercent = aws.Int64(100) req.EcsServiceMaximumPercent = aws.Int64(200) // force staging to deploy immediately without waiting for connections to drain req.ElbDeregistrationDelay = aws.Int(0) } req.EcsServiceDesiredCount = 1 req.EscServiceHealthCheckGracePeriodSeconds = aws.Int64(60) // S3 temp prefix, a life cycle policy will be applied to this. req.S3BucketTempPrefix = "tmp/" // Elastic Cache settings for a Redis cache cluster. Could defined different settings by env. req.CacheCluster = &elasticache.CreateCacheClusterInput{ AutoMinorVersionUpgrade: aws.Bool(true), CacheClusterId: aws.String(req.ProjectName+"-"+req.Env), CacheNodeType: aws.String("cache.t2.micro"), CacheSubnetGroupName: aws.String("default"), Engine: aws.String("redis"), EngineVersion: aws.String("5.0.4"), NumCacheNodes: aws.Int64(1), Port: aws.Int64(6379), SnapshotRetentionLimit: aws.Int64(7), } // Recommended to be set to allkeys-lru to avoid OOM since redis will be used as an ephemeral store. req.CacheClusterParameter = []*elasticache.ParameterNameValue{ &elasticache.ParameterNameValue{ ParameterName: aws.String("maxmemory-policy"), ParameterValue: aws.String("allkeys-lru"), }, } // RDS cluster is used for Aurora which is limited to regions and db instance types so not good for example. req.DBCluster = nil // RDS settings for a Postgres database Instance. Could defined different settings by env. req.DBInstance = &rds.CreateDBInstanceInput{ DBInstanceIdentifier: aws.String(req.ProjectName+"-"+req.Env+"-01"), DBName: aws.String("shared"), Engine: aws.String("postgres"), MasterUsername: aws.String("god"), MasterUserPassword: aws.String("mypassword"), // When empty auto generated. Port: aws.Int64(5432), DBInstanceClass: aws.String("db.t2.small"), AllocatedStorage: aws.Int64(20), MultiAZ: aws.Bool(false), PubliclyAccessible: aws.Bool(false), StorageEncrypted: aws.Bool(true), BackupRetentionPeriod: aws.Int64(7), EnablePerformanceInsights: aws.Bool(false), AutoMinorVersionUpgrade: aws.Bool(true), CopyTagsToSnapshot: aws.Bool(true), Tags: []*rds.Tag{ {Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, {Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, } log.Printf("\t%s\tDefaults set.", tests.Success) } log.Println("\tValidate request.") errs := validator.New().Struct(req) if errs != nil { return nil, errs } log.Printf("\t%s\tNew request generated.", tests.Success) } return &req, nil } // Run is the main entrypoint for deploying a service for a given target env. func ServiceDeploy(log *log.Logger, req *serviceDeployRequest) error { /* log.Println("Verify required commands are installed.") for _, cmdVals := range requiredCmdsDeploy { cmd := exec.Command(cmdVals[0], cmdVals[1:]...) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { return errors.WithMessagef(err, "failed to execute %s - %s\n%s", strings.Join(cmdVals, " "), string(out)) } log.Printf("\t%s\t%s - %s", tests.Success, cmdVals[0], string(out)) } // Pull the current env variables to be passed in for command execution. envVars := EnvVars(os.Environ()) */ startTime := time.Now() // Load the AWS ECR repository. Try to find by name else create new one. var docker *dockerClient.Client var registryAuth string { log.Println("ECR - Get or create repository.") svc := ecr.New(req.awsSession()) var awsRepo *ecr.Repository descRes, err := svc.DescribeRepositories(&ecr.DescribeRepositoriesInput{ RepositoryNames: []*string{aws.String(req.EcrRepositoryName)}, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != ecr.ErrCodeRepositoryNotFoundException { return errors.Wrapf(err, "failed to describe repository '%s'", req.EcrRepositoryName) } } else if len(descRes.Repositories) > 0 { awsRepo = descRes.Repositories[0] } if awsRepo == nil { // If no repository was found, create one. createRes, err := svc.CreateRepository(&ecr.CreateRepositoryInput{ RepositoryName: aws.String(req.EcrRepositoryName), Tags: []*ecr.Tag{ &ecr.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &ecr.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create repository '%s'", req.EcrRepositoryName) } awsRepo = createRes.Repository log.Printf("\t\tCreated: %s.", *awsRepo.RepositoryArn) } else { log.Printf("\t\tFound: %s.", *awsRepo.RepositoryArn) log.Println("\t\tChecking old ECR images.") delIds, err := EcrPurgeImages(req) if err != nil { return err } // If there are image IDs to delete, delete them. if len(delIds) > 0 { log.Printf("\t\tDeleted %d images that exceeded limit of %d", len(delIds), req.EcrRepositoryMaxImages) for _, imgId := range delIds { log.Printf("\t\t\t%s", *imgId.ImageTag) } } } tag1 := req.Env + "-" + req.ServiceName req.BuildTags = append(req.BuildTags, tag1) if v := os.Getenv("CI_COMMIT_REF_NAME"); v != "" { tag2 := tag1 + "-" + v req.BuildTags = append(req.BuildTags, tag2) req.ReleaseImage = *awsRepo.RepositoryUri + ":" + tag2 } else { req.ReleaseImage = *awsRepo.RepositoryUri + ":" + tag1 } log.Printf("\t\trelease image: %s", req.ReleaseImage) log.Printf("\t\ttags: %s", strings.Join(req.BuildTags, " ")) log.Printf("\t%s\tRelease image valid.", tests.Success) log.Println("ECR - Retrieve authorization token used for docker login.") // Get the credentials necessary for logging into the AWS Elastic Container Registry // made available with the AWS access key and AWS secret access keys. res, err := svc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) if err != nil { return errors.Wrap(err, "failed to get ecr authorization token") } authToken, err := base64.StdEncoding.DecodeString(*res.AuthorizationData[0].AuthorizationToken) if err != nil { return errors.Wrap(err, "failed to base64 decode ecr authorization token") } pts := strings.Split(string(authToken), ":") user := pts[0] pass := pts[1] docker, err = dockerClient.NewEnvClient() if err != nil { return errors.WithMessage(err, "failed to init new docker client from env") } loginRes, err := docker.RegistryLogin(context.Background(), dockerTypes.AuthConfig{ Username: user, Password: pass, ServerAddress: *res.AuthorizationData[0].ProxyEndpoint, }) if err != nil { return errors.WithMessage(err, "failed docker registry login") } log.Printf("\t\tStatus: %s", loginRes.Status ) registryAuth = fmt.Sprintf(`{"Username": "%s", "Password": "%s"}`, user, pass) registryAuth = base64.StdEncoding.EncodeToString([]byte(registryAuth)) log.Printf("\t%s\tdocker login ok.", tests.Success) } // Do the docker build. if req.NoBuild == false { dockerFile, err := filepath.Rel(req.ProjectRoot, req.DockerFile) if err != nil { return errors.Wrapf(err, "failed parse relative path for %s from %s", req.DockerFile, req.ProjectRoot) } buildOpts := dockerTypes.ImageBuildOptions{ Tags: []string{req.ReleaseImage}, BuildArgs: map[string]*string{ "service": &req.ServiceName, "env": &req.Env, }, Dockerfile: dockerFile, NoCache: req.NoCache, } // Append the build tags. var builtImageTags []string for _, t := range req.BuildTags { if strings.HasSuffix(req.ReleaseImage, ":"+t) { // skip duplicate image tags continue } imageTag := req.ReleaseImage + ":" + t buildOpts.Tags = append(buildOpts.Tags, imageTag) builtImageTags = append(builtImageTags, imageTag) } log.Println("starting docker build") buildCtx, err := archive.TarWithOptions(req.ProjectRoot, &archive.TarOptions{}) if err != nil { return errors.Wrap(err, "failed to create docker build context") } _, err = docker.ImageBuild(context.Background(), buildCtx, buildOpts) if err != nil { return errors.Wrap(err, "failed to build docker image") } // Push the newly built docker container to the registry. if req.NoPush == false { log.Printf("\t\tpush release image %s", req.ReleaseImage) // Common Errors: // 1. Image push failed Error parsing HTTP response: unexpected end of JSON input: "" // If you are trying to push to an ECR repository, you need to make sure that the // ecr:BatchCheckLayerAvailability permission is also checked. It is not selected by // default when using the default push permissions that ECR sets. // https://github.com/moby/moby/issues/19010 pushOpts := dockerTypes.ImagePushOptions{ All: true, // Push returns EOF if no 'X-Registry-Auth' header is specified // https://github.com/moby/moby/issues/10983 RegistryAuth: registryAuth, } closer, err := docker.ImagePush(context.Background(), req.ReleaseImage, pushOpts) if err != nil { return errors.WithMessagef(err, "failed to push image %s", req.ReleaseImage) } io.Copy(os.Stdout, closer) closer.Close() // Push all the build tags. for _, t := range builtImageTags { log.Printf("\t\tpush tag %s", t) closer, err := docker.ImagePush(context.Background(), req.ReleaseImage, pushOpts) if err != nil { return errors.WithMessagef(err, "failed to push image %s", t) } io.Copy(os.Stdout, closer) closer.Close() } } log.Printf("\t%s\tbuild complete.\n", tests.Success) } // Exit and don't continue if skip deploy. if req.NoDeploy == true { return nil } // Try to find the datadog API Key, this value is optional. var datadogApiKey string { log.Println("Datadog - Get API Key") // Load Datadog API Key which can be either stored in an env var or in AWS Secrets Manager. // 1. Check env vars for [DEV|STAGE|PROD]_DD_API_KEY and DD_API_KEY datadogApiKey = getTargetEnv(req.Env, "DD_API_KEY") // 2. Check AWS Secrets Manager for datadog entry prefixed with target env. if datadogApiKey == "" { prefixedSecretId := strings.ToUpper(req.Env) + "/DATADOG" var err error datadogApiKey, err = GetAwsSecretValue(req.AwsCreds, prefixedSecretId) if err != nil { if aerr, ok := errors.Cause(err).(awserr.Error); !ok || aerr.Code() != secretsmanager.ErrCodeResourceNotFoundException { return err } } } // 3. Check AWS Secrets Manager for datadog entry. if datadogApiKey == "" { secretId := "DATADOG" var err error datadogApiKey, err = GetAwsSecretValue(req.AwsCreds, secretId) if err != nil { if aerr, ok := errors.Cause(err).(awserr.Error); !ok || aerr.Code() != secretsmanager.ErrCodeResourceNotFoundException { return err } } } if datadogApiKey != "" { log.Printf("\t%s\tAPI Key set.\n", tests.Success) } else { log.Printf("\t%s\tAPI Key NOT set.\n", tests.Failed) } } // Try to find the AWS Cloudwatch Log Group by name or create new one. { log.Println("CloudWatch Logs - Get or Create Log Group") svc := cloudwatchlogs.New(req.awsSession()) // If no log group was found, create one. var err error _, err = svc.CreateLogGroup(&cloudwatchlogs.CreateLogGroupInput{ LogGroupName: aws.String(req.CloudWatchLogGroupName), Tags: map[string]*string{ awsTagNameProject: aws.String(req.ProjectName), awsTagNameEnv: aws.String(req.Env), }, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != cloudwatchlogs.ErrCodeResourceAlreadyExistsException { return errors.Wrapf(err, "failed to create log group '%s'", req.CloudWatchLogGroupName) } log.Printf("\t\tFound: %s.", req.CloudWatchLogGroupName) } else { log.Printf("\t\tCreated: %s.", req.CloudWatchLogGroupName) } log.Printf("\t%s\tUsing Log Group '%s'.\n", tests.Success, req.CloudWatchLogGroupName) } // Try to find the AWS S3 Buckets by names or create new ones. { log.Println("S3 - Setup Buckets") svc := s3.New(req.awsSession()) var bucketNames []string if req.S3BucketPrivateName != "" { bucketNames = append(bucketNames, req.S3BucketPrivateName) } if req.S3BucketPublicName != "" { bucketNames = append(bucketNames, req.S3BucketPublicName) } log.Println("\tGet or Create S3 Buckets") for _, bucketName := range bucketNames { _, err := svc.CreateBucket(&s3.CreateBucketInput{ Bucket: aws.String(bucketName), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || (aerr.Code() != s3.ErrCodeBucketAlreadyExists && aerr.Code() != s3.ErrCodeBucketAlreadyOwnedByYou) { return errors.Wrapf(err, "failed to create s3 bucket '%s'", bucketName) } log.Printf("\t\tFound: %s.", bucketName) } else { log.Printf("\t\tCreated: %s.", bucketName) } } log.Println("\tWait for S3 Buckets to exist") for _, bucketName := range bucketNames { log.Printf("\t\t%s", bucketName) err := svc.WaitUntilBucketExists(&s3.HeadBucketInput{ Bucket: aws.String(bucketName), }) if err != nil { return errors.Wrapf(err, "failed to wait for s3 bucket '%s' to exist", bucketName) } log.Printf("\t\t\tExists") } log.Println("\tConfigure S3 Buckets to exist") for _, bucketName := range bucketNames { log.Printf("\t\t%s", bucketName) // Add a life cycle policy to expire keys for the temp directory. _, err := svc.PutBucketLifecycleConfiguration(&s3.PutBucketLifecycleConfigurationInput{ Bucket: aws.String(bucketName), LifecycleConfiguration: &s3.BucketLifecycleConfiguration{ Rules: []*s3.LifecycleRule{ { ID: aws.String("Rule for : "+req.S3BucketTempPrefix), Status: aws.String("Enabled"), Filter: &s3.LifecycleRuleFilter{ Prefix: aws.String(req.S3BucketTempPrefix), }, Expiration: &s3.LifecycleExpiration{ // Indicates the lifetime, in days, of the objects that are subject to the rule. // The value must be a non-zero positive integer. Days: aws.Int64(1), }, // Specifies the days since the initiation of an incomplete multipart upload // that Amazon S3 will wait before permanently removing all parts of the upload. // For more information, see Aborting Incomplete Multipart Uploads Using a Bucket // Lifecycle Policy (https://docs.aws.amazon.com/AmazonS3/latest/dev/mpuoverview.html#mpu-abort-incomplete-mpu-lifecycle-config) // in the Amazon Simple Storage Service Developer Guide. AbortIncompleteMultipartUpload: &s3.AbortIncompleteMultipartUpload{ DaysAfterInitiation: aws.Int64(1), }, }, }, }, }) if err != nil { return errors.Wrapf(err, "failed to configure lifecycle rule for s3 bucket '%s'", bucketName) } log.Printf("\t\t\tAdded lifecycle expiration for prefix '%s'", req.S3BucketTempPrefix) if bucketName == req.S3BucketPublicName { // Enable CORS for public bucket. _, err = svc.PutBucketCors(&s3.PutBucketCorsInput{ Bucket: aws.String(bucketName), CORSConfiguration: &s3.CORSConfiguration{ CORSRules: []*s3.CORSRule{ &s3.CORSRule{ // Headers that are specified in the Access-Control-Request-Headers header. // These headers are allowed in a preflight OPTIONS request. In response to // any preflight OPTIONS request, Amazon S3 returns any requested headers that // are allowed. // AllowedHeaders: aws.StringSlice([]string{}), // An HTTP method that you allow the origin to execute. Valid values are GET, // PUT, HEAD, POST, and DELETE. // // AllowedMethods is a required field AllowedMethods: aws.StringSlice([]string{"GET", "POST"}), // One or more origins you want customers to be able to access the bucket from. // // AllowedOrigins is a required field AllowedOrigins: aws.StringSlice([]string{"*"}), // One or more headers in the response that you want customers to be able to // access from their applications (for example, from a JavaScript XMLHttpRequest // object). // ExposeHeaders: aws.StringSlice([]string{}), // The time in seconds that your browser is to cache the preflight response // for the specified resource. // MaxAgeSeconds: aws.Int64(), }, }, }, }) if err != nil { return errors.Wrapf(err, "failed to put CORS on s3 bucket '%s'", bucketName) } log.Printf("\t\t\tUpdated CORS") } else { // Block public access for all non-public buckets. _, err = svc.PutPublicAccessBlock(&s3.PutPublicAccessBlockInput{ Bucket: aws.String(bucketName), PublicAccessBlockConfiguration: &s3.PublicAccessBlockConfiguration{ // Specifies whether Amazon S3 should block public access control lists (ACLs) // for this bucket and objects in this bucket. Setting this element to TRUE // causes the following behavior: // // * PUT Bucket acl and PUT Object acl calls fail if the specified ACL is // public. // // * PUT Object calls fail if the request includes a public ACL. // // Enabling this setting doesn't affect existing policies or ACLs. BlockPublicAcls: aws.Bool(true), // Specifies whether Amazon S3 should block public bucket policies for this // bucket. Setting this element to TRUE causes Amazon S3 to reject calls to // PUT Bucket policy if the specified bucket policy allows public access. // // Enabling this setting doesn't affect existing bucket policies. BlockPublicPolicy: aws.Bool(true), // Specifies whether Amazon S3 should restrict public bucket policies for this // bucket. Setting this element to TRUE restricts access to this bucket to only // AWS services and authorized users within this account if the bucket has a // public policy. // // Enabling this setting doesn't affect previously stored bucket policies, except // that public and cross-account access within any public bucket policy, including // non-public delegation to specific accounts, is blocked. RestrictPublicBuckets: aws.Bool(true), // Specifies whether Amazon S3 should ignore public ACLs for this bucket and // objects in this bucket. Setting this element to TRUE causes Amazon S3 to // ignore all public ACLs on this bucket and objects in this bucket. // // Enabling this setting doesn't affect the persistence of any existing ACLs // and doesn't prevent new public ACLs from being set. IgnorePublicAcls: aws.Bool(true), }, }) if err != nil { return errors.Wrapf(err, "failed to block public access for s3 bucket '%s'", bucketName) } log.Printf("\t\t\tBlocked public access") // Add a bucket policy to enable exports from Cloudwatch Logs. tmpPolicyResource := strings.Trim(filepath.Join(bucketName, req.S3BucketTempPrefix), "/") _, err = svc.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucketName), Policy: aws.String(fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [ { "Action": "s3:GetBucketAcl", "Effect": "Allow", "Resource": "arn:aws:s3:::%s", "Principal": { "Service": "logs.%s.amazonaws.com" } }, { "Action": "s3:PutObject" , "Effect": "Allow", "Resource": "arn:aws:s3:::%s/*", "Condition": { "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control" } }, "Principal": { "Service": "logs.%s.amazonaws.com" } } ] }`, bucketName, req.AwsCreds.Region, tmpPolicyResource, req.AwsCreds.Region )), }) if err != nil { return errors.Wrapf(err, "failed to put bucket policy for s3 bucket '%s'", bucketName) } log.Printf("\t\t\tUpdated bucket policy") } } log.Printf("\t%s\tBuckets setup.\n", tests.Success) } // Find the default VPC and associated subnets. Custom subnets outside of the default VPC are not currently supported. var subnetsIDs []string var vpcId string { log.Println("EC2 - Find Subnets") svc := ec2.New(req.awsSession()) var subnets []*ec2.Subnet if true { // len(req.ec2SubnetIds) == 0 { log.Println("\t\tFind all subnets are that default for each available AZ.") err := svc.DescribeSubnetsPages(&ec2.DescribeSubnetsInput{}, func(res *ec2.DescribeSubnetsOutput, lastPage bool) bool { for _, s := range res.Subnets { if *s.DefaultForAz { subnets = append(subnets, s) } } return !lastPage }) if err != nil { return errors.Wrap(err, "failed to find default subnets") } /*} else { log.Println("\t\tFind all subnets for the IDs provided.") err := svc.DescribeSubnetsPages(&ec2.DescribeSubnetsInput{ SubnetIds: aws.StringSlice(flags.Ec2SubnetIds), }, func(res *ec2.DescribeSubnetsOutput, lastPage bool) bool { for _, s := range res.Subnets { subnets = append(subnets, s) } return !lastPage }) if err != nil { return errors.Wrapf(err, "failed to find subnets: %s", strings.Join(flags.Ec2SubnetIds, ", ")) } else if len(flags.Ec2SubnetIds) != len(subnets) { return errors.Errorf("failed to find all subnets, expected %d, got %d", len(flags.Ec2SubnetIds) != len(subnets)) }*/ } if len(subnets) == 0 { return errors.New("failed to find any subnets, expected at least 1") } for _, s := range subnets { if s.VpcId == nil { continue } if vpcId == "" { vpcId = *s.VpcId } else if vpcId != *s.VpcId { return errors.Errorf("invalid subnet %s, all subnets should belong to the same VPC, expected %s, got %s", *s.SubnetId, vpcId, *s.VpcId) } subnetsIDs = append(subnetsIDs, *s.SubnetId) log.Printf("\t\t\t%s", *s.SubnetId) } log.Printf("\t\tFound %d subnets.\n", len(subnets)) } // Try to find the AWS Security Group by name or create a new one. var securityGroupId string { log.Println("EC2 - Find Security Group") svc := ec2.New(req.awsSession()) log.Printf("\t\tFind security group '%s'.\n", req.Ec2SecurityGroupName) err := svc.DescribeSecurityGroupsPages(&ec2.DescribeSecurityGroupsInput{ GroupNames: aws.StringSlice([]string{req.Ec2SecurityGroupName}), }, func(res *ec2.DescribeSecurityGroupsOutput, lastPage bool) bool { for _, s := range res.SecurityGroups { if *s.GroupName == req.Ec2SecurityGroupName { securityGroupId = *s.GroupId break } } return !lastPage }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != "InvalidGroup.NotFound" { return errors.Wrapf(err, "failed to find security group '%s'", req.Ec2SecurityGroupName) } } if securityGroupId == "" { // If no security group was found, create one. createRes, err := svc.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{ // The name of the security group. // Constraints: Up to 255 characters in length. Cannot start with sg-. // Constraints for EC2-Classic: ASCII characters // Constraints for EC2-VPC: a-z, A-Z, 0-9, spaces, and ._-:/()#,@[]+=&;{}!$* // GroupName is a required field GroupName: aws.String(req.Ec2SecurityGroupName), // A description for the security group. This is informational only. // Constraints: Up to 255 characters in length // Constraints for EC2-Classic: ASCII characters // Constraints for EC2-VPC: a-z, A-Z, 0-9, spaces, and ._-:/()#,@[]+=&;{}!$* // Description is a required field Description: aws.String(fmt.Sprintf("Security group for %s running on ECS cluster %s", req.ProjectName, req.EcsClusterName)), // [EC2-VPC] The ID of the VPC. Required for EC2-VPC. VpcId: aws.String(vpcId), }) if err != nil { return errors.Wrapf(err, "failed to create security group '%s'", req.Ec2SecurityGroupName) } securityGroupId = *createRes.GroupId log.Printf("\t\tCreated: %s.", req.Ec2SecurityGroupName) } else { log.Printf("\t\tFound: %s.", req.Ec2SecurityGroupName) } ingressInputs := []*ec2.AuthorizeSecurityGroupIngressInput{ // Enable services to be publicly available via HTTP port 80 &ec2.AuthorizeSecurityGroupIngressInput{ IpProtocol: aws.String("tcp"), CidrIp: aws.String("0.0.0.0/0"), FromPort: aws.Int64(80), ToPort: aws.Int64(80), GroupId: aws.String(securityGroupId), }, // Allow all services in the security group to access other services. &ec2.AuthorizeSecurityGroupIngressInput{ SourceSecurityGroupName: aws.String(req.Ec2SecurityGroupName), GroupId: aws.String(securityGroupId), }, } // When we are not using an Elastic Load Balancer, services need to support direct access via HTTPS. // HTTPS is terminated via the web server and not on the Load Balancer. if !req.EnableEcsElb { // Enable services to be publicly available via HTTPS port 443 ingressInputs = append(ingressInputs, &ec2.AuthorizeSecurityGroupIngressInput{ IpProtocol: aws.String("tcp"), CidrIp: aws.String("0.0.0.0/0"), FromPort: aws.Int64(443), ToPort: aws.Int64(443), GroupId: aws.String(securityGroupId), }) } // Add all the default ingress to the security group. for _, ingressInput := range ingressInputs { _, err = svc.AuthorizeSecurityGroupIngress(ingressInput) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != "InvalidPermission.Duplicate" { return errors.Wrapf(err, "failed to add ingress for security group '%s'", req.Ec2SecurityGroupName) } } } log.Printf("\t%s\tUsing Security Group '%s'.\n", tests.Success, req.Ec2SecurityGroupName) } // If a database cluster is defined, ensure it exists else create a new one. var dbCluster *rds.DBCluster if req.DBCluster != nil { log.Println("RDS - Get or Create Database Cluster") svc := rds.New(req.awsSession()) descRes, err := svc.DescribeDBClusters(&rds.DescribeDBClustersInput{ DBClusterIdentifier: req.DBCluster.DBClusterIdentifier, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != rds.ErrCodeDBClusterNotFoundFault { return errors.Wrapf(err, "failed to describe database cluster '%s'", *req.DBCluster.DBClusterIdentifier) } } else if len(descRes.DBClusters) > 0 { dbCluster = descRes.DBClusters[0] } if dbCluster == nil { // If no cluster was found, create one. createRes, err := svc.CreateDBCluster(req.DBCluster) if err != nil { return errors.Wrapf(err, "failed to create cluster '%s'", *req.DBCluster.DBClusterIdentifier) } dbCluster = createRes.DBCluster log.Printf("\t\tCreated: %s", *dbCluster.DBClusterArn) } else { log.Printf("\t\tFound: %s", *dbCluster.DBClusterArn) } // The status of the cluster. log.Printf("\t\t\tStatus: %s", *dbCluster.Status) log.Printf("\t%s\tUsing DB Cluster '%s'.\n", tests.Success, *dbCluster.DatabaseName) } // If a database instance is defined, ensure it exists else create a new one. var db *DB if req.DBInstance != nil { log.Println("RDS - Get or Create Database Instance") // Secret ID used to store the DB username and password across deploys. dbSecretId := filepath.Join(req.ProjectName, req.Env, *req.DBInstance.DBInstanceIdentifier) // Retrieve the current secret value if something is stored. { sm := secretsmanager.New(req.awsSession()) res, err := sm.GetSecretValue(&secretsmanager.GetSecretValueInput{ SecretId: aws.String(dbSecretId), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != secretsmanager.ErrCodeResourceNotFoundException { return errors.Wrapf(err, "failed to get value for secret id %s", dbSecretId) } } else { err = json.Unmarshal([]byte(*res.SecretString), &db) if err != nil { return errors.Wrap(err, "failed to json decode db credentials") } } } // Init a new RDS client. svc := rds.New(req.awsSession()) // Always set the VPC Security Group ID dynamically with the one created/found previously. req.DBInstance.VpcSecurityGroupIds = aws.StringSlice([]string{securityGroupId}) // When a DB cluster exists, add the identifier to the instance input. This is for creating databases with // the storage engine of AWS Aurora. if dbCluster != nil { req.DBInstance.DBClusterIdentifier = dbCluster.DBClusterIdentifier } else { req.DBInstance.DBClusterIdentifier = nil } // Try to find an existing DB instance with the same identifier. var dbInstance *rds.DBInstance descRes, err := svc.DescribeDBInstances(&rds.DescribeDBInstancesInput{ DBInstanceIdentifier: req.DBInstance.DBInstanceIdentifier, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != rds.ErrCodeDBInstanceNotFoundFault { return errors.Wrapf(err, "failed to describe database instance '%s'", *req.DBInstance.DBInstanceIdentifier) } } else if len(descRes.DBInstances) > 0 { dbInstance = descRes.DBInstances[0] } // No DB instance was found, so create a new one. if dbInstance == nil { if db == nil { // If master password is not set, pull from cluster or generate random. if req.DBInstance.MasterUserPassword == nil { if req.DBCluster.MasterUserPassword != nil && *req.DBCluster.MasterUserPassword != "" { req.DBInstance.MasterUserPassword = req.DBCluster.MasterUserPassword } else { req.DBInstance.MasterUserPassword = aws.String(uuid.NewRandom().String()) } } // Only set the password right now, all other details will be set after the database instance is created. db = &DB{ Pass: *req.DBInstance.MasterUserPassword, } // Store the secret first in the event that create fails. { // Json encode the db details to be stored as secret text. dat, err := json.Marshal(db) if err != nil { return errors.Wrap(err, "failed to marshal db credentials") } // Create the new entry in AWS Secret Manager with the database password. sm := secretsmanager.New(req.awsSession()) _, err = sm.CreateSecret(&secretsmanager.CreateSecretInput{ Name: aws.String(dbSecretId), SecretString: aws.String(string(dat)), }) if err != nil { /* if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != secretsmanager.ErrCodeResourceExistsException { return errors.Wrap(err, "failed to create new secret with db credentials") } _, err = sm.UpdateSecret(&secretsmanager.UpdateSecretInput{ SecretId: aws.String(dbSecretId), SecretString: aws.String(string(dat)), }) if err != nil { return errors.Wrap(err, "failed to update secret with db credentials") }*/ return errors.Wrap(err, "failed to create new secret with db credentials") } log.Printf("\t\tStored Secret\n") } } else { req.DBInstance.MasterUserPassword = aws.String(db.Pass) } // If no cluster was found, create one. createRes, err := svc.CreateDBInstance(req.DBInstance) if err != nil { return errors.Wrapf(err, "failed to create instance '%s'", *req.DBInstance.DBInstanceIdentifier) } dbInstance = createRes.DBInstance log.Printf("\t\tCreated: %s", *dbInstance.DBInstanceArn) } else { log.Printf("\t\tFound: %s", *dbInstance.DBInstanceArn) } // The status of the instance. log.Printf("\t\t\tStatus: %s", *dbInstance.DBInstanceStatus) // If the instance is not active as was recently created, wait for it to become active. if *dbInstance.DBInstanceStatus != "available" { log.Printf("\t\tWhat for instance to become available.") err = svc.WaitUntilDBInstanceAvailable(&rds.DescribeDBInstancesInput{ DBInstanceIdentifier: dbInstance.DBInstanceIdentifier, }) if err != nil { return errors.Wrapf(err, "failed to wait for database instance '%s' to enter available state", *req.DBInstance.DBInstanceIdentifier) } } // Update the secret with the db instance details. This happens after DB create to help address when the // db instance was successfully created, but the secret failed to save. The db details host should be empty or // match the current instance endpoint. curHost := fmt.Sprintf("%s:%d", *dbInstance.Endpoint.Address, *dbInstance.Endpoint.Port) if curHost != db.Host { // Copy the instance details to the db struct. db.Host = curHost db.User = *dbInstance.MasterUsername db.Database = *dbInstance.DBName db.Driver = *dbInstance.Engine db.DisableTLS = false // Json encode the db details to be stored as text via AWS Secrets Manager. dat, err := json.Marshal(db) if err != nil { return errors.Wrap(err, "failed to marshal db credentials") } // Update the current AWS Secret. sm := secretsmanager.New(req.awsSession()) _, err = sm.UpdateSecret(&secretsmanager.UpdateSecretInput{ SecretId: aws.String(dbSecretId), SecretString: aws.String(string(dat)), }) if err != nil { return errors.Wrap(err, "failed to update secret with db credentials") } log.Printf("\t\tUpdate Secret\n") } log.Printf("\t%s\tUsing DB Instance '%s'.\n", tests.Success, *dbInstance.DBInstanceIdentifier) } var cacheCluster *elasticache.CacheCluster if req.CacheCluster != nil { log.Println("Elastic Cache - Get or Create Cache Cluster") // Set the security group of the cache cluster req.CacheCluster.SecurityGroupIds = aws.StringSlice([]string{securityGroupId}) svc := elasticache.New(req.awsSession()) descRes, err := svc.DescribeCacheClusters(&elasticache.DescribeCacheClustersInput{ CacheClusterId: req.CacheCluster.CacheClusterId, ShowCacheNodeInfo: aws.Bool(true), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != elasticache.ErrCodeCacheClusterNotFoundFault { return errors.Wrapf(err, "failed to describe cache cluster '%s'", *req.CacheCluster.CacheClusterId) } } else if len(descRes.CacheClusters) > 0 { cacheCluster = descRes.CacheClusters[0] } if cacheCluster == nil { // If no repository was found, create one. createRes, err := svc.CreateCacheCluster(req.CacheCluster) if err != nil { return errors.Wrapf(err, "failed to create cluster '%s'", *req.CacheCluster.CacheClusterId) } cacheCluster = createRes.CacheCluster /* // TODO: Tag cache cluster, ARN for the cache cluster is not readly available. _, err = svc.AddTagsToResource(&elasticache.AddTagsToResourceInput{ ResourceName: ???, Tags: []*elasticache.Tag{ {Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, {Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create cluster '%s'", *req.CacheCluster.CacheClusterId) } */ log.Printf("\t\tCreated: %s", *cacheCluster.CacheClusterId) } else { log.Printf("\t\tFound: %s", *cacheCluster.CacheClusterId) } // The status of the cluster. log.Printf("\t\t\tStatus: %s", *cacheCluster.CacheClusterStatus) // If the cache cluster is not active as was recently created, wait for it to become active. if *cacheCluster.CacheClusterStatus != "available" { log.Printf("\t\tWhat for cluster to become available.") err = svc.WaitUntilCacheClusterAvailable(&elasticache.DescribeCacheClustersInput{ CacheClusterId: req.CacheCluster.CacheClusterId, }) if err != nil { return errors.Wrapf(err, "failed to wait for cache cluster '%s' to enter available state", req.CacheCluster.CacheClusterId) } } // If there are custom cache group parameters set, then create a new group and keep them modified. if len(req.CacheClusterParameter) > 0 { customCacheParameterGroupName := fmt.Sprintf("%s-%s%s", strings.ToLower(req.ProjectNameCamel()), *cacheCluster.Engine, *cacheCluster.EngineVersion) customCacheParameterGroupName = strings.Replace(customCacheParameterGroupName, ".", "-", -1) // If the cache cluster is using the default parameter group, create a new custom group. if strings.HasPrefix(*cacheCluster.CacheParameterGroup.CacheParameterGroupName, "default") { // Lookup the group family from the current cache parameter group. descRes, err := svc.DescribeCacheParameterGroups(&elasticache.DescribeCacheParameterGroupsInput{ CacheParameterGroupName: cacheCluster.CacheParameterGroup.CacheParameterGroupName, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != elasticache.ErrCodeCacheClusterNotFoundFault { return errors.Wrapf(err, "failed to describe cache parameter group '%s'", *req.CacheCluster.CacheClusterId) } } log.Printf("\t\tCreated custom Cache Parameter Group : %s", customCacheParameterGroupName) _, err = svc.CreateCacheParameterGroup(&elasticache.CreateCacheParameterGroupInput{ CacheParameterGroupFamily: descRes.CacheParameterGroups[0].CacheParameterGroupFamily, CacheParameterGroupName: aws.String(customCacheParameterGroupName), Description: aws.String(fmt.Sprintf("Customized default parameter group for %s %s", *cacheCluster.Engine, *cacheCluster.EngineVersion)), }) if err != nil { return errors.Wrapf(err, "failed to cache parameter group '%s'", customCacheParameterGroupName) } log.Printf("\t\tSet Cache Parameter Group : %s", customCacheParameterGroupName) updateRes, err := svc.ModifyCacheCluster(&elasticache.ModifyCacheClusterInput{ CacheClusterId: cacheCluster.CacheClusterId, CacheParameterGroupName: aws.String(customCacheParameterGroupName), }) if err != nil { return errors.Wrapf(err, "failed modify cache parameter group '%s' for cache cluster '%s'", customCacheParameterGroupName, *cacheCluster.CacheClusterId) } cacheCluster = updateRes.CacheCluster } // Only modify the cache parameter group if the cache cluster is custom one created to allow other groups to // be set on the cache cluster but not modified. if *cacheCluster.CacheParameterGroup.CacheParameterGroupName == customCacheParameterGroupName { log.Printf("\t\tUpdating Cache Parameter Group : %s", *cacheCluster.CacheParameterGroup.CacheParameterGroupName) _, err = svc.ModifyCacheParameterGroup(&elasticache.ModifyCacheParameterGroupInput{ CacheParameterGroupName: cacheCluster.CacheParameterGroup.CacheParameterGroupName, ParameterNameValues: req.CacheClusterParameter, }) if err != nil { return errors.Wrapf(err, "failed to modify cache parameter group '%s'", * cacheCluster.CacheParameterGroup.CacheParameterGroupName) } for _, p := range req.CacheClusterParameter { log.Printf("\t\t\tSet '%s' to '%s'", *p.ParameterName, *p.ParameterValue) } } } log.Printf("\t%s\tUsing Cache Cluster '%s'.\n", tests.Success, *cacheCluster.CacheClusterId) } // Try to find AWS ECS Cluster by name or create new one. var ecsCluster *ecs.Cluster { log.Println("ECS - Get or Create Cluster") svc := ecs.New(req.awsSession()) descRes, err := svc.DescribeClusters(&ecs.DescribeClustersInput{ Clusters: []*string{aws.String(req.EcsClusterName)}, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != ecs.ErrCodeClusterNotFoundException { return errors.Wrapf(err, "failed to describe cluster '%s'", req.EcsClusterName) } } else if len(descRes.Clusters) > 0 { ecsCluster = descRes.Clusters[0] } if ecsCluster == nil { // If no repository was found, create one. createRes, err := svc.CreateCluster(&ecs.CreateClusterInput{ ClusterName: aws.String(req.EcsClusterName), Tags: []*ecs.Tag{ &ecs.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &ecs.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create cluster '%s'", req.EcsClusterName) } ecsCluster = createRes.Cluster log.Printf("\t\tCreated: %s.", *ecsCluster.ClusterArn) } else { log.Printf("\t\tFound: %s.", *ecsCluster.ClusterArn) // The number of services that are running on the cluster in an ACTIVE state. // You can view these services with ListServices. log.Printf("\t\t\tActiveServicesCount: %d.", *ecsCluster.ActiveServicesCount) // The number of tasks in the cluster that are in the PENDING state. log.Printf("\t\t\tPendingTasksCount: %d.", *ecsCluster.PendingTasksCount) // The number of container instances registered into the cluster. This includes // container instances in both ACTIVE and DRAINING status. log.Printf("\t\t\tRegisteredContainerInstancesCount: %d.", *ecsCluster.RegisteredContainerInstancesCount) // The number of tasks in the cluster that are in the RUNNING state. log.Printf("\t\t\tRunningTasksCount: %d.", *ecsCluster.RunningTasksCount) } // The status of the cluster. The valid values are ACTIVE or INACTIVE. ACTIVE // indicates that you can register container instances with the cluster and // the associated instances can accept tasks. log.Printf("\t\t\tStatus: %s.", *ecsCluster.Status) log.Printf("\t%s\tUsing ECS Cluster '%s'.\n", tests.Success, *ecsCluster.ClusterName) } // Register a new ECS task. var taskDef *ecs.TaskDefinition { log.Println("ECS - Register task definition") // List of placeholders that can be used in task definition and replaced on deployment. placeholders := map[string]string{ "{SERVICE}": req.ServiceName, "{RELEASE_IMAGE}": req.ReleaseImage, "{ECS_CLUSTER}": req.EcsClusterName, "{ECS_SERVICE}": req.EcsServiceName, "{AWS_REGION}": req.AwsCreds.Region, "{AWSLOGS_GROUP}": req.CloudWatchLogGroupName, "{ENV}": req.Env, "{DATADOG_APIKEY}": datadogApiKey, "{DATADOG_ESSENTIAL}": "true", "{HTTP_HOST}": "0.0.0.0:80", "{HTTPS_HOST}": "", // Not enabled by default "{APP_BASE_URL}": "", // Not set by default, requires a hostname to be defined. "{CACHE_HOST}": "", // Not enabled by default "{DB_HOST}": "", "{DB_USER}": "", "{DB_PASS}": "", "{DB_DATABASE}": "", "{DB_DRIVER}": "", "{DB_DISABLE_TLS}": "", // Directly map GitLab CICD env variables set during deploy. "{CI_COMMIT_REF_NAME}": os.Getenv("CI_COMMIT_REF_NAME"), "{CI_COMMIT_REF_SLUG}": os.Getenv("CI_COMMIT_REF_SLUG"), "{CI_COMMIT_SHA}": os.Getenv("CI_COMMIT_SHA"), "{CI_COMMIT_TAG}": os.Getenv("CI_COMMIT_TAG"), "{CI_COMMIT_TITLE}": os.Getenv("CI_COMMIT_TITLE"), "{CI_COMMIT_DESCRIPTION}": os.Getenv("CI_COMMIT_DESCRIPTION"), "{CI_COMMIT_JOB_ID}": os.Getenv("CI_COMMIT_JOB_ID"), "{CI_COMMIT_JOB_URL}": os.Getenv("CI_COMMIT_JOB_URL"), "{CI_COMMIT_PIPELINE_ID}": os.Getenv("CI_COMMIT_PIPELINE_ID"), "{CI_COMMIT_PIPELINE_URL}": os.Getenv("CI_COMMIT_PIPELINE_URL"), } // When the datadog API key is empty, don't force the container to be essential have have the whole task fail. if datadogApiKey == "" { placeholders["{DATADOG_ESSENTIAL}"] = "false" } // When there is no Elastic Load Balancer, we need to terminate HTTPS on the app. if req.EnableHTTPS && !req.EnableEcsElb { placeholders["{HTTPS_HOST}"] = "0.0.0.0:443" } // When a domain name if defined for the service, set the App Base URL. Default to HTTPS if enabled. if req.ServiceDomainName != "" { var appSchema string if req.EnableHTTPS { appSchema = "https" } else { appSchema = "http" } placeholders["{APP_BASE_URL}"] = fmt.Sprintf("%s://%s/", appSchema, req.ServiceDomainName) } // When db is set, update the placeholders. if db != nil { placeholders["{DB_HOST}"] = db.Host placeholders["{DB_USER}"] = db.User placeholders["{DB_PASS}"] = db.Pass placeholders["{DB_DATABASE}"] = db.Database placeholders["{DB_DRIVER}"] = db.Driver if db.DisableTLS { placeholders["{DB_DISABLE_TLS}"] = "true" } else { placeholders["{DB_DISABLE_TLS}"] = "false" } } // When cache cluster is set, set the host and port. if cacheCluster != nil { var cacheHost string if cacheCluster.ConfigurationEndpoint != nil { // Works for memcache. cacheHost = fmt.Sprintf("%s:%d", *cacheCluster.ConfigurationEndpoint.Address, *cacheCluster.ConfigurationEndpoint.Port) } else if len(cacheCluster.CacheNodes) > 0 { // Works for redis. cacheHost = fmt.Sprintf("%s:%d", *cacheCluster.CacheNodes[0].Endpoint.Address, *cacheCluster.CacheNodes[0].Endpoint.Port) } else { return errors.New("Unable to determine cache host from cache cluster") } placeholders["{CACHE_HOST}"] = cacheHost } // Loop through all the placeholders and create a list of keys to search json. var pks []string for k, _ := range placeholders { pks = append(pks, k) } // Generate new regular expression for finding placeholders. expr := "(" + strings.Join(pks, "|") + ")" r, err := regexp.Compile(expr) if err != nil { return err } // Read the defined json task definition. dat, err := EcsReadTaskDefinition(req.ServiceDir, req.Env) if err != nil { return err } // Replace placeholders used in the JSON task definition. { jsonStr := string(dat) matches := r.FindAllString(jsonStr, -1) if len(matches) > 0 { log.Println("\t\tUpdating placeholders.") replaced := make(map[string]bool) for _, m := range matches { if replaced[m] { continue } replaced[m] = true newVal := placeholders[m] log.Printf("\t\t\t%s -> %s", m, newVal) jsonStr = strings.Replace(jsonStr, m, newVal, -1) } } dat = []byte(jsonStr) } //if flags.Debug { // log.Println(string(dat)) //} log.Println("\t\tParse JSON to task definition.") taskDefInput, err := parseTaskDefinitionInput(dat) if err != nil { return err } // If a task definition value is empty, populate it with the default value. if taskDefInput.Family == nil || *taskDefInput.Family == "" { taskDefInput.Family = &req.ServiceName } if len(taskDefInput.ContainerDefinitions) > 0 { if taskDefInput.ContainerDefinitions[0].Name == nil || *taskDefInput.ContainerDefinitions[0].Name == "" { taskDefInput.ContainerDefinitions[0].Name = &req.EcsServiceName } if taskDefInput.ContainerDefinitions[0].Image == nil || *taskDefInput.ContainerDefinitions[0].Image == "" { taskDefInput.ContainerDefinitions[0].Image = &req.ReleaseImage } } //if flags.Debug { // d, _ := json.Marshal(taskDef) // log.Println(string(d)) //} log.Printf("\t\t\tFamily: %s", *taskDefInput.Family) log.Printf("\t\t\tExecutionRoleArn: %s", *taskDefInput.ExecutionRoleArn) if taskDefInput.TaskRoleArn != nil { log.Printf("\t\t\tTaskRoleArn: %s", *taskDefInput.TaskRoleArn) } if taskDefInput.NetworkMode != nil { log.Printf("\t\t\tNetworkMode: %s", *taskDefInput.NetworkMode) } log.Printf("\t\t\tTaskDefinitions: %d", len(taskDefInput.ContainerDefinitions)) // If memory or cpu for the task is not set, need to compute from container definitions. if (taskDefInput.Cpu == nil || *taskDefInput.Cpu == "") || (taskDefInput.Memory == nil || *taskDefInput.Memory == "") { log.Println("\t\tCompute CPU and Memory for task definition.") var ( totalMemory int64 totalCpu int64 ) for _, c := range taskDefInput.ContainerDefinitions { if c.Memory != nil { totalMemory = totalMemory + *c.Memory } else if c.MemoryReservation != nil { totalMemory = totalMemory + *c.MemoryReservation } else { totalMemory = totalMemory + 1 } if c.Cpu != nil { totalCpu = totalCpu + *c.Cpu } else { totalCpu = totalCpu + 1 } } log.Printf("\t\t\tContainer Definitions has defined total memory %d and cpu %d", totalMemory, totalCpu) var ( selectedMemory int64 selectedCpu int64 ) if totalMemory < 8192 { if totalMemory > 7168 { selectedMemory = 8192 if totalCpu >= 2048 { selectedCpu = 4096 } else if totalCpu >= 1024 { selectedCpu = 2048 } else { selectedCpu = 1024 } } else if totalMemory > 6144 { selectedMemory = 7168 if totalCpu >= 2048 { selectedCpu = 4096 } else if totalCpu >= 1024 { selectedCpu = 2048 } else { selectedCpu = 1024 } } else if totalMemory > 5120 || totalCpu >= 1024 { selectedMemory = 6144 if totalCpu >= 2048 { selectedCpu = 4096 } else if totalCpu >= 1024 { selectedCpu = 2048 } else { selectedCpu = 1024 } } else if totalMemory > 4096 { selectedMemory = 5120 if totalCpu >= 512 { selectedCpu = 1024 } else { selectedCpu = 512 } } else if totalMemory > 3072 { selectedMemory = 4096 if totalCpu >= 512 { selectedCpu = 1024 } else { selectedCpu = 512 } } else if totalMemory > 2048 || totalCpu >= 512 { selectedMemory = 3072 if totalCpu >= 512 { selectedCpu = 1024 } else { selectedCpu = 512 } } else if totalMemory > 1024 || totalCpu >= 256 { selectedMemory = 2048 if totalCpu >= 256 { if totalCpu >= 512 { selectedCpu = 1024 } else { selectedCpu = 512 } } else { selectedCpu = 256 } } else if totalMemory > 512 { selectedMemory = 1024 if totalCpu >= 256 { selectedCpu = 512 } else { selectedCpu = 256 } } else { selectedMemory = 512 selectedCpu = 256 } } log.Printf("\t\t\tSelected memory %d and cpu %d", selectedMemory, selectedCpu) taskDefInput.Memory = aws.String(strconv.Itoa(int(selectedMemory))) taskDefInput.Cpu = aws.String(strconv.Itoa(int(selectedCpu))) } log.Printf("\t%s\tLoaded task definition complete.\n", tests.Success) // The execution role is the IAM role that executes ECS actions such as pulling the image and storing the // application logs in cloudwatch. if taskDefInput.ExecutionRoleArn == nil || *taskDefInput.ExecutionRoleArn == "" { svc := iam.New(req.awsSession()) /* res, err := svc.CreateServiceLinkedRole(&iam.CreateServiceLinkedRoleInput{ AWSServiceName: aws.String("ecs.amazonaws.com"), Description: aws.String(""), }) if err != nil { return errors.Wrapf(err, "failed to register task definition '%s'", *taskDef.Family) } taskDefInput.ExecutionRoleArn = res.Role.Arn */ // Find or create role for ExecutionRoleArn. { log.Printf("\tAppend ExecutionRoleArn to task definition input for role %s.", req.EcsExecutionRoleName) res, err := svc.GetRole(&iam.GetRoleInput{ RoleName: aws.String(req.EcsExecutionRoleName), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != iam.ErrCodeNoSuchEntityException { return errors.Wrapf(err, "failed to find task role '%s'", req.EcsExecutionRoleName) } } if res.Role != nil { taskDefInput.ExecutionRoleArn = res.Role.Arn log.Printf("\t\t\tFound role '%s'", *taskDefInput.ExecutionRoleArn) } else { // If no repository was found, create one. res, err := svc.CreateRole(&iam.CreateRoleInput{ RoleName: aws.String(req.EcsExecutionRoleName), Description: aws.String(fmt.Sprintf("Provides access to other AWS service resources that are required to run Amazon ECS tasks for %s. ", req.ProjectName)), AssumeRolePolicyDocument: aws.String("{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":[\"ecs-tasks.amazonaws.com\"]},\"Action\":[\"sts:AssumeRole\"]}]}"), Tags: []*iam.Tag{ &iam.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &iam.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create task role '%s'", req.EcsExecutionRoleName) } taskDefInput.ExecutionRoleArn = res.Role.Arn log.Printf("\t\t\tCreated role '%s'", *taskDefInput.ExecutionRoleArn) } policyArns := []string{ "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", } for _, policyArn := range policyArns { _, err = svc.AttachRolePolicy(&iam.AttachRolePolicyInput{ PolicyArn: aws.String(policyArn), RoleName: aws.String(req.EcsExecutionRoleName), }) if err != nil { return errors.Wrapf(err, "failed to attach policy '%s' to task role '%s'", policyArn, req.EcsExecutionRoleName) } log.Printf("\t\t\t\tAttached Policy '%s'", policyArn) } log.Printf("\t%s\tExecutionRoleArn updated.\n", tests.Success) } } // The task role is the IAM role used by the task itself to access other AWS Services. To access services // like S3, SQS, etc then those permissions would need to be covered by the TaskRole. if taskDefInput.TaskRoleArn == nil || *taskDefInput.TaskRoleArn == "" { svc := iam.New(req.awsSession()) // Find or create the default service policy. var policyArn string { log.Printf("\tFind default service policy %s.", req.EcsTaskPolicyName) var policyVersionId string err = svc.ListPoliciesPages(&iam.ListPoliciesInput{}, func(res *iam.ListPoliciesOutput, lastPage bool) bool { for _, p := range res.Policies { if *p.PolicyName == req.EcsTaskPolicyName { policyArn = *p.Arn policyVersionId = *p.DefaultVersionId return false } } return !lastPage }) if err != nil { return errors.Wrap(err, "failed to list IAM policies") } if policyArn != "" { log.Printf("\t\t\tFound policy '%s' versionId '%s'", policyArn, policyVersionId) res, err := svc.GetPolicyVersion(&iam.GetPolicyVersionInput{ PolicyArn: aws.String(policyArn), VersionId: aws.String(policyVersionId), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != iam.ErrCodeNoSuchEntityException { return errors.Wrapf(err, "failed to read policy '%s' version '%s'", req.EcsTaskPolicyName, policyVersionId) } } // The policy document returned in this structure is URL-encoded compliant with // RFC 3986 (https://tools.ietf.org/html/rfc3986). You can use a URL decoding // method to convert the policy back to plain JSON text. curJson, err := url.QueryUnescape(*res.PolicyVersion.Document) if err != nil { return errors.Wrapf(err, "failed to url unescape policy document - %s", string(*res.PolicyVersion.Document)) } // Compare policy documents and add any missing actions for each statement by matching Sid. var curDoc IamPolicyDocument err = json.Unmarshal([]byte(curJson), &curDoc) if err != nil { return errors.Wrapf(err, "failed to json decode policy document - %s", string(curJson)) } var updateDoc bool for _, baseStmt := range baseServicePolicyDocument.Statement { var found bool for curIdx, curStmt := range curDoc.Statement { if baseStmt.Sid != curStmt.Sid { continue } found = true for _, baseAction := range baseStmt.Action { var hasAction bool for _, curAction := range curStmt.Action { if baseAction == curAction { hasAction = true break } } if !hasAction { log.Printf("\t\t\t\tAdded new action %s for '%s'", curStmt.Sid) curStmt.Action = append(curStmt.Action, baseAction) curDoc.Statement[curIdx] = curStmt updateDoc = true } } } if !found { log.Printf("\t\t\t\tAdded new statement '%s'", baseStmt.Sid) curDoc.Statement = append(curDoc.Statement, baseStmt) updateDoc = true } } if updateDoc { dat, err := json.Marshal(curDoc) if err != nil { return errors.Wrap(err, "failed to json encode policy document") } _, err = svc.CreatePolicyVersion(&iam.CreatePolicyVersionInput{ PolicyArn: aws.String(policyArn), PolicyDocument: aws.String(string(dat)), SetAsDefault: aws.Bool(true), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != iam.ErrCodeNoSuchEntityException { return errors.Wrapf(err, "failed to read policy '%s' version '%s'", req.EcsTaskPolicyName, policyVersionId) } } } } else { dat, err := json.Marshal(baseServicePolicyDocument) if err != nil { return errors.Wrap(err, "failed to json encode policy document") } // If no repository was found, create one. res, err := svc.CreatePolicy(&iam.CreatePolicyInput{ PolicyName: aws.String(req.EcsTaskPolicyName), Description: aws.String(fmt.Sprintf("Defines access for %s services. ", req.ProjectName)), PolicyDocument: aws.String(string(dat)), }) if err != nil { return errors.Wrapf(err, "failed to create task policy '%s'", req.EcsTaskPolicyName) } policyArn = *res.Policy.Arn log.Printf("\t\t\tCreated policy '%s'", policyArn) } log.Printf("\t%s\tConfigured default service policy.\n", tests.Success) } // Find or create role for TaskRoleArn. { log.Printf("\tAppend TaskRoleArn to task definition input for role %s.", req.EcsTaskRoleName) res, err := svc.GetRole(&iam.GetRoleInput{ RoleName: aws.String(req.EcsTaskRoleName), }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != iam.ErrCodeNoSuchEntityException { return errors.Wrapf(err, "failed to find task role '%s'", req.EcsTaskRoleName) } } if res.Role != nil { taskDefInput.TaskRoleArn = res.Role.Arn log.Printf("\t\t\tFound role '%s'", *taskDefInput.TaskRoleArn) } else { // If no repository was found, create one. res, err := svc.CreateRole(&iam.CreateRoleInput{ RoleName: aws.String(req.EcsTaskRoleName), Description: aws.String(fmt.Sprintf("Allows ECS tasks for %s to call AWS services on your behalf.", req.ProjectName)), AssumeRolePolicyDocument: aws.String("{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":[\"ecs-tasks.amazonaws.com\"]},\"Action\":[\"sts:AssumeRole\"]}]}"), Tags: []*iam.Tag{ &iam.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &iam.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create task role '%s'", req.EcsTaskRoleName) } taskDefInput.TaskRoleArn = res.Role.Arn log.Printf("\t\t\tCreated role '%s'", *taskDefInput.TaskRoleArn) //_, err = svc.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{ // PolicyDocument: , // RoleName: aws.String(roleName), //}) //if err != nil { // return errors.Wrapf(err, "failed to create task role '%s'", roleName) //} } _, err = svc.AttachRolePolicy(&iam.AttachRolePolicyInput{ PolicyArn: aws.String(policyArn), RoleName: aws.String(req.EcsTaskRoleName), }) if err != nil { return errors.Wrapf(err, "failed to attach policy '%s' to task role '%s'", policyArn, req.EcsTaskRoleName) } log.Printf("\t%s\tTaskRoleArn updated.\n", tests.Success) } } log.Println("\tRegister new task definition.") { svc := ecs.New(req.awsSession()) // Registers a new task. res, err := svc.RegisterTaskDefinition(taskDefInput) if err != nil { return errors.Wrapf(err, "failed to register task definition '%s'", *taskDefInput.Family) } taskDef = res.TaskDefinition log.Printf("\t\tRegistered: %s.", *taskDef.TaskDefinitionArn) log.Printf("\t\t\tRevision: %d.", *taskDef.Revision) log.Printf("\t\t\tStatus: %s.", *taskDef.Status) log.Printf("\t%s\tTask definition registered.\n", tests.Success) } } // Try to find AWS ECS Service by name. This does not error on not found, but results are used to determine if // the full creation process of a service needs to be executed. var ecsService *ecs.Service { log.Println("ECS - Find Service") svc := ecs.New(req.awsSession()) res, err := svc.DescribeServices(&ecs.DescribeServicesInput{ Cluster: ecsCluster.ClusterArn, Services: []*string{aws.String(req.EcsServiceName)}, }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != ecs.ErrCodeServiceNotFoundException { return errors.Wrapf(err, "failed to describe service '%s'", req.EcsServiceName) } } else if len(res.Services) > 0 { ecsService = res.Services[0] log.Printf("\t\tFound: %s.", *ecsService.ServiceArn) // The desired number of instantiations of the task definition to keep running // on the service. This value is specified when the service is created with // CreateService, and it can be modified with UpdateService. log.Printf("\t\t\tDesiredCount: %d.", *ecsService.DesiredCount) // The number of tasks in the cluster that are in the PENDING state. log.Printf("\t\t\tPendingCount: %d.", *ecsService.PendingCount) // The number of tasks in the cluster that are in the RUNNING state. log.Printf("\t\t\tRunningCount: %d.", *ecsService.RunningCount) // The status of the service. The valid values are ACTIVE, DRAINING, or INACTIVE. log.Printf("\t\t\tStatus: %s.", *ecsService.Status) log.Printf("\t%s\tUsing ECS Service '%s'.\n", tests.Success, *ecsService.ServiceName) } else { log.Printf("\t%s\tExisting ECS Service not found.\n", tests.Success) } } // Check to see if the service should be re-created instead of updated. if ecsService != nil { var ( recreateService bool forceDelete bool ) if req.RecreateService { // Flag was included to force recreate. recreateService = true forceDelete = true } else if req.EnableEcsElb && (ecsService.LoadBalancers == nil || len(ecsService.LoadBalancers) == 0) { // Service was created with no ELB and now ELB is enabled. recreateService = true } else if !req.EnableEcsElb && (ecsService.LoadBalancers != nil && len(ecsService.LoadBalancers) > 0) { // Service was created with ELB and now ELB is disabled. recreateService = true } if recreateService { log.Println("ECS - Delete Service") svc := ecs.New(req.awsSession()) _, err := svc.DeleteService(&ecs.DeleteServiceInput{ Cluster: ecsService.ClusterArn, Service: ecsService.ServiceArn, // If true, allows you to delete a service even if it has not been scaled down // to zero tasks. It is only necessary to use this if the service is using the // REPLICA scheduling strategy. Force: aws.Bool(forceDelete), }) if err != nil { return errors.Wrapf(err, "failed to create security group '%s'", req.Ec2SecurityGroupName) } log.Printf("\t%s\tDelete Service.\n", tests.Success) } } // If the service exists update the service, else create a new service. if ecsService != nil && *ecsService.Status != "INACTIVE" { log.Println("ECS - Update Service") svc := ecs.New(req.awsSession()) // If the desired count is zero because it was spun down for termination of staging env, update to launch // with at least once task running for the service. desiredCount := *ecsService.DesiredCount if desiredCount == 0 { desiredCount = 1 } updateRes, err := svc.UpdateService(&ecs.UpdateServiceInput{ Cluster: ecsCluster.ClusterName, Service: ecsService.ServiceName, DesiredCount: aws.Int64(desiredCount), HealthCheckGracePeriodSeconds: ecsService.HealthCheckGracePeriodSeconds, TaskDefinition: taskDef.TaskDefinitionArn, // Whether to force a new deployment of the service. Deployments are not forced // by default. You can use this option to trigger a new deployment with no service // definition changes. For example, you can update a service's tasks to use // a newer Docker image with the same image/tag combination (my_image:latest) // or to roll Fargate tasks onto a newer platform version. ForceNewDeployment: aws.Bool(false), }) if err != nil { return errors.Wrapf(err, "failed to update service '%s'", *ecsService.ServiceName) } ecsService = updateRes.Service log.Printf("\t%s\tUpdated ECS Service '%s'.\n", tests.Success, *ecsService.ServiceName) } else { // If an Elastic Load Balancer is enabled, then ensure one exists else create one. var ecsELBs []*ecs.LoadBalancer if req.EnableEcsElb { var certificateArn string if req.EnableHTTPS { log.Println("ACM - Find Elastic Load Balance") svc := acm.New(req.awsSession()) err := svc.ListCertificatesPages(&acm.ListCertificatesInput{}, func(res *acm.ListCertificatesOutput, lastPage bool) bool { for _, cert := range res.CertificateSummaryList { if *cert.DomainName == req.ServiceDomainName { certificateArn = *cert.CertificateArn return false } } return !lastPage }) if err != nil { return errors.Wrapf(err, "failed to list certificates for '%s'", req.ServiceDomainName) } if certificateArn == "" { // Create hash of all the domain names to be used to mark unique requests. idempotencyToken := req.ServiceDomainName + "|" + strings.Join(req.ServiceDomainNameAliases, "|") idempotencyToken = fmt.Sprintf("%x", md5.Sum([]byte(idempotencyToken))) // If no certicate was found, create one. createRes, err := svc.RequestCertificate(&acm.RequestCertificateInput{ // Fully qualified domain name (FQDN), such as www.example.com, that you want // to secure with an ACM certificate. Use an asterisk (*) to create a wildcard // certificate that protects several sites in the same domain. For example, // *.example.com protects www.example.com, site.example.com, and images.example.com. // // The first domain name you enter cannot exceed 63 octets, including periods. // Each subsequent Subject Alternative Name (SAN), however, can be up to 253 // octets in length. // // DomainName is a required field DomainName: aws.String(req.ServiceDomainName), // Customer chosen string that can be used to distinguish between calls to RequestCertificate. // Idempotency tokens time out after one hour. Therefore, if you call RequestCertificate // multiple times with the same idempotency token within one hour, ACM recognizes // that you are requesting only one certificate and will issue only one. If // you change the idempotency token for each call, ACM recognizes that you are // requesting multiple certificates. IdempotencyToken: aws.String(idempotencyToken), // Currently, you can use this parameter to specify whether to add the certificate // to a certificate transparency log. Certificate transparency makes it possible // to detect SSL/TLS certificates that have been mistakenly or maliciously issued. // Certificates that have not been logged typically produce an error message // in a browser. For more information, see Opting Out of Certificate Transparency // Logging (https://docs.aws.amazon.com/acm/latest/userguide/acm-bestpractices.html#best-practices-transparency). Options: &acm.CertificateOptions{ CertificateTransparencyLoggingPreference: aws.String("DISABLED"), }, // Additional FQDNs to be included in the Subject Alternative Name extension // of the ACM certificate. For example, add the name www.example.net to a certificate // for which the DomainName field is www.example.com if users can reach your // site by using either name. The maximum number of domain names that you can // add to an ACM certificate is 100. However, the initial limit is 10 domain // names. If you need more than 10 names, you must request a limit increase. // For more information, see Limits (https://docs.aws.amazon.com/acm/latest/userguide/acm-limits.html). SubjectAlternativeNames: aws.StringSlice(req.ServiceDomainNameAliases), // The method you want to use if you are requesting a public certificate to // validate that you own or control domain. You can validate with DNS (https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-dns.html) // or validate with email (https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-email.html). // We recommend that you use DNS validation. ValidationMethod: aws.String("DNS"), }) if err != nil { return errors.Wrapf(err, "failed to create certiciate '%s'", req.ServiceDomainName) } certificateArn = *createRes.CertificateArn log.Printf("\t\tCreated certiciate '%s'", req.ServiceDomainName) } else { log.Printf("\t\tFound certiciate '%s'", req.ServiceDomainName) } log.Printf("\t%s\tUsing ACM Certicate '%s'.\n", tests.Success, certificateArn) } log.Println("EC2 - Find Elastic Load Balance") { svc := elbv2.New(req.awsSession()) var elb *elbv2.LoadBalancer err := svc.DescribeLoadBalancersPages(&elbv2.DescribeLoadBalancersInput{ Names: []*string{aws.String(req.ElbLoadBalancerName)}, }, func(res *elbv2.DescribeLoadBalancersOutput, lastPage bool) bool { for _, lb := range res.LoadBalancers { if *lb.LoadBalancerName == req.ElbLoadBalancerName { elb = lb return false } } return !lastPage }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != elbv2.ErrCodeLoadBalancerNotFoundException { return errors.Wrapf(err, "failed to describe load balancer '%s'", req.ElbLoadBalancerName) } } var curListeners []*elbv2.Listener if elb == nil { // If no repository was found, create one. createRes, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{ // The name of the load balancer. // This name must be unique per region per account, can have a maximum of 32 // characters, must contain only alphanumeric characters or hyphens, must not // begin or end with a hyphen, and must not begin with "internal-". // Name is a required field Name: aws.String(req.ElbLoadBalancerName), // [Application Load Balancers] The type of IP addresses used by the subnets // for your load balancer. The possible values are ipv4 (for IPv4 addresses) // and dualstack (for IPv4 and IPv6 addresses). IpAddressType: aws.String("dualstack"), // The nodes of an Internet-facing load balancer have public IP addresses. The // DNS name of an Internet-facing load balancer is publicly resolvable to the // public IP addresses of the nodes. Therefore, Internet-facing load balancers // can route requests from clients over the internet. // The nodes of an internal load balancer have only private IP addresses. The // DNS name of an internal load balancer is publicly resolvable to the private // IP addresses of the nodes. Therefore, internal load balancers can only route // requests from clients with access to the VPC for the load balancer. Scheme: aws.String("Internet-facing"), // [Application Load Balancers] The IDs of the security groups for the load // balancer. SecurityGroups: aws.StringSlice([]string{req.Ec2SecurityGroupName}), // The IDs of the public subnets. You can specify only one subnet per Availability // Zone. You must specify either subnets or subnet mappings. // [Application Load Balancers] You must specify subnets from at least two Availability // Zones. Subnets: aws.StringSlice(subnetsIDs), // The type of load balancer. Type: aws.String("application"), // One or more tags to assign to the load balancer. Tags: []*elbv2.Tag{ &elbv2.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &elbv2.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, }) if err != nil { return errors.Wrapf(err, "failed to create load balancer '%s'", req.ElbLoadBalancerName) } elb = createRes.LoadBalancers[0] log.Printf("\t\tCreated: %s.", *elb.LoadBalancerArn) } else { log.Printf("\t\tFound: %s.", *elb.LoadBalancerArn) // Search for existing listeners associated with the load balancer. res, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{ // The Amazon Resource Name (ARN) of the load balancer. LoadBalancerArn: elb.LoadBalancerArn, // There are two target groups, return both associated listeners if they exist. PageSize: aws.Int64(2), }) if err != nil { return errors.Wrapf(err, "failed to find listeners for load balancer '%s'", req.ElbLoadBalancerName) } curListeners = res.Listeners } // The state code. The initial state of the load balancer is provisioning. After // the load balancer is fully set up and ready to route traffic, its state is // active. If the load balancer could not be set up, its state is failed. log.Printf("\t\t\tState: %s.", *elb.State.Code) // Default target groups. targetGroupInputs := []*elbv2.CreateTargetGroupInput{ // Default target group for HTTP via port 80. &elbv2.CreateTargetGroupInput{ // The name of the target group. // This name must be unique per region per account, can have a maximum of 32 // characters, must contain only alphanumeric characters or hyphens, and must // not begin or end with a hyphen. // Name is a required field Name: aws.String(fmt.Sprintf("%s-http", *elb.LoadBalancerName)), // The port on which the targets receive traffic. This port is used unless you // specify a port override when registering the target. If the target is a Lambda // function, this parameter does not apply. Port: aws.Int64(80), // The protocol to use for routing traffic to the targets. For Application Load // Balancers, the supported protocols are HTTP and HTTPS. For Network Load Balancers, // the supported protocols are TCP, TLS, UDP, or TCP_UDP. A TCP_UDP listener // must be associated with a TCP_UDP target group. If the target is a Lambda // function, this parameter does not apply. Protocol: aws.String("HTTP"), // Indicates whether health checks are enabled. If the target type is lambda, // health checks are disabled by default but can be enabled. If the target type // is instance or ip, health checks are always enabled and cannot be disabled. HealthCheckEnabled: aws.Bool(true), // The approximate amount of time, in seconds, between health checks of an individual // target. For HTTP and HTTPS health checks, the range is 5–300 seconds. For // TCP health checks, the supported values are 10 and 30 seconds. If the target // type is instance or ip, the default is 30 seconds. If the target type is // lambda, the default is 35 seconds. HealthCheckIntervalSeconds: aws.Int64(30), // [HTTP/HTTPS health checks] The ping path that is the destination on the targets // for health checks. The default is /. HealthCheckPath: aws.String("/ping"), // The protocol the load balancer uses when performing health checks on targets. // For Application Load Balancers, the default is HTTP. For Network Load Balancers, // the default is TCP. The TCP protocol is supported for health checks only // if the protocol of the target group is TCP, TLS, UDP, or TCP_UDP. The TLS, // UDP, and TCP_UDP protocols are not supported for health checks. HealthCheckProtocol: aws.String("HTTP"), // The amount of time, in seconds, during which no response from a target means // a failed health check. For target groups with a protocol of HTTP or HTTPS, // the default is 5 seconds. For target groups with a protocol of TCP or TLS, // this value must be 6 seconds for HTTP health checks and 10 seconds for TCP // and HTTPS health checks. If the target type is lambda, the default is 30 // seconds. HealthCheckTimeoutSeconds: aws.Int64(5), // The number of consecutive health checks successes required before considering // an unhealthy target healthy. For target groups with a protocol of HTTP or // HTTPS, the default is 5. For target groups with a protocol of TCP or TLS, // the default is 3. If the target type is lambda, the default is 5. HealthyThresholdCount: aws.Int64(3), // The number of consecutive health check failures required before considering // a target unhealthy. For target groups with a protocol of HTTP or HTTPS, the // default is 2. For target groups with a protocol of TCP or TLS, this value // must be the same as the healthy threshold count. If the target type is lambda, // the default is 2. UnhealthyThresholdCount: aws.Int64(3), // [HTTP/HTTPS health checks] The HTTP codes to use when checking for a successful // response from a target. Matcher: &elbv2.Matcher{ HttpCode: aws.String("200"), }, // The type of target that you must specify when registering targets with this // target group. You can't specify targets for a target group using more than // one target type. // // * instance - Targets are specified by instance ID. This is the default // value. If the target group protocol is UDP or TCP_UDP, the target type // must be instance. // // * ip - Targets are specified by IP address. You can specify IP addresses // from the subnets of the virtual private cloud (VPC) for the target group, // the RFC 1918 range (10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16), and // the RFC 6598 range (100.64.0.0/10). You can't specify publicly routable // IP addresses. // // * lambda - The target groups contains a single Lambda function. TargetType: aws.String("ip"), // The identifier of the virtual private cloud (VPC). If the target is a Lambda // function, this parameter does not apply. VpcId: aws.String(vpcId), }, } // If HTTPS is enabled, then add the associated target group. if req.EnableHTTPS { // Default target group for HTTPS via port 443. targetGroupInputs = append(targetGroupInputs, &elbv2.CreateTargetGroupInput{ Name: aws.String(fmt.Sprintf("%s-https", *elb.LoadBalancerName)), Port: aws.Int64(443), Protocol: aws.String("HTTPS"), HealthCheckEnabled: aws.Bool(true), HealthCheckIntervalSeconds: aws.Int64(30), HealthCheckPath: aws.String("/ping"), HealthCheckProtocol: aws.String("HTTPS"), HealthCheckTimeoutSeconds: aws.Int64(5), HealthyThresholdCount: aws.Int64(3), UnhealthyThresholdCount: aws.Int64(3), Matcher: &elbv2.Matcher{ HttpCode: aws.String("200"), }, TargetType: aws.String("ip"), VpcId: aws.String(vpcId), }) } for _, targetGroupInput := range targetGroupInputs { var targetGroup *elbv2.TargetGroup err = svc.DescribeTargetGroupsPages(&elbv2.DescribeTargetGroupsInput{ LoadBalancerArn: elb.LoadBalancerArn, Names: []*string{aws.String(req.ElbLoadBalancerName)}, }, func(res *elbv2.DescribeTargetGroupsOutput, lastPage bool) bool { for _, tg := range res.TargetGroups { if *tg.TargetGroupName == *targetGroupInput.Name { targetGroup = tg return false } } return !lastPage }) if err != nil { if aerr, ok := err.(awserr.Error); !ok || aerr.Code() != elbv2.ErrCodeTargetGroupNotFoundException { return errors.Wrapf(err, "failed to describe target group '%s'", *targetGroupInput.Name) } } if targetGroup == nil { // If no target group was found, create one. createRes, err := svc.CreateTargetGroup(targetGroupInput) if err != nil { return errors.Wrapf(err, "failed to create target group '%s'", *targetGroupInput.Name) } targetGroup = createRes.TargetGroups[0] log.Printf("\t\tAdded target group: %s.", *targetGroup.TargetGroupArn) } else { log.Printf("\t\tHas target group: %s.", *targetGroup.TargetGroupArn) } ecsELBs = append(ecsELBs, &ecs.LoadBalancer{ // The name of the container (as it appears in a container definition) to associate // with the load balancer. ContainerName: aws.String(req.EcsServiceName), // The port on the container to associate with the load balancer. This port // must correspond to a containerPort in the service's task definition. Your // container instances must allow ingress traffic on the hostPort of the port // mapping. ContainerPort: targetGroup.Port, // The full Amazon Resource Name (ARN) of the Elastic Load Balancing target // group or groups associated with a service or task set. TargetGroupArn: targetGroup.TargetGroupArn, }) if req.ElbDeregistrationDelay != nil { // If no target group was found, create one. _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{ TargetGroupArn: targetGroup.TargetGroupArn, Attributes: []*elbv2.TargetGroupAttribute{ &elbv2.TargetGroupAttribute{ // The name of the attribute. Key: aws.String("deregistration_delay.timeout_seconds"), // The value of the attribute. Value: aws.String(strconv.Itoa(*req.ElbDeregistrationDelay)), }, }, }) if err != nil { return errors.Wrapf(err, "failed to modify target group '%s' attributes", *targetGroupInput.Name) } log.Printf("\t\t\tSet sttributes.") } var foundListener bool for _, cl := range curListeners { if cl.Port == targetGroupInput.Port { foundListener = true break } } if !foundListener { listenerInput := &elbv2.CreateListenerInput{ // The actions for the default rule. The rule must include one forward action // or one or more fixed-response actions. // // If the action type is forward, you specify a target group. The protocol of // the target group must be HTTP or HTTPS for an Application Load Balancer. // The protocol of the target group must be TCP, TLS, UDP, or TCP_UDP for a // Network Load Balancer. // // DefaultActions is a required field DefaultActions: []*elbv2.Action{ &elbv2.Action{ // The type of action. Each rule must include exactly one of the following types // of actions: forward, fixed-response, or redirect. // // Type is a required field Type: aws.String("forward"), // The Amazon Resource Name (ARN) of the target group. Specify only when Type // is forward. TargetGroupArn: targetGroup.TargetGroupArn, }, }, // The Amazon Resource Name (ARN) of the load balancer. // // LoadBalancerArn is a required field LoadBalancerArn: elb.LoadBalancerArn, // The port on which the load balancer is listening. // // Port is a required field Port: targetGroup.Port, // The protocol for connections from clients to the load balancer. For Application // Load Balancers, the supported protocols are HTTP and HTTPS. For Network Load // Balancers, the supported protocols are TCP, TLS, UDP, and TCP_UDP. // // Protocol is a required field Protocol: targetGroup.Protocol, } if *listenerInput.Protocol == "HTTPS" { listenerInput.Certificates = append(listenerInput.Certificates, &elbv2.Certificate{ CertificateArn: aws.String(certificateArn), IsDefault: aws.Bool(true), }) } // If no repository was found, create one. createRes, err := svc.CreateListener(listenerInput) if err != nil { return errors.Wrapf(err, "failed to create listener '%s'", req.ElbLoadBalancerName) } log.Printf("\t\t\tAdded Listener: %s.", *createRes.Listeners[0].ListenerArn) } } log.Printf("\t%s\tUsing ELB '%s'.\n", tests.Success, *elb.LoadBalancerName) } } log.Println("ECS - Create Service") { svc := ecs.New(req.awsSession()) var assignPublicIp *string var healthCheckGracePeriodSeconds *int64 if len(ecsELBs) == 0 { assignPublicIp = aws.String("ENABLED") } else { assignPublicIp = aws.String("DISABLED") healthCheckGracePeriodSeconds = req.EscServiceHealthCheckGracePeriodSeconds } serviceInput := &ecs.CreateServiceInput{ // The short name or full Amazon Resource Name (ARN) of the cluster that your // service is running on. If you do not specify a cluster, the default cluster // is assumed. Cluster: ecsCluster.ClusterName, // The name of your service. Up to 255 letters (uppercase and lowercase), numbers, // and hyphens are allowed. Service names must be unique within a cluster, but // you can have similarly named services in multiple clusters within a Region // or across multiple Regions. // // ServiceName is a required field ServiceName: aws.String(req.EcsServiceName), // Optional deployment parameters that control how many tasks run during the // deployment and the ordering of stopping and starting tasks. DeploymentConfiguration: &ecs.DeploymentConfiguration{ // Refer to documentation for flags.ecsServiceMaximumPercent MaximumPercent: req.EcsServiceMaximumPercent, // Refer to documentation for flags.ecsServiceMinimumHealthyPercent MinimumHealthyPercent: req.EcsServiceMinimumHealthyPercent, }, // Refer to documentation for flags.ecsServiceDesiredCount. DesiredCount: aws.Int64(req.EcsServiceDesiredCount), // Specifies whether to enable Amazon ECS managed tags for the tasks within // the service. For more information, see Tagging Your Amazon ECS Resources // (https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-using-tags.html) // in the Amazon Elastic Container Service Developer Guide. EnableECSManagedTags: aws.Bool(false), // The period of time, in seconds, that the Amazon ECS service scheduler should // ignore unhealthy Elastic Load Balancing target health checks after a task // has first started. This is only valid if your service is configured to use // a load balancer. If your service's tasks take a while to start and respond // to Elastic Load Balancing health checks, you can specify a health check grace // period of up to 2,147,483,647 seconds. During that time, the ECS service // scheduler ignores health check status. This grace period can prevent the // ECS service scheduler from marking tasks as unhealthy and stopping them before // they have time to come up. HealthCheckGracePeriodSeconds: healthCheckGracePeriodSeconds, // The launch type on which to run your service. For more information, see Amazon // ECS Launch Types (https://docs.aws.amazon.com/AmazonECS/latest/developerguide/launch_types.html) // in the Amazon Elastic Container Service Developer Guide. LaunchType: aws.String("FARGATE"), // A load balancer object representing the load balancer to use with your service. LoadBalancers: ecsELBs, // The network configuration for the service. This parameter is required for // task definitions that use the awsvpc network mode to receive their own elastic // network interface, and it is not supported for other network modes. For more // information, see Task Networking (https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-networking.html) // in the Amazon Elastic Container Service Developer Guide. NetworkConfiguration: &ecs.NetworkConfiguration{ AwsvpcConfiguration: &ecs.AwsVpcConfiguration{ // Whether the task's elastic network interface receives a public IP address. // The default value is DISABLED. AssignPublicIp: assignPublicIp, // The security groups associated with the task or service. If you do not specify // a security group, the default security group for the VPC is used. There is // a limit of 5 security groups that can be specified per AwsVpcConfiguration. // All specified security groups must be from the same VPC. SecurityGroups: aws.StringSlice([]string{securityGroupId}), // The subnets associated with the task or service. There is a limit of 16 subnets // that can be specified per AwsVpcConfiguration. // All specified subnets must be from the same VPC. // Subnets is a required field Subnets: aws.StringSlice(subnetsIDs), }, }, // The family and revision (family:revision) or full ARN of the task definition // to run in your service. If a revision is not specified, the latest ACTIVE // revision is used. If you modify the task definition with UpdateService, Amazon // ECS spawns a task with the new version of the task definition and then stops // an old task after the new version is running. TaskDefinition: taskDef.TaskDefinitionArn, // The metadata that you apply to the service to help you categorize and organize // them. Each tag consists of a key and an optional value, both of which you // define. When a service is deleted, the tags are deleted as well. Tag keys // can have a maximum character length of 128 characters, and tag values can // have a maximum length of 256 characters. Tags: []*ecs.Tag{ &ecs.Tag{Key: aws.String(awsTagNameProject), Value: aws.String(req.ProjectName)}, &ecs.Tag{Key: aws.String(awsTagNameEnv), Value: aws.String(req.Env)}, }, } createRes, err := svc.CreateService(serviceInput) // If tags aren't enabled for the account, try the request again without them. // https://aws.amazon.com/blogs/compute/migrating-your-amazon-ecs-deployment-to-the-new-arn-and-resource-id-format-2/ if err != nil && strings.Contains(err.Error(), "new ARN and resource ID format must be enabled") { serviceInput.Tags = nil createRes, err = svc.CreateService(serviceInput) } if err != nil { return errors.Wrapf(err, "failed to create service '%s'", req.EcsServiceName) } ecsService = createRes.Service log.Printf("\t%s\tCreated ECS Service '%s'.\n", tests.Success, *ecsService.ServiceName) } } // Wait for the updated or created service to enter a stable state. { log.Println("\tWaiting for service to enter stable state.") // Helper method to get the logs from cloudwatch for a specific task ID. getTaskLogs := func(taskId string) ([]string, error) { if req.S3BucketPrivateName == "" { // No private S3 bucket defined so unable to export logs streams. return []string{}, nil } // Stream name generated by ECS for the awslogs driver. logStreamName := fmt.Sprintf("ecs/%s/%s", *ecsService.ServiceName, taskId) // Define s3 key prefix used to export the stream logs to. s3KeyPrefix := filepath.Join(req.S3BucketTempPrefix, "logs/cloudwatchlogs/exports", req.CloudWatchLogGroupName) var downloadPrefix string { svc := cloudwatchlogs.New(req.awsSession()) createRes, err := svc.CreateExportTask(&cloudwatchlogs.CreateExportTaskInput{ LogGroupName: aws.String(req.CloudWatchLogGroupName), LogStreamNamePrefix: aws.String(logStreamName), //TaskName: aws.String(taskId), Destination: aws.String( req.S3BucketPrivateName), DestinationPrefix: aws.String(s3KeyPrefix), From: aws.Int64(startTime.UTC().AddDate(0, 0, -1).UnixNano() / int64(time.Millisecond)), To: aws.Int64(time.Now().UTC().AddDate(0, 0, 1).UnixNano() / int64(time.Millisecond)), }) if err != nil { return []string{}, errors.Wrapf(err, "failed to create export task for from log group '%s' with stream name prefix '%s'", req.CloudWatchLogGroupName, logStreamName) } exportTaskId := *createRes.TaskId for { descRes, err := svc.DescribeExportTasks(&cloudwatchlogs.DescribeExportTasksInput{ TaskId: aws.String(exportTaskId), }) if err != nil { return []string{}, errors.Wrapf(err, "failed to describe export task '%s' for from log group '%s' with stream name prefix '%s'", exportTaskId, req.CloudWatchLogGroupName, logStreamName) } taskStatus := *descRes.ExportTasks[0].Status.Code if taskStatus == "COMPLETED" { downloadPrefix = filepath.Join(s3KeyPrefix, exportTaskId) + "/" break } else if taskStatus == "CANCELLED" || taskStatus == "FAILED" { break } time.Sleep(time.Second * 5) } } var logLines []string if downloadPrefix != "" { svc := s3.New(req.awsSession()) var s3Keys []string err := svc.ListObjectsPages(&s3.ListObjectsInput{ Bucket: aws.String(req.S3BucketPrivateName), Prefix: aws.String(downloadPrefix), }, func(res *s3.ListObjectsOutput, lastPage bool) bool { for _, obj := range res.Contents { s3Keys = append(s3Keys, *obj.Key) } return !lastPage }) if err != nil { return []string{}, errors.Wrapf(err, "failed to list objects from s3 bucket '%s' with prefix '%s'", req.S3BucketPrivateName, downloadPrefix) } for _, s3Key := range s3Keys { res, err := svc.GetObject(&s3.GetObjectInput{ Bucket: aws.String(req.S3BucketPrivateName), Key: aws.String(s3Key), }) if err != nil { return []string{}, errors.Wrapf(err, "failed to get object '%s' from s3 bucket", s3Key, req.S3BucketPrivateName) } r,_ := gzip.NewReader(res.Body) dat, err := ioutil.ReadAll(r) res.Body.Close() if err != nil { return []string{}, errors.Wrapf(err, "failed to read object '%s' from s3 bucket", s3Key, req.S3BucketPrivateName) } for _, l := range strings.Split(string(dat), "\n") { l = strings.TrimSpace(l) if l == "" { continue } logLines = append(logLines, l) } } } return logLines, nil } // Helper method to display tasks errors that failed to start while we wait for the service to stable state. taskLogLines := make(map[string][]string) checkTasks := func() (bool, error) { svc := ecs.New(req.awsSession()) serviceTaskRes, err := svc.ListTasks(&ecs.ListTasksInput{ Cluster: aws.String(req.EcsClusterName), ServiceName: aws.String(req.EcsServiceName), DesiredStatus: aws.String("STOPPED"), }) if err != nil { return false, errors.Wrapf(err, "failed to list tasks for cluster '%s' service '%s'", req.EcsClusterName, req.EcsServiceName) } if len(serviceTaskRes.TaskArns) == 0 { return false, nil } taskRes, err := svc.DescribeTasks(&ecs.DescribeTasksInput{ Cluster: aws.String(req.EcsClusterName), Tasks: serviceTaskRes.TaskArns, }) if err != nil { return false, errors.Wrapf(err, "failed to describe %d tasks for cluster '%s'", len(serviceTaskRes.TaskArns), req.EcsClusterName) } var failures []*ecs.Failure var stoppedCnt int64 for _, t := range taskRes.Tasks { if *t.TaskDefinitionArn != *taskDef.TaskDefinitionArn || t.TaskArn == nil { continue } stoppedCnt = stoppedCnt + 1 taskId := filepath.Base(*t.TaskArn) log.Printf("\t\t\tTask %s stopped\n", *t.TaskArn) for _, tc := range t.Containers { if tc.ExitCode != nil && tc.Reason != nil { log.Printf("\t\t\tContainer %s exited with %d - %s.\n", *tc.Name, *tc.ExitCode, *tc.Reason) } else if tc.ExitCode != nil { log.Printf("\t\t\tContainer %s exited with %d.\n", *tc.Name, *tc.ExitCode) } else { log.Printf("\t\t\tContainer %s exited.\n", *tc.Name) } } // Avoid exporting the logs multiple times. logLines, ok := taskLogLines[taskId] if !ok { logLines, err = getTaskLogs(taskId) if err != nil { return false, errors.Wrapf(err, "failed to get logs for task %s for cluster '%s'", *t.TaskArn, req.EcsClusterName) } taskLogLines[taskId] = logLines } if len(logLines) > 0 { log.Printf("\t\t\tTask Logs:\n") for _, l := range logLines { log.Printf("\t\t\t\t%s\n", l) } } log.Printf("\t%s\tTask %s failed with %s - %s.\n", tests.Failed, *t.TaskArn, *t.StopCode, *t.StoppedReason) // Limit failures to only the current task definition. for _, f := range taskRes.Failures { if *f.Arn == *t.TaskArn { failures = append(failures, f) } } } if len(failures) > 0 { for _, t := range failures { log.Printf("\t%s\tTask %s failed with %s.\n", tests.Failed, *t.Arn, *t.Reason) } } // If the number of stopped tasks with the current task def match the desired count for the service, // then we no longer need to continue to check the status of the tasks. if stoppedCnt == *ecsService.DesiredCount { return true, nil } return false, nil } // New wait group with only a count of one, this will allow the first go worker to exit to cancel both. checkErr := make(chan error, 1) // Check the status of the service tasks and print out info for debugging. ticker := time.NewTicker(10 * time.Second) go func() { for { select { case <- ticker.C: stop, err := checkTasks() if err != nil { log.Printf("\t%s\tFailed to check tasks.\n%+v\n", tests.Failed, err) } if stop { checkErr <- errors.New("all tasks for service are stopped") return } } } }() // Use the AWS ECS method to check for the service to be stable. go func() { svc := ecs.New(req.awsSession()) err := svc.WaitUntilServicesStable(&ecs.DescribeServicesInput{ Cluster: ecsCluster.ClusterArn, Services: aws.StringSlice([]string{*ecsService.ServiceArn}), }) if err != nil { checkErr <- errors.Wrapf(err, "failed to wait for service '%s' to enter stable state", *ecsService.ServiceName) } else { // All done. checkErr <- nil } }() if err := <-checkErr; err != nil { log.Printf("\t%s\tFailed to check tasks.\n%+v\n", tests.Failed, err) return nil } // Wait for one of the methods to finish and then ensure the ticker is stopped. ticker.Stop() log.Printf("\t%s\tService running.\n", tests.Success) } // Route 53 zone lookup when hostname is set. Supports both top level domains or sub domains. var zoneArecNames = map[string][]string{} if req.ServiceDomainName != "" { log.Println("Route 53 - Get or create hosted zones.") svc := route53.New(req.awsSession()) log.Println("\tList all hosted zones.") var zones []*route53.HostedZone err := svc.ListHostedZonesPages(&route53.ListHostedZonesInput{}, func(res *route53.ListHostedZonesOutput, lastPage bool) bool { for _, z := range res.HostedZones { zones = append(zones, z) } return !lastPage }) if err != nil { return errors.Wrap(err, "failed list route 53 hosted zones") } // Generate a slice with the primary domain name and include all the alternative domain names. lookupDomains := []string{req.ServiceDomainName} for _, dn := range req.ServiceDomainNameAliases { lookupDomains = append(lookupDomains, dn) } // Loop through all the defined domain names and find the associated zone even when they are a sub domain. for _, dn := range lookupDomains { log.Printf("\t\tFind zone for domain '%s'", dn) // Get the top level domain from url. zoneName := domainutil.Domain(dn) log.Printf("\t\t\tTop Level Domain: '%s'", zoneName) // Check if url has subdomain. var subdomain string if domainutil.HasSubdomain(dn) { subdomain = domainutil.Subdomain(dn) log.Printf("\t\t\tsubdomain: '%s'", subdomain) } // Start at the top level domain and try to find a hosted zone. Search until a match is found or there are // no more domain levels to search for. var zoneId string for { log.Printf("\t\t\tChecking zone '%s' for associated hosted zone.", zoneName) // Loop over each one of hosted zones and try to find match. for _, z := range zones { log.Printf("\t\t\t\tChecking if %s matches %s", *z.Name, zoneName) if strings.TrimRight(*z.Name, ".") == zoneName { zoneId = *z.Id break } } if zoneId != "" || zoneName == dn { // Found a matching zone or have search all possibilities! break } // If we have not found a hosted zone, append the next level from the domain to the zone. pts := strings.Split(subdomain, ".") subs := []string{} for idx, sn := range pts { if idx == len(pts)-1 { zoneName = sn + "." + zoneName } else { subs = append(subs, sn) } } subdomain = strings.Join(subs, ".") } var aName string if zoneId == "" { // Get the top level domain from url again. zoneName := domainutil.Domain(dn) log.Printf("\t\t\tNo hosted zone found for '%s', create '%s'.", dn, zoneName) createRes, err := svc.CreateHostedZone(&route53.CreateHostedZoneInput{ Name: aws.String(zoneName), HostedZoneConfig: &route53.HostedZoneConfig{ Comment: aws.String(fmt.Sprintf("Public hosted zone created by saas-starter-kit.")), }, // A unique string that identifies the request and that allows failed CreateHostedZone // requests to be retried without the risk of executing the operation twice. // You must use a unique CallerReference string every time you submit a CreateHostedZone // request. CallerReference can be any unique string, for example, a date/time // stamp. // // CallerReference is a required field CallerReference: aws.String("truss-deploy"), }) if err != nil { return errors.Wrapf(err, "failed to create route 53 hosted zone '%s' for domain '%s'", zoneName, dn) } zoneId = *createRes.HostedZone.Id log.Printf("\t\t\tCreated hosted zone '%s'", zoneId) // The fully qualified A record name. aName = dn } else { log.Printf("\t\t\tFound hosted zone '%s'", zoneId) // The fully qualified A record name. if subdomain != "" { aName = subdomain + "." + zoneName } else { aName = zoneName } } // Add the A record to be maintained for the zone. if _, ok := zoneArecNames[zoneId]; !ok { zoneArecNames[zoneId] = []string{} } zoneArecNames[zoneId] = append(zoneArecNames[zoneId], aName) log.Printf("\t%s\tZone '%s' found with A record name '%s'.\n", tests.Success, zoneId, aName) } } if req.EnableEcsElb { // TODO: Need to connect ELB to route53 } else { log.Println("\tFind network interface IDs for running tasks.") var networkInterfaceIds []string { svc := ecs.New(req.awsSession()) log.Println("\t\tFind tasks for service.") servceTaskRes, err := svc.ListTasks(&ecs.ListTasksInput{ Cluster: aws.String(req.EcsClusterName), ServiceName: aws.String(req.EcsServiceName), }) if err != nil { return errors.Wrapf(err, "failed to list tasks for cluster '%s' service '%s'", req.EcsClusterName, req.EcsServiceName) } log.Println("\t\tDescribe tasks for service.") taskRes, err := svc.DescribeTasks(&ecs.DescribeTasksInput{ Cluster: aws.String(req.EcsClusterName), Tasks: servceTaskRes.TaskArns, }) if err != nil { return errors.Wrapf(err, "failed to describe %d tasks for cluster '%s'", len(servceTaskRes.TaskArns), req.EcsClusterName) } var failures []*ecs.Failure var taskArns []string for _, t := range taskRes.Tasks { if t.TaskDefinitionArn != taskDef.TaskDefinitionArn { continue } taskArns = append(taskArns, *t.TaskArn) // Limit failures to only the current task definition. for _, f := range taskRes.Failures { if *f.Arn == *t.TaskArn { failures = append(failures, f) } } } if len(failures) > 0 { for _, t := range failures { log.Printf("\t%s\tTask %s failed with %s.\n", tests.Failed, *t.Arn, *t.Reason) } } else { log.Printf("\t%s\tTasks founds.\n", tests.Success) } log.Println("\t\tWaiting for tasks to enter running state.") { var err error err = svc.WaitUntilTasksRunning(&ecs.DescribeTasksInput{ Cluster: ecsCluster.ClusterArn, Tasks: aws.StringSlice(taskArns), }) if err != nil { return errors.Wrapf(err, "failed to wait for tasks to enter running state for cluster '%s'", req.EcsClusterName) } log.Printf("\t%s\tTasks running.\n", tests.Success) } log.Println("\t\tDescribe tasks for running tasks.") { taskRes, err := svc.DescribeTasks(&ecs.DescribeTasksInput{ Cluster: aws.String(req.EcsClusterName), Tasks: aws.StringSlice(taskArns), }) if err != nil { return errors.Wrapf(err, "failed to describe %d running tasks for cluster '%s'", len(taskArns), req.EcsClusterName) } for _, t := range taskRes.Tasks { if t.Attachments == nil { continue } for _, a := range t.Attachments { if a.Details == nil { continue } for _, ad := range a.Details { if ad.Name != nil && *ad.Name == "networkInterfaceId" { networkInterfaceIds = append(networkInterfaceIds, *ad.Value) } } } } log.Printf("\t%s\tFound %d network interface IDs.\n", tests.Success, len(networkInterfaceIds)) } } log.Println("\tGet public IPs for network interface IDs.") var publicIps []string { svc := ec2.New(req.awsSession()) log.Println("\t\tDescribe network interfaces.") res, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ NetworkInterfaceIds: aws.StringSlice(networkInterfaceIds), }) if err != nil { return errors.Wrap(err, "failed to describe network interfaces") } for _, ni := range res.NetworkInterfaces { if ni.Association == nil || ni.Association.PublicIp == nil { continue } publicIps = append(publicIps, *ni.Association.PublicIp) } log.Printf("\t%s\tFound %d public IPs.\n", tests.Success, len(publicIps)) } log.Println("\tUpdate public IPs for hosted zones.") { svc := route53.New(req.awsSession()) // Public IPs to be served as round robin. log.Printf("\t\tPublic IPs:\n") rrs := []*route53.ResourceRecord{} for _, ip := range publicIps { log.Printf("\t\t\t%s\n", ip) rrs = append(rrs, &route53.ResourceRecord{Value: aws.String(ip)}) } for zoneId, aNames := range zoneArecNames { log.Printf("\t\tChange zone '%s'.\n", zoneId) input := &route53.ChangeResourceRecordSetsInput{ ChangeBatch: &route53.ChangeBatch{ Changes: []*route53.Change{}, }, HostedZoneId: aws.String(zoneId), } // Add all the A record names with the same set of public IPs. for _, aName := range aNames { log.Printf("\t\t\tAdd A record for '%s'.\n", aName) input.ChangeBatch.Changes = append(input.ChangeBatch.Changes, &route53.Change{ Action: aws.String("UPSERT"), ResourceRecordSet: &route53.ResourceRecordSet{ Name: aws.String(aName), ResourceRecords: rrs, TTL: aws.Int64(60), Type: aws.String("A"), }, }) } _, err := svc.ChangeResourceRecordSets(input) if err != nil { return errors.Wrapf(err, "failed to update A records for zone '%s'", zoneId) } } log.Printf("\t%s\tDNS entries updated.\n", tests.Success) } } return nil }