├── .gitignore ├── .golangci.yml ├── Makefile ├── README.md ├── circle.yml ├── cmd └── ecs-task-runner │ └── main.go ├── conf └── config.go ├── ecs-task-runner.go ├── go.mod ├── go.sum ├── internal ├── aws │ ├── cloudwatchlogs.go │ ├── ec2.go │ ├── ecs.go │ ├── iam.go │ ├── secretsmanager.go │ ├── session.go │ ├── session_test.go │ └── vpc.go ├── log │ ├── utils.go │ └── utils_test.go └── util │ └── functions.go ├── output.go └── versions ├── 1.1 └── Dockerfile ├── 1.2 └── Dockerfile ├── 2.0 └── Dockerfile ├── 2.1 └── Dockerfile ├── 2.2 └── Dockerfile ├── 2.3 └── Dockerfile └── 3.0 └── Dockerfile /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/ecs-task-runner/dist 2 | vendor 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 3m 3 | issues-exit-code: 1 4 | tests: false 5 | skip-dirs: 6 | - generated 7 | 8 | output: 9 | format: colored-line-number # colored-line-number|line-number|json|tab|checkstyle 10 | print-issued-lines: true 11 | print-linter-name: true 12 | 13 | linters: 14 | disable-all: true 15 | enable: 16 | - govet 17 | - errcheck 18 | - staticcheck 19 | - unused 20 | - gosimple 21 | - structcheck 22 | - varcheck 23 | - ineffassign 24 | - deadcode 25 | - typecheck 26 | # default disabled 27 | - golint 28 | - gosec 29 | - unconvert 30 | - goconst 31 | - goimports 32 | - maligned 33 | - megacheck 34 | - misspell 35 | - nakedret 36 | - prealloc 37 | - scopelint 38 | - gocritic 39 | 40 | linters-settings: 41 | govet: 42 | check-shadowing: true 43 | errcheck: 44 | check-type-assertions: false 45 | check-blank: false 46 | unused: 47 | check-exported: false 48 | golint: 49 | min-confidence: 0.8 50 | goconst: 51 | min-len: 3 52 | min-occurrences: 3 53 | maligned: 54 | suggest-new: true 55 | misspell: 56 | locale: US 57 | nakedret: 58 | max-func-lines: 30 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all run start stop deps test build 2 | 3 | all: build 4 | 5 | run: 6 | @docker run --rm -it -v "${GOPATH}":/go \ 7 | -v "${HOME}/.aws":/root/.aws \ 8 | -w /go/src/github.com/pottava/ecs-task-runner \ 9 | -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ 10 | -e AWS_MFA_SERIAL_NUMBER -e AWS_MFA_TOKEN \ 11 | -e AWS_PROFILE -e AWS_ASSUME_ROLE \ 12 | -e AWS_DEFAULT_REGION=us-west-2 \ 13 | -e APP_DEBUG=0 \ 14 | golang:1.17.5-alpine3.15 \ 15 | go run cmd/ecs-task-runner/main.go \ 16 | run alpine --entrypoint env --extended-output 17 | 18 | start: 19 | @docker run --rm -it -v "${GOPATH}":/go \ 20 | -v "${HOME}/.aws":/root/.aws \ 21 | -w /go/src/github.com/pottava/ecs-task-runner \ 22 | -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ 23 | -e AWS_MFA_SERIAL_NUMBER -e AWS_MFA_TOKEN \ 24 | -e AWS_PROFILE -e AWS_ASSUME_ROLE \ 25 | -e AWS_DEFAULT_REGION=us-west-2 \ 26 | -e APP_DEBUG=0 \ 27 | golang:1.17.5-alpine3.15 \ 28 | go run cmd/ecs-task-runner/main.go \ 29 | run dockercloud/hello-world \ 30 | --async -p 80 --security-groups "${SECURITY_GROUP_ID}" --spot 31 | 32 | stop: 33 | @docker run --rm -it -v "${GOPATH}":/go \ 34 | -v "${HOME}/.aws":/root/.aws \ 35 | -w /go/src/github.com/pottava/ecs-task-runner \ 36 | -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ 37 | -e AWS_MFA_SERIAL_NUMBER -e AWS_MFA_TOKEN \ 38 | -e AWS_PROFILE -e AWS_ASSUME_ROLE \ 39 | -e AWS_DEFAULT_REGION=us-west-2 \ 40 | -e APP_DEBUG=0 \ 41 | golang:1.17.5-alpine3.15 \ 42 | go run cmd/ecs-task-runner/main.go \ 43 | stop "${REQUEST_ID}" 44 | 45 | deps: 46 | @docker run --rm -it -v "${GOPATH}":/go \ 47 | -w /go/src/github.com/pottava/ecs-task-runner \ 48 | golang:1.17.5-alpine3.15 go mod vendor 49 | 50 | test: 51 | @docker run --rm -it -v "${GOPATH}":/go \ 52 | -w /go/src/github.com/pottava/ecs-task-runner \ 53 | supinf/golangci-lint:1.19 \ 54 | run --config .golangci.yml 55 | @docker run --rm -it -v "${GOPATH}":/go \ 56 | -w /go/src/github.com/pottava/ecs-task-runner \ 57 | --entrypoint go golang:1.17.5-alpine3.15 \ 58 | test -vet off $(go list ./...) -mod=readonly 59 | 60 | build: 61 | @docker run --rm -it -v "${GOPATH}":/go \ 62 | -w /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner \ 63 | supinf/go-gox:1.11 --osarch "linux/amd64 darwin/amd64 windows/amd64" \ 64 | -ldflags "-s -w" -output "dist/{{.OS}}_{{.Arch}}" 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A synchronous task runner for AWS Fargate on Amazon ECS 2 | 3 | [![CircleCI](https://circleci.com/gh/pottava/ecs-task-runner.svg?style=svg)](https://circleci.com/gh/pottava/ecs-task-runner) 4 | 5 | [![pottava/ecs-task-runner](http://dockeri.co/image/pottava/ecs-task-runner)](https://hub.docker.com/r/pottava/ecs-task-runner/) 6 | 7 | Supported tags and respective `Dockerfile` links: 8 | ・latest ([versions/3.0/Dockerfile](https://github.com/pottava/ecs-task-runner/blob/master/versions/3.0/Dockerfile)) 9 | ・3.0 ([versions/3.0/Dockerfile](https://github.com/pottava/ecs-task-runner/blob/master/versions/3.0/Dockerfile)) 10 | ・2.3 ([versions/2.3/Dockerfile](https://github.com/pottava/ecs-task-runner/blob/master/versions/2.3/Dockerfile)) 11 | ・1 ([versions/1.2/Dockerfile](https://github.com/pottava/ecs-task-runner/blob/master/versions/1.2/Dockerfile)) 12 | 13 | ## Description 14 | 15 | This is a synchronous task runner for AWS Fargate. It runs a docker container on Fargate and waits for its done. Then it returns its standard output logs from CloudWatch Logs. All resources we need are created temporarily and remove them after the task finished. 16 | 17 | ## Installation 18 | 19 | curl (macOS): 20 | 21 | ```sh 22 | $ curl -Lo ecs-task-runner https://github.com/pottava/ecs-task-runner/releases/download/3.0/ecs-task-runner_darwin_amd64 \ 23 | && chmod +x ecs-task-runner 24 | ``` 25 | 26 | curl (Linux): 27 | 28 | ```sh 29 | $ curl -Lo ecs-task-runner https://github.com/pottava/ecs-task-runner/releases/download/3.0/ecs-task-runner_linux_amd64 \ 30 | && chmod +x ecs-task-runner 31 | ``` 32 | 33 | go: 34 | 35 | ```console 36 | go get github.com/pottava/ecs-task-runner/... 37 | ``` 38 | 39 | docker: 40 | 41 | ```console 42 | docker pull pottava/ecs-task-runner 43 | ``` 44 | 45 | ## Parameters 46 | 47 | Common parameters: 48 | 49 | Environment Variables | Argument | Description | Required | Default 50 | ------------------------- | --------------- | ------------------------------- | -------- | --------- 51 | AWS_ACCESS_KEY_ID | access-key, a | AWS `access key` for API access | | 52 | AWS_SECRET_ACCESS_KEY | secret-key, s | AWS `secret key` for API access | | 53 | AWS_DEFAULT_REGION | region, r | AWS `region` for API access | | us-east-1 54 | AWS_PROFILE | profile | AWS `profile` for API access | | default 55 | AWS_ASSUME_ROLE | assume-role | IAM Role ARN to be assumed | | 56 | AWS_MFA_SERIAL_NUMBER | mfa-serial-num | A serial number of MFA device | | 57 | AWS_MFA_TOKEN | mfa-token | A token for MFA | | 58 | ECS_CLUSTER | cluster, c | Amazon ECS cluster name | | 59 | EXEC_ROLE_NAME | exec-role-name | Name of an execution role | | ecs-task-runner 60 | TASK_TIMEOUT | timeout, t | Timeout minutes for the task | | 30 61 | EXTENDED_OUTPUT | extended-output | True: meta data also returns | | false 62 | 63 | for the `run` command: 64 | 65 | Environment Variables | Argument | Description | Required | Default 66 | ------------------------- | --------------- | ------------------------------- | -------- | --------- 67 | DOCKER_IMAGE | | Docker image to be run on ECS | * | 68 | FARGATE_SPOT | spot | True: fargate spot will be used | | false 69 | FORCE_ECR | force-ecr, f | True: you can use shortened name | | false 70 | ENTRYPOINT | entrypoint | Override `ENTRYPOINT` of the image | | 71 | COMMAND | command | Override `CMD` of the image | | 72 | PORT | port, p | Publish ports | | 73 | ENVIRONMENT | environment, e | Add `ENV` to the container | | 74 | USER | docker-user | The user inside the container | | 75 | LABEL | label, l | Add `LABEL` to the container | | 76 | SUBNETS | subnets | Fargate's Subnets | | 77 | SECURITY_GROUPS | security-groups | Fargate's SecurityGroups | | 78 | TASKDEF_FAMILY | taskdef-family | ECS Task Definition family name | | ecs-task-runner 79 | TASK_ROLE | task-role-arn | ARN of an IAM Role for the task | | 80 | CPU | cpu | Requested vCPU to run Fargate | | 256 81 | MEMORY | memory | Requested memory to run Fargate | | 512 82 | NUMBER | number, n | Number of tasks | | 1 83 | PRIVATE_REGISTRY_USER | user | PrivateRegistry Username | | 84 | PRIVATE_REGISTRY_PASSWORD | password | PrivateRegistry Password | | 85 | KMS_CUSTOMKEY_ID | kms-key-id | KMS custom key ID for SecretsManager | | 86 | ASSIGN_PUBLIC_IP | assign-pub-ip | True: Assigns public IP | | true 87 | READONLY_ROOOTFS | readonly-rootfs | Make the root file system read-only | | false 88 | ASYNC | async | True: Does not wait for the job done | | false 89 | 90 | for the `stop` command: 91 | 92 | Environment Variables | Argument | Description | Required | Default 93 | ------------------------- | --------------- | ------------------------------- | -------- | --------- 94 | REQUEST_ID | | Resources ID to be stopped | * | 95 | TASK_ARN | task-arn | Task ARNs to be stopped | | 96 | 97 | ## Samples 98 | 99 | ```console 100 | $ export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 101 | $ export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 102 | $ ecs-task-runner run alpine --entrypoint env 103 | { 104 | "container-1": [ 105 | "2018-09-23T11:42:01+09:00: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 106 | "2018-09-23T11:42:01+09:00: HOSTNAME=ip-172-31-40-206.us-east-1.compute.internal", 107 | "2018-09-23T11:42:01+09:00: AWS_DEFAULT_REGION=ap-northeast-1", 108 | "2018-09-23T11:42:01+09:00: AWS_REGION=ap-northeast-1", 109 | "2018-09-23T11:42:01+09:00: HOME=/root" 110 | ] 111 | } 112 | ``` 113 | 114 | Using an assume role with MFA token 115 | 116 | ```console 117 | $ unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY 118 | $ 119 | $ export AWS_PROFILE=project-alpha-dev 120 | $ export AWS_ASSUME_ROLE=arn:aws:iam::123456789012:role/rolename 121 | $ export AWS_MFA_SERIAL_NUMBER=arn:aws:iam::123456789012:mfa/mfaname 122 | $ export AWS_MFA_TOKEN=123456 123 | $ 124 | $ ecs-task-runner run alpine --entrypoint env 125 | { 126 | "container-1": [ 127 | "2018-09-23T11:42:01+09:00: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 128 | "2018-09-23T11:42:01+09:00: HOSTNAME=ip-172-31-40-206.us-east-1.compute.internal", 129 | "2018-09-23T11:42:01+09:00: AWS_DEFAULT_REGION=ap-northeast-1", 130 | "2018-09-23T11:42:01+09:00: AWS_REGION=ap-northeast-1", 131 | "2018-09-23T11:42:01+09:00: HOME=/root" 132 | ] 133 | } 134 | $ echo $? 135 | 0 136 | ``` 137 | 138 | This app will return with an exit code of the containers: 139 | 140 | ```console 141 | $ ecs-task-runner run alpine --entrypoint sh,-c --command "exit 255" 142 | { 143 | "container-1": [] 144 | } 145 | $ echo $? 146 | 255 147 | ``` 148 | 149 | Run a container asynchronously with `--async` flag: 150 | 151 | ```console 152 | $ ecs-task-runner run nginx --async -p 80 --security-groups sg-public-80-abcdefg 153 | { 154 | "RequestID": "ecs-task-runner-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 155 | "Tasks": [ 156 | { 157 | "PublicIP": "xx.xxx.xxx.xx", 158 | "TaskARN": "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 159 | } 160 | ] 161 | } 162 | ``` 163 | 164 | To stop the asynchronous tasks: 165 | 166 | ```console 167 | $ ecs-task-runner stop ecs-task-runner-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 168 | { 169 | "container-1": [ 170 | "2018-09-23T22:34:37+09:00: zzz.zz.z.zzz - - [23/Sep/2018:13:34:37 +0000] \"GET / HTTP/1.1\" 200 612 \"-\" \"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0 Safari/537.36\" \"-\"" 171 | ] 172 | } 173 | ``` 174 | 175 | ## Usage 176 | 177 | With arguments: 178 | 179 | ```console 180 | ecs-task-runner -a AKIAIOSFODNN7EXAMPLE -s wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY run sample/image 181 | ``` 182 | 183 | With environment variables: 184 | 185 | ```console 186 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 187 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 188 | ecs-task-runner run sample/image 189 | ``` 190 | 191 | With ECR shortened image name: 192 | 193 | ```console 194 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 195 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 196 | ecs-task-runner run --force-ecr my-ecr/image 197 | ``` 198 | 199 | With a private registory: 200 | 201 | ```console 202 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 203 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 204 | export PRIVATE_REGISTRY_USER=user 205 | export PRIVATE_REGISTRY_PASSWORD=password 206 | ecs-task-runner run sample/secret 207 | ``` 208 | 209 | With the docker container: 210 | 211 | ```console 212 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 213 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 214 | docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY pottava/ecs-task-runner sample/image 215 | ``` 216 | 217 | ## Troubleshooting 218 | 219 | If the command returns non-zero exit code, you can try `--extended-output` to analyze the cause of failure. 220 | 221 | ```console 222 | export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE 223 | export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY 224 | ecs-task-runner run sample/image --extended-output 225 | ``` 226 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | working_directory: /go/src/github.com/pottava/ecs-task-runner 5 | docker: 6 | - image: circleci/golang:1.17.5 7 | steps: 8 | - checkout 9 | - run: go mod download 10 | - run: go mod verify 11 | - run: 12 | name: Run tests 13 | command: | 14 | go test -cover -bench -benchmem 15 | # - run: go run cmd/ecs-task-runner/main.go run alpine --entrypoint env --extended-output 16 | build: 17 | working_directory: /go/src/github.com/pottava/ecs-task-runner 18 | docker: 19 | - image: circleci/golang:1.17.5 20 | steps: 21 | - checkout 22 | - run: go mod download 23 | - run: go mod verify 24 | - run: go get github.com/mitchellh/gox 25 | - run: go get github.com/tcnksm/ghr 26 | - run: 27 | name: Build and release 28 | command: | 29 | cd cmd/ecs-task-runner/ 30 | gox --osarch "linux/amd64 darwin/amd64 windows/amd64" -ldflags "-s -w -X main.version=${CIRCLE_TAG} -X main.commit=${CIRCLE_SHA1:0:7} -X main.date=$(date +%Y-%m-%d --utc)" -output "${GOPATH}/pkg/${CIRCLE_PROJECT_REPONAME}_{{.OS}}_{{.Arch}}" 31 | ghr -t $GITHUB_TOKEN -u $CIRCLE_PROJECT_USERNAME -r $CIRCLE_PROJECT_REPONAME --replace ${CIRCLE_TAG} $GOPATH/pkg/ 32 | 33 | workflows: 34 | version: 2 35 | dev: 36 | jobs: 37 | - test: 38 | filters: 39 | branches: 40 | only: /.*/ 41 | release: 42 | jobs: 43 | - build: 44 | filters: 45 | branches: 46 | ignore: /.*/ 47 | tags: 48 | only: /[1-9]+(\.[0-9]+)*/ 49 | -------------------------------------------------------------------------------- /cmd/ecs-task-runner/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "runtime/debug" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/aws/aws-sdk-go/aws" 13 | commands "github.com/pottava/ecs-task-runner" 14 | "github.com/pottava/ecs-task-runner/conf" 15 | lib "github.com/pottava/ecs-task-runner/internal/aws" 16 | "github.com/pottava/ecs-task-runner/internal/log" 17 | cli "gopkg.in/alecthomas/kingpin.v2" 18 | ) 19 | 20 | // for compile flags 21 | var ( 22 | ver = "dev" 23 | commit string 24 | date string 25 | ) 26 | 27 | func main() { 28 | defer func() { 29 | if err := recover(); err != nil { 30 | if os.Getenv("APP_DEBUG") == "1" { 31 | debug.PrintStack() 32 | } 33 | log.Errors.Fatal(err) 34 | } 35 | }() 36 | 37 | app := cli.New("ecs-rask-runner", "A synchronous task runner AWS Fargate on Amazon ECS") 38 | if len(commit) > 0 && len(date) > 0 { 39 | app.Version(fmt.Sprintf("%s-%s (built at %s)", ver, commit, date)) 40 | } else { 41 | app.Version(ver) 42 | } 43 | // global flags 44 | awsconf := &conf.AwsConfig{} 45 | awsconf.AccessKey = app.Flag("access-key", "AWS access key ID."). 46 | Short('a').Envar("AWS_ACCESS_KEY_ID").String() 47 | awsconf.SecretKey = app.Flag("secret-key", "AWS secret access key."). 48 | Short('s').Envar("AWS_SECRET_ACCESS_KEY").String() 49 | awsconf.Profile = app.Flag("profile", "AWS default profile."). 50 | Envar("AWS_PROFILE").String() 51 | awsconf.AssumeRole = app.Flag("assume-role", "IAM Role ARN to be assumed."). 52 | Envar("AWS_ASSUME_ROLE").String() 53 | awsconf.MfaSerialNumber = app.Flag("mfa-serial-num", "A serial number of MFA device."). 54 | Envar("AWS_MFA_SERIAL_NUMBER").String() 55 | awsconf.MfaToken = app.Flag("mfa-token", "A token for MFA."). 56 | Envar("AWS_MFA_TOKEN").String() 57 | awsconf.Region = app.Flag("region", "AWS default region."). 58 | Short('r').Envar("AWS_DEFAULT_REGION").Default("us-east-1").String() 59 | 60 | common := &conf.CommonConfig{} 61 | common.AppVersion = ver 62 | if len(commit) > 0 && len(date) > 0 { 63 | common.AppVersion = fmt.Sprintf("%s-%s (built at %s)", ver, commit, date) 64 | } 65 | common.EcsCluster = app.Flag("cluster", "Amazon ECS cluster name."). 66 | Short('c').Envar("ECS_CLUSTER").String() 67 | common.ExecRoleName = app.Flag("exec-role-name", "Name of an execution role for the task."). 68 | Envar("EXEC_ROLE_NAME").Default("ecs-task-runner").String() 69 | common.Timeout = app.Flag("timeout", "Timeout minutes for the task."). 70 | Short('t').Envar("TASK_TIMEOUT").Default("30").Int64() 71 | common.ExtendedOutput = app.Flag("extended-output", "If it's True, meta data returns as well."). 72 | Envar("EXTENDED_OUTPUT").Default("false").Bool() 73 | common.IsDebugMode = os.Getenv("APP_DEBUG") == "1" 74 | 75 | // commands 76 | runconf := &conf.RunConfig{} 77 | runconf.Aws = awsconf 78 | runconf.Common = common 79 | run := app.Command("run", "Run a docker image as a Fargate container on ECS cluster.") 80 | image := run.Arg("image", "Docker image name to be executed on ECS."). 81 | Envar("DOCKER_IMAGE").Required().String() 82 | runconf.Spot = run.Flag("spot", "If it's True, fargate spot will be used."). 83 | Envar("FARGATE_SPOT").Default("false").Bool() 84 | runconf.ForceECR = run.Flag("force-ecr", "If it's True, you can use the shortened image name."). 85 | Short('f').Envar("FORCE_ECR").Default("false").Bool() 86 | runconf.TaskDefFamily = run.Flag("taskdef-family", "ECS Task Definition family name."). 87 | Envar("TASKDEF_FAMILY").Default("ecs-task-runner").String() 88 | entrypoints := run.Flag("entrypoint", "Override `ENTRYPOINT` of the image."). 89 | Envar("ENTRYPOINT").Strings() 90 | runconf.User = run.Flag("docker-user", "The user name to use inside the container."). 91 | Envar("USER").String() 92 | cmds := run.Flag("command", "Override `CMD` of the image."). 93 | Envar("COMMAND").Strings() 94 | subnets := run.Flag("subnets", "Subnets on where Fargate containers run."). 95 | Envar("SUBNETS").Strings() 96 | ports := run.Flag("port", "Publish ports."). 97 | Short('p').Envar("PORT").Int64List() 98 | envs := run.Flag("environment", "Add `ENV` to the container."). 99 | Short('e').Envar("ENVIRONMENT").Strings() 100 | labels := run.Flag("label", "Add `LABEL` to the container."). 101 | Short('l').Envar("LABEL").Strings() 102 | securityGroups := run.Flag("security-groups", "SecurityGroups to be assigned to containers."). 103 | Envar("SECURITY_GROUPS").Strings() 104 | runconf.CPU = run.Flag("cpu", "Requested vCPU to run Fargate containers."). 105 | Envar("CPU").Default("256").String() 106 | runconf.Memory = run.Flag("memory", "Requested memory to run Fargate containers."). 107 | Envar("MEMORY").Default("512").String() 108 | runconf.TaskRoleArn = run.Flag("task-role-arn", "ARN of an IAM Role for the task."). 109 | Envar("TASK_ROLE_ARN").String() 110 | runconf.NumberOfTasks = run.Flag("number", "Number of tasks."). 111 | Short('n').Envar("NUMBER").Default("1").Int64() 112 | runconf.KMSCustomKeyID = run.Flag("kms-key-id", "KMS custom key ID for SecretsManager decryption."). 113 | Envar("KMS_CUSTOMKEY_ID").String() 114 | runconf.DockerUser = run.Flag("user", "PrivateRegistry Username ."). 115 | Envar("PRIVATE_REGISTRY_USER").String() 116 | runconf.DockerPassword = run.Flag("password", "PrivateRegistry Password."). 117 | Envar("PRIVATE_REGISTRY_PASSWORD").String() 118 | runconf.AssignPublicIP = run.Flag("assign-pub-ip", "If it's True, it assigns public IP."). 119 | Envar("ASSIGN_PUBLIC_IP").Default("true").Bool() 120 | runconf.ReadOnlyRootFS = run.Flag("readonly-rootfs", "If it's True, it makes the Root-FS READ only."). 121 | Envar("READONLY_ROOOTFS").Default("false").Bool() 122 | runconf.Asynchronous = run.Flag("async", "If it's True, the app does not wait for the job done."). 123 | Envar("ASYNC").Default("false").Bool() 124 | 125 | stopconf := &conf.StopConfig{} 126 | stopconf.Aws = awsconf 127 | stopconf.Common = common 128 | stop := app.Command("stop", "Stop a Fargate on ECS cluster.") 129 | stopconf.RequestID = stop.Arg("request-id", "Request ID."). 130 | Envar("REQUEST_ID").Required().String() 131 | taskARNs := stop.Flag("task-arn", "ECS Task ARN."). 132 | Envar("TASK_ARN").Strings() 133 | 134 | switch cli.MustParse(app.Parse(os.Args[1:])) { 135 | case run.FullCommand(): 136 | runconf.Image = aws.StringValue(image) 137 | runconf.Entrypoint = []*string{} 138 | if entrypoints != nil { 139 | for _, candidate := range *entrypoints { 140 | for _, entrypoint := range strings.Split(candidate, ",") { 141 | runconf.Entrypoint = append(runconf.Entrypoint, aws.String(entrypoint)) 142 | } 143 | } 144 | } 145 | runconf.Commands = []*string{} 146 | if cmds != nil { 147 | for _, candidate := range *cmds { 148 | for _, cmd := range strings.Split(candidate, ",") { 149 | runconf.Commands = append(runconf.Commands, aws.String(cmd)) 150 | } 151 | } 152 | } 153 | runconf.Ports = []*int64{} 154 | if envs != nil { 155 | for _, candidate := range *ports { 156 | runconf.Ports = append(runconf.Ports, aws.Int64(candidate)) 157 | } 158 | } 159 | runconf.Environments = map[string]*string{} 160 | if envs != nil { 161 | for _, candidate := range *envs { 162 | for _, env := range strings.Split(candidate, ",") { 163 | if keyval := strings.Split(env, "="); len(keyval) >= 2 { 164 | runconf.Environments[keyval[0]] = aws.String(strings.Join(keyval[1:], "=")) 165 | } 166 | } 167 | } 168 | } 169 | runconf.Labels = map[string]*string{} 170 | if labels != nil { 171 | for _, candidate := range *labels { 172 | for _, label := range strings.Split(candidate, ",") { 173 | if keyval := strings.Split(label, "="); len(keyval) >= 2 { 174 | runconf.Labels[keyval[0]] = aws.String(strings.Join(keyval[1:], "=")) 175 | } 176 | } 177 | } 178 | } 179 | runconf.Subnets = []*string{} 180 | if subnets != nil { 181 | for _, subnet := range *subnets { 182 | runconf.Subnets = append(runconf.Subnets, aws.String(subnet)) 183 | } 184 | } 185 | runconf.SecurityGroups = []*string{} 186 | if securityGroups != nil { 187 | for _, securityGroup := range *securityGroups { 188 | runconf.SecurityGroups = append(runconf.SecurityGroups, aws.String(securityGroup)) 189 | } 190 | } 191 | 192 | // Cancel 193 | ctx, cancel := context.WithCancel(context.Background()) 194 | c := make(chan os.Signal) 195 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 196 | go func() { 197 | <-c 198 | cancel() 199 | if sess, err := lib.Session(runconf.Aws, runconf.Common.IsDebugMode); err == nil { 200 | commands.DeleteResouces(runconf.Aws, runconf.Common, sess) 201 | } 202 | os.Exit(1) 203 | }() 204 | 205 | // Execute 206 | out, err := commands.Run(ctx, runconf) 207 | if err != nil { 208 | log.Errors.Fatal(err) 209 | return 210 | } 211 | if aws.BoolValue(runconf.Asynchronous) { 212 | if aws.BoolValue(common.ExtendedOutput) { 213 | log.PrintJSON(struct { 214 | RequestID string `json:"RequestID"` 215 | AsyncTasks []commands.OutputAsyncTask `json:"Tasks"` 216 | Meta commands.OutputMeta `json:"meta"` 217 | }{ 218 | RequestID: out.RequestID, 219 | AsyncTasks: out.AsyncTasks, 220 | Meta: out.Meta, 221 | }) 222 | } else { 223 | log.PrintJSON(struct { 224 | RequestID string `json:"RequestID"` 225 | AsyncTasks []commands.OutputAsyncTask `json:"Tasks"` 226 | }{ 227 | RequestID: out.RequestID, 228 | AsyncTasks: out.AsyncTasks, 229 | }) 230 | } 231 | } else { 232 | if aws.BoolValue(common.ExtendedOutput) { 233 | out.SyncLogs["meta"] = out.Meta 234 | } 235 | log.PrintJSON(out.SyncLogs) 236 | } 237 | os.Exit(int(aws.Int64Value(out.ExitCode))) 238 | 239 | case stop.FullCommand(): 240 | stopconf.TaskARNs = []*string{} 241 | if taskARNs != nil { 242 | for _, taskARN := range *taskARNs { 243 | stopconf.TaskARNs = append(stopconf.TaskARNs, aws.String(taskARN)) 244 | } 245 | } 246 | 247 | // Cancel 248 | ctx, cancel := context.WithCancel(context.Background()) 249 | c := make(chan os.Signal) 250 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 251 | go func() { 252 | <-c 253 | cancel() 254 | os.Exit(1) 255 | }() 256 | 257 | // Execute 258 | out, err := commands.Stop(ctx, stopconf) 259 | if err != nil { 260 | log.Errors.Fatal(err) 261 | return 262 | } 263 | if aws.BoolValue(common.ExtendedOutput) { 264 | out.SyncLogs["meta"] = out.Meta 265 | } 266 | log.PrintJSON(out.SyncLogs) 267 | os.Exit(int(aws.Int64Value(out.ExitCode))) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | // AwsConfig is set of AWS configurations 4 | type AwsConfig struct { 5 | AccountID string 6 | Region *string 7 | AccessKey *string 8 | SecretKey *string 9 | Profile *string 10 | AssumeRole *string 11 | MfaSerialNumber *string 12 | MfaToken *string 13 | } 14 | 15 | // CommonConfig is set of common configurations 16 | type CommonConfig struct { // nolint 17 | AppVersion string 18 | EcsCluster *string 19 | ClusterExisted bool 20 | ExecRoleName *string 21 | Timeout *int64 22 | ExtendedOutput *bool 23 | IsDebugMode bool 24 | } 25 | 26 | // RunConfig is set of configurations for running a container 27 | type RunConfig struct { 28 | Aws *AwsConfig 29 | Common *CommonConfig 30 | Image string 31 | Spot *bool 32 | ForceECR *bool 33 | TaskDefFamily *string 34 | Entrypoint []*string 35 | Commands []*string 36 | Ports []*int64 37 | Environments map[string]*string 38 | User *string 39 | Labels map[string]*string 40 | Subnets []*string 41 | SecurityGroups []*string 42 | CPU *string 43 | Memory *string 44 | TaskRoleArn *string 45 | NumberOfTasks *int64 46 | WithParamStore bool 47 | KMSCustomKeyID *string 48 | DockerUser *string 49 | DockerPassword *string 50 | AssignPublicIP *bool 51 | ReadOnlyRootFS *bool 52 | Asynchronous *bool 53 | } 54 | 55 | // StopConfig is set of configurations for stopping a container 56 | type StopConfig struct { 57 | Aws *AwsConfig 58 | Common *CommonConfig 59 | RequestID *string 60 | TaskARNs []*string 61 | } 62 | -------------------------------------------------------------------------------- /ecs-task-runner.go: -------------------------------------------------------------------------------- 1 | package etr 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/awserr" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/ecr" 15 | "github.com/aws/aws-sdk-go/service/ecs" 16 | "github.com/aws/aws-sdk-go/service/iam" 17 | "github.com/aws/aws-sdk-go/service/sts" 18 | "github.com/docker/distribution/reference" 19 | "github.com/google/uuid" 20 | config "github.com/pottava/ecs-task-runner/conf" 21 | lib "github.com/pottava/ecs-task-runner/internal/aws" 22 | "github.com/pottava/ecs-task-runner/internal/log" 23 | "github.com/pottava/ecs-task-runner/internal/util" 24 | "golang.org/x/sync/errgroup" 25 | ) 26 | 27 | var ( 28 | requestID string 29 | logGroup string 30 | taskDefARN *string 31 | ) 32 | 33 | func init() { 34 | requestID = fmt.Sprintf("ecs-task-runner-%s", uuid.New().String()) 35 | logGroup = fmt.Sprintf("/ecs/%s", requestID) 36 | } 37 | 38 | // Run runs the docker image on Amazon ECS 39 | func Run(ctx context.Context, conf *config.RunConfig) (output *Output, err error) { 40 | startedAt := time.Now() 41 | 42 | if conf.Common.IsDebugMode { 43 | log.PrintJSON(conf) 44 | } 45 | // Check AWS credentials 46 | sess, err := lib.Session(conf.Aws, conf.Common.IsDebugMode) 47 | if err != nil { 48 | return &Output{ExitCode: exitWithError}, err 49 | } 50 | conf.Aws.AccountID, err = getAccountID(ctx, sess, conf.Common, conf.Aws) 51 | if err != nil { 52 | return &Output{ExitCode: exitWithError}, err 53 | } 54 | eg, _ := errgroup.WithContext(context.Background()) 55 | 56 | // Check existence of the image on ECR 57 | var image *string 58 | eg.Go(func() (err error) { 59 | image, err = validateImageName(ctx, conf, sess) 60 | return err 61 | }) 62 | // Ensure resource existence 63 | eg.Go(func() (err error) { 64 | return ensureAWSResources(ctx, sess, conf) 65 | }) 66 | if err = eg.Wait(); err != nil { 67 | return &Output{ExitCode: exitWithError}, err 68 | } 69 | // Check if the environment variables contain sensitive data 70 | conf.WithParamStore = containsSensitiveData(ctx, sess, conf) 71 | 72 | // Create AWS resources 73 | var taskDefInput *ecs.RegisterTaskDefinitionInput 74 | taskDefInput, err = createResouces(ctx, sess, conf, image, startedAt) 75 | if err != nil { 76 | DeleteResouces(conf.Aws, conf.Common, sess) 77 | return &Output{ExitCode: exitWithError}, err 78 | } 79 | // Run the ECS task 80 | runTaskAt := time.Now() 81 | tasks, runconfig, err := run(ctx, sess, conf) 82 | if err != nil { 83 | DeleteResouces(conf.Aws, conf.Common, sess) 84 | return &Output{ExitCode: exitWithError}, err 85 | } 86 | // Asynchronous job 87 | if aws.BoolValue(conf.Asynchronous) { 88 | // Wait for its start 89 | tasks, err = waitForTask(ctx, sess, conf.Common, tasks, func(task *ecs.Task) bool { 90 | return !strings.EqualFold(aws.StringValue(task.LastStatus), "PROVISIONING") && 91 | !strings.EqualFold(aws.StringValue(task.LastStatus), "PENDING") 92 | }) 93 | if err != nil { 94 | DeleteResouces(conf.Aws, conf.Common, sess) 95 | return &Output{ExitCode: exitWithError}, err 96 | } 97 | output = runResults(ctx, conf, startedAt, runTaskAt, nil, nil, taskDefInput, runconfig, tasks) 98 | if len(tasks) == 0 || len(tasks[0].Containers) == 0 { 99 | output.ExitCode = exitWithError 100 | } 101 | deleteResoucesImmediately(conf.Aws, conf.Common, sess) 102 | return output, nil 103 | } 104 | // Wait for its done 105 | tasks, err = waitForTask(ctx, sess, conf.Common, tasks, func(task *ecs.Task) bool { 106 | return strings.EqualFold(aws.StringValue(task.LastStatus), "STOPPED") && task.StoppedAt != nil 107 | }) 108 | if err != nil { 109 | DeleteResouces(conf.Aws, conf.Common, sess) 110 | return &Output{ExitCode: exitWithError}, err 111 | } 112 | // Retrieve app log 113 | logs := lib.RetrieveLogs(ctx, sess, tasks, aws.StringValue(conf.Common.EcsCluster), logGroup, logPrefix) 114 | retrieveLogsAt := time.Now() 115 | 116 | // Delete AWS resources 117 | DeleteResouces(conf.Aws, conf.Common, sess) 118 | 119 | // Format the result 120 | output = runResults(ctx, conf, startedAt, runTaskAt, &retrieveLogsAt, logs, taskDefInput, runconfig, tasks) 121 | 122 | if len(tasks) == 0 || len(tasks[0].Containers) == 0 { 123 | return &Output{ExitCode: exitWithError}, nil 124 | } 125 | for _, task := range tasks { 126 | for _, container := range task.Containers { 127 | if aws.Int64Value(output.ExitCode) != 0 { 128 | break 129 | } 130 | output.ExitCode = container.ExitCode 131 | } 132 | } 133 | for _, status := range output.Meta.ExitCodes { 134 | if strings.Contains(status.StopCode, "Failed") { 135 | output.ExitCode = exitWithError 136 | } 137 | } 138 | return output, nil 139 | } 140 | 141 | // Stop stops the Fargate container on Amazon ECS 142 | func Stop(ctx context.Context, conf *config.StopConfig) (output *Output, err error) { 143 | 144 | // Check AWS credentials 145 | sess, err := lib.Session(conf.Aws, conf.Common.IsDebugMode) 146 | if err != nil { 147 | return &Output{ExitCode: exitWithError}, err 148 | } 149 | conf.Aws.AccountID, err = getAccountID(ctx, sess, conf.Common, conf.Aws) 150 | if err != nil { 151 | return &Output{ExitCode: exitWithError}, err 152 | } 153 | // Ensure parameters 154 | requestID = aws.StringValue(conf.RequestID) 155 | logGroup = fmt.Sprintf("/ecs/%s", requestID) 156 | conf.Common.ClusterExisted = !util.IsEmpty(conf.Common.EcsCluster) 157 | if !conf.Common.ClusterExisted { 158 | conf.Common.EcsCluster = conf.RequestID 159 | } 160 | if conf.Common.IsDebugMode { 161 | log.PrintJSON(conf) 162 | } 163 | // Retrieve all tasks to check cluster can be deleted or not 164 | all, err := ecs.New(sess).ListTasksWithContext(ctx, &ecs.ListTasksInput{ 165 | Cluster: conf.Common.EcsCluster, 166 | }) 167 | if err != nil { 168 | return &Output{ExitCode: exitWithError}, err 169 | } 170 | // Stop the task 171 | tasks := []*ecs.Task{} 172 | if len(conf.TaskARNs) == 0 { 173 | conf.TaskARNs = all.TaskArns 174 | } 175 | for _, taskARN := range conf.TaskARNs { 176 | task, err := lib.StopTask(ctx, sess, conf.Common.EcsCluster, taskARN) 177 | if err != nil { 178 | return &Output{ExitCode: exitWithError}, err 179 | } 180 | tasks = append(tasks, task) 181 | } 182 | tasks, _ = waitForTask(ctx, sess, conf.Common, tasks, func(task *ecs.Task) bool { // nolint 183 | return strings.EqualFold(aws.StringValue(task.LastStatus), "STOPPED") && task.StoppedAt != nil 184 | }) 185 | logs := lib.RetrieveLogs(ctx, sess, tasks, aws.StringValue(conf.Common.EcsCluster), logGroup, logPrefix) 186 | output = stopResults(ctx, conf, logs, tasks) 187 | 188 | // Delete AWS resources 189 | if len(all.TaskArns) == len(tasks) { 190 | deleteResoucesInTheEnd(conf.Aws, conf.Common, sess) 191 | } 192 | if len(tasks) == 0 || len(tasks[0].Containers) == 0 { 193 | return &Output{ExitCode: exitNormally}, nil 194 | } 195 | for _, task := range tasks { 196 | for _, container := range task.Containers { 197 | if aws.Int64Value(output.ExitCode) != 0 { 198 | break 199 | } 200 | output.ExitCode = container.ExitCode 201 | } 202 | } 203 | for _, status := range output.Meta.ExitCodes { 204 | if strings.Contains(status.StopCode, "Failed") { 205 | output.ExitCode = exitWithError 206 | } 207 | } 208 | return output, nil 209 | } 210 | 211 | func validateImageName(ctx context.Context, conf *config.RunConfig, sess *session.Session) (*string, error) { 212 | imageHost, imageName, imageTag, err := parseImageName(conf.Image) 213 | if err != nil { 214 | log.Errors.Println("Provided image name is invalid.") 215 | return nil, err 216 | } 217 | // Try to make up ECR image name 218 | if aws.BoolValue(conf.ForceECR) { 219 | if !strings.Contains(aws.StringValue(imageHost), conf.Aws.AccountID) { 220 | imageName = aws.String(fmt.Sprintf( 221 | "%s/%s", 222 | aws.StringValue(imageHost), 223 | aws.StringValue(imageName), 224 | )) 225 | imageHost = aws.String(fmt.Sprintf( 226 | "%s.dkr.ecr.%s.amazonaws.com", 227 | conf.Aws.AccountID, 228 | aws.StringValue(conf.Aws.Region), 229 | )) 230 | } 231 | } 232 | if strings.Contains(aws.StringValue(imageHost), "amazonaws.com") { 233 | if _, err := ecr.New(sess).DescribeRepositoriesWithContext(ctx, &ecr.DescribeRepositoriesInput{ 234 | RepositoryNames: []*string{imageName}, 235 | }); err != nil { 236 | return nil, err 237 | } 238 | } 239 | if aws.StringValue(imageHost) == "" { 240 | return aws.String(fmt.Sprintf( 241 | "%s%s", 242 | aws.StringValue(imageName), 243 | aws.StringValue(imageTag), 244 | )), nil 245 | } 246 | return aws.String(fmt.Sprintf( 247 | "%s/%s%s", 248 | aws.StringValue(imageHost), 249 | aws.StringValue(imageName), 250 | aws.StringValue(imageTag), 251 | )), nil 252 | } 253 | 254 | func parseImageName(value string) (*string, *string, *string, error) { 255 | ref, err := reference.Parse(value) 256 | if err != nil { 257 | return nil, nil, nil, err 258 | } 259 | imageHost := "" 260 | imageName := "" 261 | if candidate, ok := ref.(reference.Named); ok { 262 | imageHost, imageName = reference.SplitHostname(candidate) 263 | } 264 | imageTag := ":latest" 265 | if candidate, ok := ref.(reference.Tagged); ok { 266 | imageTag = ":" + candidate.Tag() 267 | } 268 | if candidate, ok := ref.(reference.Digested); ok { 269 | digest := candidate.Digest() 270 | if digest.Validate() == nil { 271 | imageTag = "@" + digest.String() 272 | } 273 | } 274 | return aws.String(imageHost), aws.String(imageName), aws.String(imageTag), nil 275 | } 276 | 277 | func ensureAWSResources(ctx context.Context, sess *session.Session, conf *config.RunConfig) error { 278 | eg, _ := errgroup.WithContext(context.Background()) 279 | vpc := lib.FindDefaultVPC(ctx, sess) 280 | 281 | // Ensure cluster existence 282 | eg.Go(func() error { 283 | if util.IsEmpty(conf.Common.EcsCluster) { 284 | conf.Common.EcsCluster = aws.String(requestID) 285 | } 286 | existed, e := lib.CreateClusterIfNotExist(ctx, sess, conf.Common.EcsCluster, conf.Spot) 287 | conf.Common.ClusterExisted = existed 288 | return e 289 | }) 290 | 291 | // Ensure subnets existence 292 | eg.Go(func() (err error) { 293 | subnets := []*string{} 294 | if conf.Subnets == nil || len(conf.Subnets) == 0 { 295 | defSubnet := lib.FindDefaultSubnet(ctx, sess, vpc) 296 | if defSubnet == nil { 297 | return errors.New("There is no default subnet") 298 | } 299 | subnets = append(subnets, defSubnet) 300 | } else { 301 | for _, arg := range conf.Subnets { 302 | for _, subnet := range strings.Split(aws.StringValue(arg), ",") { 303 | subnets = append(subnets, aws.String(subnet)) 304 | } 305 | } 306 | } 307 | conf.Subnets = subnets 308 | return nil 309 | }) 310 | 311 | // Ensure security-group existence 312 | eg.Go(func() (err error) { 313 | sgs := []*string{} 314 | if conf.SecurityGroups == nil || len(conf.SecurityGroups) == 0 { 315 | defSecurityGroup := lib.FindDefaultSecurityGroup(ctx, sess, vpc) 316 | if defSecurityGroup == nil { 317 | return errors.New("There is no default security group") 318 | } 319 | sgs = append(sgs, defSecurityGroup) 320 | } else { 321 | for _, arg := range conf.SecurityGroups { 322 | for _, sg := range strings.Split(aws.StringValue(arg), ",") { 323 | sgs = append(sgs, aws.String(sg)) 324 | } 325 | } 326 | } 327 | conf.SecurityGroups = sgs 328 | return nil 329 | }) 330 | return eg.Wait() 331 | } 332 | 333 | func containsSensitiveData(ctx context.Context, sess *session.Session, conf *config.RunConfig) bool { 334 | ssmParameterKey := fmt.Sprintf( 335 | "arn:aws:ssm:%s:%s:parameter/", 336 | aws.StringValue(conf.Aws.Region), 337 | conf.Aws.AccountID, 338 | ) 339 | secretsManagerKey := fmt.Sprintf( 340 | "arn:aws:secretsmanager:%s:%s:secret:", 341 | aws.StringValue(conf.Aws.Region), 342 | conf.Aws.AccountID, 343 | ) 344 | for _, val := range conf.Environments { 345 | if strings.HasPrefix(aws.StringValue(val), ssmParameterKey) || strings.HasPrefix(aws.StringValue(val), secretsManagerKey) { 346 | return true 347 | } 348 | } 349 | return false 350 | } 351 | 352 | const ( 353 | ecsManagedExecPolicyArn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 354 | ecsManagedExecPolicyDocument = `{ 355 | "Statement": [{ 356 | "Effect": "Allow", 357 | "Action": "sts:AssumeRole", 358 | "Principal": { 359 | "Service": "ecs-tasks.amazonaws.com" 360 | } 361 | }] 362 | }` 363 | kmsCustomKeyID = "\"arn:aws:kms:%s:%s:%s\"," 364 | ecsGetParamsPolicyDocument = `{ 365 | "Version": "2012-10-17", 366 | "Statement": [{ 367 | "Effect": "Allow", 368 | "Action": [ 369 | "kms:Decrypt", 370 | "ssm:GetParameters", 371 | "secretsmanager:GetSecretValue" 372 | ], 373 | "Resource": [ 374 | %s 375 | "arn:aws:ssm:%s:%s:parameter/*", 376 | "arn:aws:secretsmanager:%s:%s:secret:*" 377 | ] 378 | }] 379 | }` 380 | fargate = "FARGATE" 381 | fargateSpot = "FARGATE_SPOT" 382 | logPrefix = "fargate" 383 | awsVPC = "awsvpc" 384 | awsCWLogs = "awslogs" 385 | ) 386 | 387 | var ( 388 | dockerCreds *string 389 | credsPolicy *string 390 | ) 391 | 392 | func createResouces(ctx context.Context, sess *session.Session, conf *config.RunConfig, image *string, startedAt time.Time) (taskDefInput *ecs.RegisterTaskDefinitionInput, e error) { 393 | eg, _ := errgroup.WithContext(context.Background()) 394 | 395 | eg.Go(func() error { 396 | // Make a temporary log group 397 | return lib.CreateLogGroup(ctx, sess, logGroup) 398 | }) 399 | eg.Go(func() (err error) { 400 | if !util.IsEmpty(conf.DockerUser) { 401 | // Store private registry credentials in AWS SecretsManager 402 | dockerCreds, err = lib.CreateSecret( 403 | ctx, sess, 404 | aws.String(requestID), 405 | conf.KMSCustomKeyID, 406 | aws.String(fmt.Sprintf( 407 | `{"username":"%s","password":"%s"}`, 408 | aws.StringValue(conf.DockerUser), 409 | aws.StringValue(conf.DockerPassword), 410 | )), 411 | ) 412 | if err != nil { 413 | return err 414 | } 415 | } 416 | // Make a temporary IAM role 417 | var execRoleArn *string 418 | execRoleArn, err = createIAMRole(ctx, sess, conf) 419 | if err != nil { 420 | return err 421 | } 422 | // Make a temporary task definition 423 | taskDefARN, taskDefInput, err = registerTaskDef(ctx, sess, conf, image, execRoleArn, startedAt) 424 | return 425 | }) 426 | if err := eg.Wait(); err != nil { 427 | return nil, err 428 | } 429 | return taskDefInput, nil 430 | } 431 | 432 | func createIAMRole(ctx context.Context, sess *session.Session, conf *config.RunConfig) (*string, error) { 433 | roleName := conf.Common.ExecRoleName 434 | role, err := iam.New(sess).GetRoleWithContext(ctx, &iam.GetRoleInput{ 435 | RoleName: roleName, 436 | }) 437 | var execRoleArn *string 438 | if err == nil && role.Role != nil { 439 | execRoleArn = role.Role.Arn 440 | } else { 441 | out, e := iam.New(sess).CreateRoleWithContext(ctx, &iam.CreateRoleInput{ 442 | RoleName: roleName, 443 | AssumeRolePolicyDocument: aws.String(ecsManagedExecPolicyDocument), 444 | Path: aws.String("/"), 445 | }) 446 | if e != nil { 447 | return nil, e 448 | } 449 | execRoleArn = out.Role.Arn 450 | } 451 | if err = lib.AttachPolicy(ctx, sess, roleName, aws.String(ecsManagedExecPolicyArn)); err != nil { 452 | return nil, err 453 | } 454 | // If you'd like to use private repo, the execution role has to have a special policy. 455 | // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/private-auth.html 456 | // Or if you just want to specify sensitive data with AWS Systems Manager Parameter Store 457 | // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data.html 458 | if (!util.IsEmpty(conf.DockerUser) && dockerCreds != nil) || conf.WithParamStore { 459 | policy, err := lib.CreatePolicy( 460 | ctx, sess, 461 | fmt.Sprintf("ecs-custom-%s", requestID), 462 | fmt.Sprintf( 463 | ecsGetParamsPolicyDocument, 464 | getKeyResourceName(ctx, sess, conf), 465 | aws.StringValue(conf.Aws.Region), 466 | conf.Aws.AccountID, 467 | aws.StringValue(conf.Aws.Region), 468 | conf.Aws.AccountID, 469 | )) 470 | if err != nil { 471 | return nil, err 472 | } 473 | credsPolicy = policy.Arn 474 | if err = lib.AttachPolicy(ctx, sess, roleName, credsPolicy); err != nil { 475 | return nil, err 476 | } 477 | time.Sleep(5 * time.Second) // Lag in policy reflection is now observed, wait 5 seconds 478 | } 479 | return execRoleArn, nil 480 | } 481 | 482 | func getAccountID(ctx context.Context, sess *session.Session, conf *config.CommonConfig, awsdfg *config.AwsConfig) (string, error) { 483 | if awsdfg.AccountID != "" { 484 | return awsdfg.AccountID, nil 485 | } 486 | account, err := sts.New(sess).GetCallerIdentityWithContext(ctx, nil) 487 | if err != nil { 488 | return "", err 489 | } 490 | if conf.IsDebugMode { 491 | log.PrintJSON(account) 492 | } 493 | return aws.StringValue(account.Account), nil 494 | } 495 | 496 | func getKeyResourceName(ctx context.Context, sess *session.Session, conf *config.RunConfig) string { 497 | keyID := aws.StringValue(conf.KMSCustomKeyID) 498 | if keyID == "" { 499 | return "" 500 | } 501 | if strings.HasPrefix(keyID, "arn:aws:kms:") { 502 | return fmt.Sprintf("\"%s\",", keyID) 503 | } 504 | if _, check := uuid.Parse(keyID); check == nil { 505 | return fmt.Sprintf( 506 | kmsCustomKeyID, 507 | aws.StringValue(conf.Aws.Region), 508 | conf.Aws.AccountID, 509 | "key/"+keyID, 510 | ) 511 | } 512 | // FIXME it doesn't work if you use alias 513 | // if strings.HasPrefix(keyID, "alias/") { 514 | // return fmt.Sprintf( 515 | // kmsCustomKeyID, 516 | // aws.StringValue(conf.Aws.Region), 517 | // conf.Aws.AccountID, 518 | // keyID, 519 | // ) 520 | // } 521 | return "" 522 | } 523 | 524 | func registerTaskDef(ctx context.Context, sess *session.Session, conf *config.RunConfig, image, execRoleArn *string, startedAt time.Time) (*string, *ecs.RegisterTaskDefinitionInput, error) { 525 | ssmParameterKey := fmt.Sprintf( 526 | "arn:aws:ssm:%s:%s:parameter/", 527 | aws.StringValue(conf.Aws.Region), 528 | conf.Aws.AccountID, 529 | ) 530 | secretsManagerKey := fmt.Sprintf( 531 | "arn:aws:secretsmanager:%s:%s:secret:", 532 | aws.StringValue(conf.Aws.Region), 533 | conf.Aws.AccountID, 534 | ) 535 | environments := []*ecs.KeyValuePair{} 536 | secrets := []*ecs.Secret{} 537 | for key, val := range conf.Environments { 538 | if strings.HasPrefix(aws.StringValue(val), ssmParameterKey) || strings.HasPrefix(aws.StringValue(val), secretsManagerKey) { 539 | secrets = append(secrets, &ecs.Secret{ 540 | Name: aws.String(key), 541 | ValueFrom: val, 542 | }) 543 | } else { 544 | environments = append(environments, &ecs.KeyValuePair{ 545 | Name: aws.String(key), 546 | Value: val, 547 | }) 548 | } 549 | } 550 | ports := []*ecs.PortMapping{} 551 | for _, port := range conf.Ports { 552 | ports = append(ports, &ecs.PortMapping{ 553 | ContainerPort: port, 554 | }) 555 | } 556 | labels := map[string]*string{} 557 | labels["com.github.pottava.ecs-task-runner.version"] = aws.String(conf.Common.AppVersion) 558 | labels["com.github.pottava.ecs-task-runner.started"] = aws.String(rfc3339(startedAt)) 559 | for key, value := range conf.Labels { 560 | labels[key] = value 561 | } 562 | containerDef := &ecs.ContainerDefinition{ 563 | Name: aws.String("app"), 564 | Image: image, 565 | EntryPoint: conf.Entrypoint, 566 | Command: conf.Commands, 567 | Environment: environments, 568 | Secrets: secrets, 569 | PortMappings: ports, 570 | DockerLabels: labels, 571 | Essential: aws.Bool(true), 572 | LogConfiguration: &ecs.LogConfiguration{ 573 | LogDriver: aws.String(awsCWLogs), 574 | Options: map[string]*string{ 575 | "awslogs-region": conf.Aws.Region, 576 | "awslogs-group": aws.String(logGroup), 577 | "awslogs-stream-prefix": aws.String(logPrefix), 578 | }, 579 | }, 580 | Privileged: aws.Bool(false), 581 | ReadonlyRootFilesystem: conf.ReadOnlyRootFS, 582 | } 583 | if conf.User != nil && len(aws.StringValue(conf.User)) > 0 { 584 | containerDef.User = conf.User 585 | } 586 | if !util.IsEmpty(conf.DockerUser) && dockerCreds != nil { 587 | containerDef.RepositoryCredentials = &ecs.RepositoryCredentials{ 588 | CredentialsParameter: dockerCreds, 589 | } 590 | } 591 | input := ecs.RegisterTaskDefinitionInput{ 592 | Family: conf.TaskDefFamily, 593 | RequiresCompatibilities: []*string{aws.String(fargate)}, 594 | ExecutionRoleArn: execRoleArn, 595 | TaskRoleArn: conf.TaskRoleArn, 596 | Cpu: conf.CPU, 597 | Memory: conf.Memory, 598 | NetworkMode: aws.String(awsVPC), 599 | ContainerDefinitions: []*ecs.ContainerDefinition{containerDef}, 600 | } 601 | if conf.Common.IsDebugMode { 602 | log.PrintJSON(input) 603 | } 604 | out, err := ecs.New(sess).RegisterTaskDefinitionWithContext(ctx, &input) 605 | if err != nil { 606 | return nil, nil, err 607 | } 608 | return out.TaskDefinition.TaskDefinitionArn, &input, nil 609 | } 610 | 611 | func run(ctx context.Context, sess *session.Session, conf *config.RunConfig) ([]*ecs.Task, *ecs.RunTaskInput, error) { 612 | assignPublicIP := "ENABLED" 613 | if !aws.BoolValue(conf.AssignPublicIP) { 614 | assignPublicIP = "DISABLED" 615 | } 616 | input := ecs.RunTaskInput{ 617 | Cluster: conf.Common.EcsCluster, 618 | TaskDefinition: taskDefARN, 619 | Count: conf.NumberOfTasks, 620 | NetworkConfiguration: &ecs.NetworkConfiguration{ 621 | AwsvpcConfiguration: &ecs.AwsVpcConfiguration{ 622 | AssignPublicIp: aws.String(assignPublicIP), 623 | Subnets: conf.Subnets, 624 | SecurityGroups: conf.SecurityGroups, 625 | }, 626 | }, 627 | } 628 | if aws.BoolValue(conf.Spot) { 629 | input.CapacityProviderStrategy = []*ecs.CapacityProviderStrategyItem{ 630 | &ecs.CapacityProviderStrategyItem{ 631 | CapacityProvider: aws.String(fargateSpot), 632 | Base: conf.NumberOfTasks, 633 | Weight: aws.Int64(1), 634 | }, 635 | } 636 | } else { 637 | input.LaunchType = aws.String(fargate) 638 | } 639 | if conf.Common.IsDebugMode { 640 | log.PrintJSON(input) 641 | } 642 | // Avoid the following error 643 | // ClientException: ECS was unable to assume the role that was provided for this task. 644 | timeout := time.After(30 * time.Second) 645 | for { 646 | var err error 647 | select { 648 | case <-timeout: 649 | return nil, nil, errors.New("The execute role for this task was not in Active in 30sec") 650 | default: 651 | var out *ecs.RunTaskOutput 652 | out, err = ecs.New(sess).RunTaskWithContext(ctx, &input) 653 | if err == nil { 654 | return out.Tasks, &input, nil 655 | } 656 | if ae, ok := err.(awserr.Error); ok && strings.EqualFold(ae.Code(), ecs.ErrCodeClientException) { 657 | time.Sleep(1 * time.Second) 658 | continue 659 | } 660 | return nil, nil, err 661 | } 662 | } 663 | } 664 | 665 | type judgeFunc func(task *ecs.Task) bool 666 | 667 | func waitForTask(ctx context.Context, sess *session.Session, conf *config.CommonConfig, tasks []*ecs.Task, judge judgeFunc) ([]*ecs.Task, error) { 668 | timeout := time.After(time.Duration(aws.Int64Value(conf.Timeout)) * time.Minute) 669 | taskARNs := []*string{} 670 | for _, task := range tasks { 671 | taskARNs = append(taskARNs, task.TaskArn) 672 | } 673 | for { 674 | select { 675 | case <-timeout: 676 | return nil, fmt.Errorf("The task did not finish in %d minutes", aws.Int64Value(conf.Timeout)) 677 | default: 678 | tasks, err := ecs.New(sess).DescribeTasksWithContext(ctx, &ecs.DescribeTasksInput{ 679 | Cluster: conf.EcsCluster, 680 | Tasks: taskARNs, 681 | }) 682 | if err != nil { 683 | return nil, err 684 | } 685 | if len(tasks.Tasks) > 0 { 686 | done := true 687 | for _, task := range tasks.Tasks { 688 | done = done && judge(task) 689 | } 690 | if done { 691 | if conf.IsDebugMode { 692 | log.PrintJSON(tasks.Tasks) 693 | } 694 | return tasks.Tasks, nil 695 | } 696 | } 697 | time.Sleep(1 * time.Second) 698 | } 699 | } 700 | } 701 | 702 | // DeleteResouces deletes temporary AWS resources 703 | func DeleteResouces(aws *config.AwsConfig, conf *config.CommonConfig, sess *session.Session) { 704 | wg := sync.WaitGroup{} 705 | wg.Add(2) 706 | 707 | go func() { 708 | defer wg.Done() 709 | deleteResoucesImmediately(aws, conf, sess) 710 | }() 711 | go func() { 712 | defer wg.Done() 713 | deleteResoucesInTheEnd(aws, conf, sess) 714 | }() 715 | wg.Wait() 716 | } 717 | 718 | func deleteResoucesImmediately(aws *config.AwsConfig, conf *config.CommonConfig, sess *session.Session) { 719 | wg := sync.WaitGroup{} 720 | wg.Add(3) 721 | 722 | // Delete the private registry creds in Secrets Manager 723 | go func() { 724 | defer wg.Done() 725 | lib.DeleteSecret(sess, dockerCreds, true, conf.IsDebugMode) 726 | }() 727 | // Delete the IAM policy for private registry creds 728 | go func() { 729 | defer wg.Done() 730 | if credsPolicy != nil { 731 | lib.DeletePolicy(sess, conf.ExecRoleName, credsPolicy, conf.IsDebugMode) 732 | } 733 | }() 734 | // Delete the temporary task definition 735 | go func() { 736 | defer wg.Done() 737 | lib.DeregisterTaskDef(sess, taskDefARN, conf.IsDebugMode) 738 | }() 739 | wg.Wait() 740 | } 741 | 742 | func deleteResoucesInTheEnd(aws *config.AwsConfig, conf *config.CommonConfig, sess *session.Session) { 743 | wg := sync.WaitGroup{} 744 | wg.Add(2) 745 | 746 | // Delete the temporary log group 747 | go func() { 748 | defer wg.Done() 749 | lib.DeleteLogGroup(sess, logGroup, conf.IsDebugMode) 750 | }() 751 | // Delete the temporary ECS cluster 752 | go func() { 753 | defer wg.Done() 754 | if !conf.ClusterExisted { 755 | lib.DeleteECSCluster(sess, requestID, conf.IsDebugMode) 756 | } 757 | }() 758 | wg.Wait() 759 | } 760 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pottava/ecs-task-runner 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.44.19 7 | github.com/docker/distribution v2.8.1+incompatible 8 | github.com/google/uuid v1.3.0 9 | github.com/stretchr/testify v1.7.1 10 | golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 11 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 12 | ) 13 | 14 | require ( 15 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 16 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 17 | github.com/jmespath/go-jmespath v0.4.0 // indirect 18 | github.com/opencontainers/go-digest v1.0.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= 2 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/aws/aws-sdk-go v1.44.19 h1:dhI6p4l6kisnA7gBAM8sP5YIk0bZ9HNAj7yrK7kcfdU= 6 | github.com/aws/aws-sdk-go v1.44.19/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= 7 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= 10 | github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 11 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 12 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 14 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 15 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 16 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 17 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 18 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 19 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 24 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 25 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 26 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 27 | golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4= 28 | golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 29 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 32 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 33 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 34 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 35 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 39 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 40 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 41 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 42 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 43 | -------------------------------------------------------------------------------- /internal/aws/cloudwatchlogs.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | cw "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 12 | "github.com/aws/aws-sdk-go/service/ecs" 13 | "github.com/pottava/ecs-task-runner/internal/log" 14 | ) 15 | 16 | // CreateLogGroup creates a LogGroup 17 | func CreateLogGroup(ctx context.Context, sess *session.Session, logGroup string) error { 18 | _, err := cw.New(sess).CreateLogGroupWithContext(ctx, &cw.CreateLogGroupInput{ 19 | LogGroupName: aws.String(logGroup), 20 | }) 21 | return err 22 | } 23 | 24 | var regTaskID = regexp.MustCompile("task/(.*)") 25 | 26 | // RetrieveLogs retrieve containers' logs 27 | func RetrieveLogs(ctx context.Context, sess *session.Session, tasks []*ecs.Task, clusterName, logGroupName, streamPrefix string) map[string][]*cw.OutputLogEvent { 28 | logs := map[string][]*cw.OutputLogEvent{} 29 | for _, task := range tasks { 30 | matched := regTaskID.FindAllStringSubmatch(aws.StringValue(task.TaskArn), -1) 31 | if len(matched) > 0 && len(matched[0]) > 1 { 32 | taskID := strings.Replace(matched[0][1], clusterName+"/", "", -1) 33 | name := "" 34 | if len(task.Containers) > 0 { 35 | name = aws.StringValue(task.Containers[0].Name) 36 | } 37 | if out, err := cw.New(sess).GetLogEventsWithContext(ctx, &cw.GetLogEventsInput{ 38 | LogGroupName: aws.String(logGroupName), 39 | LogStreamName: aws.String(fmt.Sprintf("%s/%s/%s", streamPrefix, name, taskID)), 40 | }); err == nil { 41 | logs[aws.StringValue(task.TaskArn)] = out.Events 42 | } 43 | } 44 | } 45 | return logs 46 | } 47 | 48 | // DeleteLogGroup deletes a specified LogGroup 49 | func DeleteLogGroup(sess *session.Session, logGroupName string, debug bool) { 50 | if _, err := cw.New(sess).DeleteLogGroup(&cw.DeleteLogGroupInput{ 51 | LogGroupName: aws.String(logGroupName), 52 | }); err != nil && debug { 53 | log.PrintJSON(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/aws/ec2.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | "github.com/aws/aws-sdk-go/service/ecs" 10 | "github.com/pottava/ecs-task-runner/conf" 11 | "github.com/pottava/ecs-task-runner/internal/log" 12 | ) 13 | 14 | // RetrievePublicIP retrieves public IP address from ENI 15 | func RetrievePublicIP(ctx context.Context, cfg *conf.AwsConfig, task *ecs.Task, debug bool) string { 16 | if task == nil || len(task.Attachments) == 0 { 17 | return "" 18 | } 19 | var eniID *string 20 | for _, detail := range task.Attachments[0].Details { 21 | if strings.EqualFold(aws.StringValue(detail.Name), "networkInterfaceId") { 22 | eniID = detail.Value 23 | } 24 | } 25 | if eniID == nil { 26 | return "" 27 | } 28 | sess, err := Session(cfg, debug) 29 | if err != nil && debug { 30 | log.PrintJSON(err) 31 | return "" 32 | } 33 | input := &ec2.DescribeNetworkInterfacesInput{ 34 | NetworkInterfaceIds: []*string{eniID}, 35 | } 36 | eni, err := ec2.New(sess).DescribeNetworkInterfacesWithContext(ctx, input) 37 | if err != nil { 38 | if debug { 39 | log.PrintJSON(err) 40 | } 41 | return "" 42 | } 43 | if len(eni.NetworkInterfaces) == 0 { 44 | return "" 45 | } 46 | return aws.StringValue(eni.NetworkInterfaces[0].Association.PublicIp) 47 | } 48 | -------------------------------------------------------------------------------- /internal/aws/ecs.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/ecs" 11 | "github.com/pottava/ecs-task-runner/internal/log" 12 | ) 13 | 14 | // CreateClusterIfNotExist creates a cluster if there is not any clusters 15 | func CreateClusterIfNotExist(ctx context.Context, sess *session.Session, cluster *string, spot *bool) (bool, error) { 16 | out, err := ecs.New(sess).DescribeClustersWithContext(ctx, &ecs.DescribeClustersInput{ 17 | Clusters: []*string{cluster}, 18 | }) 19 | if err != nil { 20 | return false, err 21 | } 22 | if len(out.Clusters) > 0 { 23 | return true, nil 24 | } 25 | input := &ecs.CreateClusterInput{ 26 | ClusterName: cluster, 27 | } 28 | if aws.BoolValue(spot) { 29 | input.CapacityProviders = []*string{fargateSpot} 30 | input.DefaultCapacityProviderStrategy = []*ecs.CapacityProviderStrategyItem{ 31 | &ecs.CapacityProviderStrategyItem{ 32 | CapacityProvider: fargateSpot, 33 | Weight: aws.Int64(1), 34 | Base: aws.Int64(0), 35 | }, 36 | } 37 | } 38 | _, err = ecs.New(sess).CreateClusterWithContext(ctx, input) 39 | if err != nil { 40 | return false, err 41 | } 42 | timeout := time.After(15 * time.Second) 43 | for { 44 | select { 45 | case <-timeout: 46 | return false, errors.New("The task did not finish in 15 seconds") 47 | default: 48 | out, err = ecs.New(sess).DescribeClustersWithContext(ctx, &ecs.DescribeClustersInput{ 49 | Clusters: []*string{cluster}, 50 | }) 51 | if err != nil { 52 | return false, err 53 | } 54 | if len(out.Clusters) > 0 && 55 | aws.StringValue(out.Clusters[0].Status) == ecs.CapacityProviderStatusActive { 56 | return false, nil 57 | } 58 | time.Sleep(2 * time.Second) 59 | } 60 | } 61 | } 62 | 63 | var fargateSpot = aws.String("FARGATE_SPOT") 64 | 65 | // StopTask stops a specified task 66 | func StopTask(ctx context.Context, sess *session.Session, cluster, taskARN *string) (*ecs.Task, error) { 67 | task, err := ecs.New(sess).StopTaskWithContext(ctx, &ecs.StopTaskInput{ 68 | Cluster: cluster, 69 | Task: taskARN, 70 | }) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return task.Task, nil 75 | } 76 | 77 | // DeregisterTaskDef deregisters a task definition 78 | func DeregisterTaskDef(sess *session.Session, taskDefinition *string, debug bool) { 79 | if _, err := ecs.New(sess).DeregisterTaskDefinition(&ecs.DeregisterTaskDefinitionInput{ 80 | TaskDefinition: taskDefinition, 81 | }); err != nil && debug { 82 | log.PrintJSON(err) 83 | } 84 | } 85 | 86 | // DeleteECSCluster deletes a ecs cluster 87 | func DeleteECSCluster(sess *session.Session, clusterName string, debug bool) { 88 | if _, err := ecs.New(sess).DeleteCluster(&ecs.DeleteClusterInput{ 89 | Cluster: aws.String(clusterName), 90 | }); err != nil && debug { 91 | log.PrintJSON(err) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/aws/iam.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/iam" 9 | "github.com/pottava/ecs-task-runner/internal/log" 10 | ) 11 | 12 | // CreatePolicy creates a policy 13 | func CreatePolicy(ctx context.Context, sess *session.Session, policyName, policyDoc string) (*iam.Policy, error) { 14 | out, err := iam.New(sess).CreatePolicyWithContext(ctx, &iam.CreatePolicyInput{ 15 | PolicyName: aws.String(policyName), 16 | PolicyDocument: aws.String(policyDoc), 17 | Path: aws.String("/"), 18 | }) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return out.Policy, nil 23 | } 24 | 25 | // AttachPolicy attaches a policy to some role 26 | func AttachPolicy(ctx context.Context, sess *session.Session, roleName, policyArn *string) error { 27 | _, err := iam.New(sess).AttachRolePolicyWithContext(ctx, &iam.AttachRolePolicyInput{ 28 | RoleName: roleName, 29 | PolicyArn: policyArn, 30 | }) 31 | return err 32 | } 33 | 34 | // DeletePolicy deletes an IAM policy 35 | func DeletePolicy(sess *session.Session, roleName, policyArn *string, debug bool) { 36 | if _, err := iam.New(sess).DetachRolePolicy(&iam.DetachRolePolicyInput{ 37 | RoleName: roleName, 38 | PolicyArn: policyArn, 39 | }); err != nil && debug { 40 | log.PrintJSON(err) 41 | } 42 | if _, err := iam.New(sess).DeletePolicy(&iam.DeletePolicyInput{ 43 | PolicyArn: policyArn, 44 | }); err != nil && debug { 45 | log.PrintJSON(err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/aws/secretsmanager.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | sm "github.com/aws/aws-sdk-go/service/secretsmanager" 9 | "github.com/pottava/ecs-task-runner/internal/log" 10 | ) 11 | 12 | // CreateSecret creates a secret 13 | func CreateSecret(ctx context.Context, sess *session.Session, name, kmsKeyID, secret *string) (*string, error) { 14 | out, err := sm.New(sess).CreateSecretWithContext(ctx, &sm.CreateSecretInput{ 15 | Name: name, 16 | KmsKeyId: kmsKeyID, 17 | SecretString: secret, 18 | }) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return out.ARN, nil 23 | } 24 | 25 | // DeleteSecret deletes a specified secret 26 | func DeleteSecret(sess *session.Session, secretID *string, force, debug bool) { 27 | if secretID == nil { 28 | return 29 | } 30 | if _, err := sm.New(sess).DeleteSecret(&sm.DeleteSecretInput{ 31 | SecretId: secretID, 32 | ForceDeleteWithoutRecovery: aws.Bool(force), 33 | }); err != nil && debug { 34 | log.PrintJSON(err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/aws/session.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/credentials" 8 | "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" 9 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/sts" 12 | "github.com/pottava/ecs-task-runner/conf" 13 | "github.com/pottava/ecs-task-runner/internal/util" 14 | ) 15 | 16 | // Session creaate a new AWS session 17 | func Session(cfg *conf.AwsConfig, debug bool, 18 | ) (*session.Session, error) { 19 | if !util.IsEmpty(cfg.AccessKey) { 20 | os.Setenv("AWS_ACCESS_KEY_ID", aws.StringValue(cfg.AccessKey)) // nolint 21 | } 22 | if !util.IsEmpty(cfg.SecretKey) { 23 | os.Setenv("AWS_SECRET_ACCESS_KEY", aws.StringValue(cfg.SecretKey)) // nolint 24 | } 25 | if !util.IsEmpty(cfg.Profile) { 26 | os.Setenv("AWS_PROFILE", aws.StringValue(cfg.Profile)) // nolint 27 | } 28 | awsConfig := &aws.Config{ 29 | Region: cfg.Region, 30 | Credentials: credentials.NewChainCredentials([]credentials.Provider{ 31 | &credentials.EnvProvider{}, 32 | &credentials.SharedCredentialsProvider{ 33 | // Use default values 34 | // - Filename: $HOME/.aws/credentials 35 | // - Profile: $AWS_PROFILE 36 | }, 37 | &ec2rolecreds.EC2RoleProvider{ 38 | Client: ec2metadata.New(session.Must(session.NewSession(&aws.Config{ 39 | Region: cfg.Region, 40 | }))), 41 | }, 42 | }), 43 | } 44 | sess, err := session.NewSession(awsConfig) 45 | if err != nil { 46 | return nil, err 47 | } 48 | if !util.IsEmpty(cfg.AssumeRole) { 49 | return assume(awsConfig, cfg, sess, cfg.AssumeRole) 50 | } 51 | return sess, nil 52 | } 53 | 54 | var sessionName = aws.String("ecs-task-runner-session") 55 | 56 | func assume(awsConfig *aws.Config, cfg *conf.AwsConfig, sess *session.Session, roleARN *string, 57 | ) (*session.Session, error) { 58 | input := &sts.AssumeRoleInput{ 59 | RoleArn: roleARN, 60 | RoleSessionName: sessionName, 61 | DurationSeconds: aws.Int64(900), 62 | } 63 | if !util.IsEmpty(cfg.MfaSerialNumber) && !util.IsEmpty(cfg.MfaToken) { 64 | input.SerialNumber = cfg.MfaSerialNumber 65 | input.TokenCode = cfg.MfaToken 66 | } 67 | assumed, err := sts.New(sess).AssumeRole(input) 68 | if err != nil { 69 | return nil, err 70 | } 71 | awsConfig.Credentials = credentials.NewChainCredentials([]credentials.Provider{ 72 | &credentials.StaticProvider{Value: credentials.Value{ 73 | AccessKeyID: aws.StringValue(assumed.Credentials.AccessKeyId), 74 | SecretAccessKey: aws.StringValue(assumed.Credentials.SecretAccessKey), 75 | SessionToken: aws.StringValue(assumed.Credentials.SessionToken), 76 | }}, 77 | }) 78 | return session.NewSession(awsConfig) 79 | } 80 | -------------------------------------------------------------------------------- /internal/aws/session_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/pottava/ecs-task-runner/conf" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSession(t *testing.T) { 13 | sess, err := Session(&conf.AwsConfig{}, false) 14 | assert.NotNil(t, sess) 15 | assert.Nil(t, err) 16 | } 17 | 18 | func TestSessionWithAllOptions(t *testing.T) { 19 | os.Setenv("DEBUG", "1") 20 | sess, err := Session( 21 | &conf.AwsConfig{ 22 | Region: aws.String("ap-northeast-1"), 23 | AccessKey: aws.String("AKIAIOSFODNN7EXAMPLE"), 24 | SecretKey: aws.String("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), 25 | Profile: aws.String("default"), 26 | AssumeRole: aws.String("arn:aws:iam::123456789012:role/role"), 27 | MfaSerialNumber: nil, 28 | MfaToken: nil, 29 | }, 30 | false, 31 | ) 32 | assert.Nil(t, sess) 33 | assert.NotNil(t, err) 34 | } 35 | -------------------------------------------------------------------------------- /internal/aws/vpc.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | ) 10 | 11 | // FindDefaultVPC finds the default VPC 12 | func FindDefaultVPC(ctx context.Context, sess *session.Session) *string { 13 | out, err := ec2.New(sess).DescribeVpcsWithContext(ctx, &ec2.DescribeVpcsInput{ 14 | Filters: []*ec2.Filter{ 15 | &ec2.Filter{ 16 | Name: aws.String("isDefault"), 17 | Values: []*string{aws.String("true")}, 18 | }, 19 | }, 20 | }) 21 | if err != nil || len(out.Vpcs) == 0 { 22 | return nil 23 | } 24 | return out.Vpcs[0].VpcId 25 | } 26 | 27 | // FindDefaultSubnet finds the default Subnet 28 | func FindDefaultSubnet(ctx context.Context, sess *session.Session, vpc *string) *string { 29 | out, err := ec2.New(sess).DescribeSubnetsWithContext(ctx, &ec2.DescribeSubnetsInput{ 30 | Filters: []*ec2.Filter{ 31 | &ec2.Filter{ 32 | Name: aws.String("vpc-id"), 33 | Values: []*string{vpc}, 34 | }, 35 | &ec2.Filter{ 36 | Name: aws.String("default-for-az"), 37 | Values: []*string{aws.String("true")}, 38 | }, 39 | }, 40 | }) 41 | if err != nil || len(out.Subnets) == 0 { 42 | return nil 43 | } 44 | return out.Subnets[0].SubnetId 45 | } 46 | 47 | // FindDefaultSecurityGroup finds the default SecurityGroup 48 | func FindDefaultSecurityGroup(ctx context.Context, sess *session.Session, vpc *string) *string { 49 | out, err := ec2.New(sess).DescribeSecurityGroupsWithContext(ctx, &ec2.DescribeSecurityGroupsInput{ 50 | Filters: []*ec2.Filter{ 51 | &ec2.Filter{ 52 | Name: aws.String("vpc-id"), 53 | Values: []*string{vpc}, 54 | }, 55 | &ec2.Filter{ 56 | Name: aws.String("group-name"), 57 | Values: []*string{aws.String("default")}, 58 | }, 59 | }, 60 | }) 61 | if err != nil || len(out.SecurityGroups) == 0 { 62 | return nil 63 | } 64 | return out.SecurityGroups[0].GroupId 65 | } 66 | -------------------------------------------------------------------------------- /internal/log/utils.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "encoding/json" 5 | native "log" 6 | "os" 7 | ) 8 | 9 | // Global loggers for this application 10 | var ( 11 | Logger = native.New(os.Stdout, "", 0) 12 | Errors = native.New(os.Stderr, "", 0) 13 | ) 14 | 15 | // PrintJSON print JSON marshaled value 16 | func PrintJSON(records interface{}) { 17 | marshaled, _ := json.MarshalIndent(records, "", " ") // nolint 18 | Logger.Println(string(marshaled)) 19 | } 20 | -------------------------------------------------------------------------------- /internal/log/utils_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPrintJSON(t *testing.T) { 12 | buf := &bytes.Buffer{} 13 | Logger = log.New(buf, "", 0) 14 | 15 | PrintJSON(1) 16 | 17 | assert.Equal(t, "1\n", buf.String()) 18 | } 19 | -------------------------------------------------------------------------------- /internal/util/functions.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | ) 6 | 7 | // IsEmpty returns the value has a valid value or not 8 | func IsEmpty(candidate *string) bool { 9 | return candidate == nil || aws.StringValue(candidate) == "" 10 | } 11 | -------------------------------------------------------------------------------- /output.go: -------------------------------------------------------------------------------- 1 | package etr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | cw "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 12 | "github.com/aws/aws-sdk-go/service/ecs" 13 | config "github.com/pottava/ecs-task-runner/conf" 14 | ec2 "github.com/pottava/ecs-task-runner/internal/aws" 15 | ) 16 | 17 | var ( 18 | exitNormally *int64 19 | exitWithError *int64 20 | ) 21 | 22 | func init() { 23 | exitNormally = aws.Int64(0) 24 | exitWithError = aws.Int64(1) 25 | } 26 | 27 | // Output is the result of this application 28 | type Output struct { 29 | ExitCode *int64 30 | SyncLogs map[string]interface{} 31 | RequestID string `json:"RequestID,omitempty"` 32 | AsyncTasks []OutputAsyncTask `json:"Tasks,omitempty"` 33 | Meta OutputMeta `json:"meta"` 34 | } 35 | 36 | // OutputAsyncTask is the output of async tasks 37 | type OutputAsyncTask struct { 38 | TaskARN string `json:"TaskARN"` 39 | PublicIP string `json:"PublicIP"` 40 | } 41 | 42 | // OutputMeta are the set of logs 43 | type OutputMeta struct { 44 | TaskDef *ecs.RegisterTaskDefinitionInput `json:"1.taskdef,omitempty"` 45 | RunConfig *ecs.RunTaskInput `json:"2.runconfig,omitempty"` 46 | Resources []OutputResources `json:"3.resources,omitempty"` 47 | Timelines []OutputTimelines `json:"4.timeline,omitempty"` 48 | ExitCodes []OutputExitCodes `json:"5.exitcodes,omitempty"` 49 | } 50 | 51 | // OutputResources represent AWS resources which were used 52 | type OutputResources struct { 53 | ClusterArn string `json:"ClusterArn,omitempty"` 54 | TaskDefinitionArn string `json:"TaskDefinitionArn,omitempty"` 55 | TaskArn string `json:"TaskArn,omitempty"` 56 | TaskRoleArn string `json:"TaskRoleArn,omitempty"` 57 | LogGroup string `json:"LogGroup,omitempty"` 58 | PublicIP string `json:"PublicIP,omitempty"` 59 | Containers []OutputContainerResources `json:"Containers,omitempty"` 60 | } 61 | 62 | // OutputContainerResources represent container resources 63 | type OutputContainerResources struct { 64 | ContainerArn string `json:"ContainerArn,omitempty"` 65 | LogStream string `json:"LogStream,omitempty"` 66 | } 67 | 68 | // OutputTimelines are the series of the events 69 | type OutputTimelines struct { 70 | AppStartedAt string `json:"0,omitempty"` 71 | AppTriedToRunFargateAt string `json:"1,omitempty"` 72 | FargateCreatedAt string `json:"2,omitempty"` 73 | FargatePullStartedAt string `json:"3,omitempty"` 74 | FargatePullStoppedAt string `json:"4,omitempty"` 75 | FargateStartedAt string `json:"5,omitempty"` 76 | FargateExecutionStoppedAt string `json:"6,omitempty"` 77 | FargateStoppedAt string `json:"7,omitempty"` 78 | AppRetrievedLogsAt string `json:"8,omitempty"` 79 | AppFinishedAt string `json:"9,omitempty"` 80 | } 81 | 82 | // OutputExitCodes represent applications status 83 | type OutputExitCodes struct { 84 | LastStatus string `json:"TaskLastStatus,omitempty"` 85 | HealthStatus string `json:"TaskHealthStatus,omitempty"` 86 | StopCode string `json:"TaskStopCode,omitempty"` 87 | StoppedReason string `json:"TaskStoppedReason,omitempty"` 88 | Containers []OutputContainerExitCodes `json:"Containers,omitempty"` 89 | } 90 | 91 | // OutputContainerExitCodes represent containers status 92 | type OutputContainerExitCodes struct { 93 | ExitCode int64 `json:"ExitCode,omitempty"` 94 | Reason string `json:"Reason,omitempty"` 95 | LastStatus string `json:"LastStatus,omitempty"` 96 | HealthStatus string `json:"HealthStatus,omitempty"` 97 | } 98 | 99 | var regTaskID = regexp.MustCompile("task/(.*)") 100 | 101 | func runResults(ctx context.Context, conf *config.RunConfig, startedAt, runTaskAt time.Time, logsAt *time.Time, logs map[string][]*cw.OutputLogEvent, taskdef *ecs.RegisterTaskDefinitionInput, runconfig *ecs.RunTaskInput, tasks []*ecs.Task) *Output { 102 | result := &Output{ExitCode: exitNormally} 103 | 104 | if aws.BoolValue(conf.Asynchronous) { // Async mode 105 | result.RequestID = requestID 106 | if len(tasks) > 0 { 107 | asyncTasks := []OutputAsyncTask{} 108 | for _, task := range tasks { 109 | asyncTasks = append(asyncTasks, OutputAsyncTask{ 110 | TaskARN: aws.StringValue(task.TaskArn), 111 | PublicIP: ec2.RetrievePublicIP( 112 | ctx, 113 | conf.Aws, 114 | task, 115 | conf.Common.IsDebugMode, 116 | ), 117 | }) 118 | } 119 | result.AsyncTasks = asyncTasks 120 | } 121 | } else { // Sync mode 122 | result.SyncLogs = map[string]interface{}{} 123 | seq := 1 124 | for _, value := range logs { 125 | messages := []string{} 126 | for _, event := range value { 127 | messages = append(messages, fmt.Sprintf( 128 | "%s: %s", 129 | time.Unix(aws.Int64Value(event.Timestamp)/1000, 0).Format(time.RFC3339), 130 | aws.StringValue(event.Message), 131 | )) 132 | } 133 | result.SyncLogs[fmt.Sprintf("container-%d", seq)] = messages 134 | seq++ 135 | } 136 | } 137 | timelines := []OutputTimelines{} 138 | resources := []OutputResources{} 139 | exitcodes := []OutputExitCodes{} 140 | if len(tasks) > 0 { 141 | for _, task := range tasks { 142 | containers := []OutputContainerResources{} 143 | taskID := "" 144 | matched := regTaskID.FindAllStringSubmatch(aws.StringValue(task.TaskArn), -1) 145 | if len(matched) > 0 && len(matched[0]) > 1 { 146 | taskID = strings.Replace(matched[0][1], requestID+"/", "", -1) 147 | } 148 | for _, container := range task.Containers { 149 | containers = append(containers, OutputContainerResources{ 150 | ContainerArn: aws.StringValue(container.ContainerArn), 151 | LogStream: fmt.Sprintf("fargate/%s/%s", aws.StringValue(container.Name), taskID), 152 | }) 153 | } 154 | container := taskdef.ContainerDefinitions[0] 155 | resource := OutputResources{ 156 | ClusterArn: aws.StringValue(task.ClusterArn), 157 | TaskDefinitionArn: aws.StringValue(task.TaskDefinitionArn), 158 | TaskArn: aws.StringValue(task.TaskArn), 159 | TaskRoleArn: aws.StringValue(taskdef.TaskRoleArn), 160 | LogGroup: aws.StringValue(container.LogConfiguration.Options["awslogs-group"]), 161 | Containers: containers, 162 | } 163 | if aws.BoolValue(conf.Common.ExtendedOutput) { 164 | resource.PublicIP = ec2.RetrievePublicIP( 165 | ctx, 166 | conf.Aws, 167 | task, 168 | conf.Common.IsDebugMode, 169 | ) 170 | } 171 | resources = append(resources, resource) 172 | 173 | timeline := OutputTimelines{ 174 | AppStartedAt: fmt.Sprintf("AppStartedAt: %s", rfc3339(startedAt)), 175 | AppTriedToRunFargateAt: fmt.Sprintf("AppTriedToRunFargateAt: %s", rfc3339(runTaskAt)), 176 | FargateCreatedAt: fmt.Sprintf("FargateCreatedAt: %s", toStr(task.CreatedAt)), 177 | FargatePullStartedAt: fmt.Sprintf("FargatePullStartedAt: %s", toStr(task.PullStartedAt)), 178 | FargatePullStoppedAt: fmt.Sprintf("FargatePullStoppedAt: %s", toStr(task.PullStoppedAt)), 179 | FargateStartedAt: fmt.Sprintf("FargateStartedAt: %s", toStr(task.StartedAt)), 180 | FargateExecutionStoppedAt: fmt.Sprintf("FargateExecutionStoppedAt: %s", toStr(task.ExecutionStoppedAt)), 181 | FargateStoppedAt: fmt.Sprintf("FargateStoppedAt: %s", toStr(task.StoppedAt)), 182 | AppRetrievedLogsAt: fmt.Sprintf("AppRetrievedLogsAt: %s", rfc3339(aws.TimeValue(logsAt))), 183 | AppFinishedAt: fmt.Sprintf("AppFinishedAt: %s", rfc3339(time.Now())), 184 | } 185 | timelines = append(timelines, timeline) 186 | 187 | exitcode := OutputExitCodes{ 188 | LastStatus: aws.StringValue(task.LastStatus), 189 | HealthStatus: aws.StringValue(task.HealthStatus), 190 | StopCode: aws.StringValue(task.StopCode), 191 | StoppedReason: aws.StringValue(task.StoppedReason), 192 | } 193 | containerExitCodes := []OutputContainerExitCodes{} 194 | for _, container := range task.Containers { 195 | containerExitCodes = append(containerExitCodes, OutputContainerExitCodes{ 196 | ExitCode: aws.Int64Value(container.ExitCode), 197 | Reason: aws.StringValue(container.Reason), 198 | LastStatus: aws.StringValue(container.LastStatus), 199 | HealthStatus: aws.StringValue(container.HealthStatus), 200 | }) 201 | } 202 | exitcode.Containers = containerExitCodes 203 | exitcodes = append(exitcodes, exitcode) 204 | } 205 | result.Meta = OutputMeta{ 206 | TaskDef: taskdef, 207 | RunConfig: runconfig, 208 | Resources: resources, 209 | Timelines: timelines, 210 | ExitCodes: exitcodes, 211 | } 212 | } 213 | return result 214 | } 215 | 216 | func stopResults(ctx context.Context, conf *config.StopConfig, logs map[string][]*cw.OutputLogEvent, tasks []*ecs.Task) *Output { 217 | result := &Output{ExitCode: exitNormally} 218 | result.SyncLogs = map[string]interface{}{} 219 | seq := 1 220 | for _, value := range logs { 221 | messages := []string{} 222 | for _, event := range value { 223 | messages = append(messages, fmt.Sprintf( 224 | "%s: %s", 225 | time.Unix(aws.Int64Value(event.Timestamp)/1000, 0).Format(time.RFC3339), 226 | aws.StringValue(event.Message), 227 | )) 228 | } 229 | result.SyncLogs[fmt.Sprintf("container-%d", seq)] = messages 230 | seq++ 231 | } 232 | timelines := []OutputTimelines{} 233 | resources := []OutputResources{} 234 | exitcodes := []OutputExitCodes{} 235 | if len(tasks) > 0 { 236 | for _, task := range tasks { 237 | containers := []OutputContainerResources{} 238 | taskID := "" 239 | matched := regTaskID.FindAllStringSubmatch(aws.StringValue(task.TaskArn), -1) 240 | if len(matched) > 0 && len(matched[0]) > 1 { 241 | taskID = strings.Replace(matched[0][1], requestID+"/", "", -1) 242 | } 243 | for _, container := range task.Containers { 244 | containers = append(containers, OutputContainerResources{ 245 | ContainerArn: aws.StringValue(container.ContainerArn), 246 | LogStream: fmt.Sprintf("fargate/%s/%s", aws.StringValue(container.Name), taskID), 247 | }) 248 | } 249 | resource := OutputResources{ 250 | ClusterArn: aws.StringValue(task.ClusterArn), 251 | TaskDefinitionArn: aws.StringValue(task.TaskDefinitionArn), 252 | TaskArn: aws.StringValue(task.TaskArn), 253 | Containers: containers, 254 | } 255 | resources = append(resources, resource) 256 | 257 | timeline := OutputTimelines{ 258 | FargateCreatedAt: fmt.Sprintf("FargateCreatedAt: %s", toStr(task.CreatedAt)), 259 | FargatePullStartedAt: fmt.Sprintf("FargatePullStartedAt: %s", toStr(task.PullStartedAt)), 260 | FargatePullStoppedAt: fmt.Sprintf("FargatePullStoppedAt: %s", toStr(task.PullStoppedAt)), 261 | FargateStartedAt: fmt.Sprintf("FargateStartedAt: %s", toStr(task.StartedAt)), 262 | FargateExecutionStoppedAt: fmt.Sprintf("FargateExecutionStoppedAt: %s", toStr(task.ExecutionStoppedAt)), 263 | FargateStoppedAt: fmt.Sprintf("FargateStoppedAt: %s", toStr(task.StoppedAt)), 264 | AppFinishedAt: fmt.Sprintf("AppFinishedAt: %s", rfc3339(time.Now())), 265 | } 266 | timelines = append(timelines, timeline) 267 | 268 | exitcode := OutputExitCodes{ 269 | LastStatus: aws.StringValue(task.LastStatus), 270 | HealthStatus: aws.StringValue(task.HealthStatus), 271 | StopCode: aws.StringValue(task.StopCode), 272 | StoppedReason: aws.StringValue(task.StoppedReason), 273 | } 274 | containerExitCodes := []OutputContainerExitCodes{} 275 | for _, container := range task.Containers { 276 | containerExitCodes = append(containerExitCodes, OutputContainerExitCodes{ 277 | ExitCode: aws.Int64Value(container.ExitCode), 278 | Reason: aws.StringValue(container.Reason), 279 | LastStatus: aws.StringValue(container.LastStatus), 280 | HealthStatus: aws.StringValue(container.HealthStatus), 281 | }) 282 | } 283 | exitcode.Containers = containerExitCodes 284 | exitcodes = append(exitcodes, exitcode) 285 | } 286 | result.Meta = OutputMeta{ 287 | Resources: resources, 288 | Timelines: timelines, 289 | ExitCodes: exitcodes, 290 | } 291 | } 292 | return result 293 | } 294 | 295 | func toStr(t *time.Time) string { 296 | if t == nil { 297 | return "---" 298 | } 299 | return rfc3339(aws.TimeValue(t)) 300 | } 301 | 302 | func rfc3339(t time.Time) string { 303 | return t.UTC().Format(time.RFC3339) 304 | } 305 | -------------------------------------------------------------------------------- /versions/1.1/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | ENV DEP_VERSION=v0.5.0 \ 4 | VERSION=1.1 \ 5 | AWS_REGION=us-east-1 6 | 7 | RUN apk add --no-cache ca-certificates 8 | 9 | RUN apk --no-cache add --virtual build-deps gcc musl-dev go git \ 10 | && export GOPATH=/go \ 11 | && export PATH=$GOPATH/bin:$PATH \ 12 | && mkdir $GOPATH \ 13 | && chmod -R 777 $GOPATH \ 14 | && go get -u github.com/golang/dep/... \ 15 | && cd /go/src/github.com/golang/dep \ 16 | && git checkout ${DEP_VERSION} \ 17 | && cd cmd/dep \ 18 | && go build \ 19 | && go get github.com/pottava/ecs-task-runner \ 20 | && cd /go/src/github.com/pottava/ecs-task-runner \ 21 | && git checkout ${VERSION} \ 22 | && githash=$(git rev-parse --short HEAD 2>/dev/null) \ 23 | && /go/src/github.com/golang/dep/cmd/dep/dep ensure \ 24 | && cd cmd/ecs-task-runner \ 25 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${VERSION} -X main.commit=${githash}" \ 26 | && mv ecs-task-runner /usr/bin/ \ 27 | && apk del --purge -r build-deps \ 28 | && cd / \ 29 | && rm -rf /go /root/.cache 30 | 31 | ENTRYPOINT ["ecs-task-runner"] 32 | CMD ["--help"] 33 | -------------------------------------------------------------------------------- /versions/1.2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | ENV DEP_VERSION=v0.5.0 \ 4 | VERSION=1.2 \ 5 | AWS_REGION=us-east-1 6 | 7 | RUN apk add --no-cache ca-certificates 8 | 9 | RUN apk --no-cache add --virtual build-deps gcc musl-dev go git \ 10 | && export GOPATH=/go \ 11 | && export PATH=$GOPATH/bin:$PATH \ 12 | && mkdir $GOPATH \ 13 | && chmod -R 777 $GOPATH \ 14 | && go get -u github.com/golang/dep/... \ 15 | && cd /go/src/github.com/golang/dep \ 16 | && git checkout ${DEP_VERSION} \ 17 | && cd cmd/dep \ 18 | && go build \ 19 | && go get github.com/pottava/ecs-task-runner \ 20 | && cd /go/src/github.com/pottava/ecs-task-runner \ 21 | && git checkout ${VERSION} \ 22 | && githash=$(git rev-parse --short HEAD 2>/dev/null) \ 23 | && /go/src/github.com/golang/dep/cmd/dep/dep ensure \ 24 | && cd cmd/ecs-task-runner \ 25 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${VERSION} -X main.commit=${githash}" \ 26 | && mv ecs-task-runner /usr/bin/ \ 27 | && apk del --purge -r build-deps \ 28 | && cd / \ 29 | && rm -rf /go /root/.cache 30 | 31 | ENTRYPOINT ["ecs-task-runner"] 32 | CMD ["--help"] 33 | -------------------------------------------------------------------------------- /versions/2.0/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | ENV DEP_VERSION=v0.5.0 \ 4 | APP_VERSION=2.0 \ 5 | AWS_DEFAULT_REGION=us-east-1 6 | 7 | RUN apk add --no-cache ca-certificates 8 | 9 | RUN apk --no-cache add --virtual build-deps gcc musl-dev go git \ 10 | && export GOPATH=/go \ 11 | && export PATH=$GOPATH/bin:$PATH \ 12 | && mkdir $GOPATH \ 13 | && chmod -R 777 $GOPATH \ 14 | && go get -u github.com/golang/dep/... \ 15 | && cd /go/src/github.com/golang/dep \ 16 | && git checkout ${DEP_VERSION} \ 17 | && cd cmd/dep \ 18 | && go build \ 19 | && go get github.com/pottava/ecs-task-runner \ 20 | && cd /go/src/github.com/pottava/ecs-task-runner \ 21 | && git checkout ${APP_VERSION} \ 22 | && githash=$(git rev-parse --short HEAD 2>/dev/null) \ 23 | && /go/src/github.com/golang/dep/cmd/dep/dep ensure \ 24 | && cd cmd/ecs-task-runner \ 25 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${APP_VERSION} -X main.commit=${githash}" \ 26 | && mv ecs-task-runner /usr/bin/ \ 27 | && apk del --purge -r build-deps \ 28 | && cd / \ 29 | && rm -rf /go /root/.cache 30 | 31 | ENTRYPOINT ["ecs-task-runner", "run"] 32 | CMD ["--help"] 33 | -------------------------------------------------------------------------------- /versions/2.1/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | ENV DEP_VERSION=v0.5.0 \ 4 | APP_VERSION=2.1 \ 5 | AWS_DEFAULT_REGION=us-east-1 6 | 7 | RUN apk add --no-cache ca-certificates 8 | 9 | RUN apk --no-cache add --virtual build-deps gcc musl-dev go git \ 10 | && export GOPATH=/go \ 11 | && export PATH=$GOPATH/bin:$PATH \ 12 | && mkdir $GOPATH \ 13 | && chmod -R 777 $GOPATH \ 14 | && go get -u github.com/golang/dep/... \ 15 | && cd /go/src/github.com/golang/dep \ 16 | && git checkout ${DEP_VERSION} \ 17 | && cd cmd/dep \ 18 | && go build \ 19 | && go get github.com/pottava/ecs-task-runner \ 20 | && cd /go/src/github.com/pottava/ecs-task-runner \ 21 | && git checkout ${APP_VERSION} \ 22 | && githash=$(git rev-parse --short HEAD 2>/dev/null) \ 23 | && /go/src/github.com/golang/dep/cmd/dep/dep ensure \ 24 | && cd cmd/ecs-task-runner \ 25 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${APP_VERSION} -X main.commit=${githash}" \ 26 | && mv ecs-task-runner /usr/bin/ \ 27 | && apk del --purge -r build-deps \ 28 | && cd / \ 29 | && rm -rf /go /root/.cache 30 | 31 | ENTRYPOINT ["ecs-task-runner", "run"] 32 | CMD ["--help"] 33 | -------------------------------------------------------------------------------- /versions/2.2/Dockerfile: -------------------------------------------------------------------------------- 1 | # ECS Task Runner v2.2 2 | # docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY pottava/ecs-task-runner alpine --entrypoint env 3 | 4 | FROM golang:1.11.4-alpine3.8 AS build-env 5 | RUN apk --no-cache add gcc musl-dev git 6 | RUN go get -u github.com/golang/dep/... 7 | WORKDIR /go/src/github.com/golang/dep 8 | RUN git checkout v0.5.0 > /dev/null 2>&1 9 | RUN go install github.com/golang/dep/... 10 | RUN go get -u github.com/pottava/ecs-task-runner/... 11 | WORKDIR /go/src/github.com/pottava/ecs-task-runner 12 | ENV APP_VERSION=2.2 13 | RUN git checkout "${APP_VERSION}" > /dev/null 2>&1 14 | RUN dep ensure 15 | RUN githash=$(git rev-parse --short HEAD 2>/dev/null) \ 16 | && cd /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner \ 17 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${APP_VERSION} -X main.commit=${githash}" 18 | RUN mv /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner/ecs-task-runner / 19 | 20 | FROM alpine:3.8 21 | ENV AWS_DEFAULT_REGION=us-east-1 22 | RUN apk add --no-cache ca-certificates 23 | COPY --from=build-env /ecs-task-runner /ecs-task-runner 24 | ENTRYPOINT ["/ecs-task-runner", "run"] 25 | CMD ["--help"] 26 | -------------------------------------------------------------------------------- /versions/2.3/Dockerfile: -------------------------------------------------------------------------------- 1 | # ECS Task Runner v2.3 2 | # docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY pottava/ecs-task-runner alpine --entrypoint env 3 | 4 | FROM golang:1.12.7-alpine3.10 AS build-env 5 | RUN apk --no-cache add gcc musl-dev git 6 | RUN go get -u github.com/golang/dep/... 7 | WORKDIR /go/src/github.com/golang/dep 8 | RUN git checkout v0.5.0 > /dev/null 2>&1 9 | RUN go install github.com/golang/dep/... 10 | RUN go get -u github.com/pottava/ecs-task-runner/... 11 | WORKDIR /go/src/github.com/pottava/ecs-task-runner 12 | ENV APP_VERSION=2.3 13 | RUN git checkout "${APP_VERSION}" > /dev/null 2>&1 14 | RUN dep ensure 15 | RUN githash=$(git rev-parse --short HEAD 2>/dev/null) \ 16 | && cd /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner \ 17 | && go build -ldflags "-s -w -X main.date=$(date +%Y-%m-%d --utc) -X main.version=${APP_VERSION} -X main.commit=${githash}" 18 | RUN mv /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner/ecs-task-runner / 19 | 20 | FROM alpine:3.10 21 | RUN apk add --no-cache ca-certificates 22 | COPY --from=build-env /ecs-task-runner /ecs-task-runner 23 | ENTRYPOINT ["/ecs-task-runner", "run"] 24 | CMD ["--help"] 25 | -------------------------------------------------------------------------------- /versions/3.0/Dockerfile: -------------------------------------------------------------------------------- 1 | # ECS Task Runner v3.0 2 | # docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY pottava/ecs-task-runner alpine --entrypoint env 3 | 4 | FROM golang:1.23.3-alpine3.20 AS builder 5 | RUN apk --no-cache add gcc musl-dev git 6 | RUN mkdir -p /go/src/github.com/pottava 7 | WORKDIR /go/src/github.com/pottava 8 | ENV APP_VERSION=3.0.3 9 | RUN git clone -b "${APP_VERSION}" https://github.com/pottava/ecs-task-runner.git 10 | WORKDIR /go/src/github.com/pottava/ecs-task-runner 11 | RUN go mod download 12 | RUN go mod verify 13 | RUN githash=$(git rev-parse --short HEAD 2>/dev/null) \ 14 | && today=$(date +%Y-%m-%d --utc) \ 15 | && cd /go/src/github.com/pottava/ecs-task-runner/cmd/ecs-task-runner \ 16 | && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ 17 | -ldflags '-s -w -X main.ver=${APP_VERSION} -X main.commit=${githash} -X main.date=${today}' \ 18 | -o /app 19 | 20 | FROM alpine:3.20 AS libs 21 | RUN apk --no-cache add ca-certificates 22 | 23 | FROM scratch 24 | COPY --from=libs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 25 | COPY --from=builder /app /ecs-task-runner 26 | ENTRYPOINT ["/ecs-task-runner", "run"] 27 | CMD ["--help"] 28 | --------------------------------------------------------------------------------