├── .gitignore ├── examples ├── ecspresso.yml ├── ecs-task-def.jsonnet ├── ecs-container-def.jsonnet └── ecs-service-def.jsonnet ├── Makefile ├── go.mod ├── command.go ├── LICENSE.txt ├── service_definition.go ├── cmd └── demitas │ ├── main.go │ └── optparse.go ├── ecspresso_config.go ├── task_definition.go ├── utils └── utils.go ├── container_definition.go ├── run_task.go ├── demitas-pf ├── demitas-exec ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /demitas 2 | /*.gz 3 | /*.gz.sha* 4 | -------------------------------------------------------------------------------- /examples/ecspresso.yml: -------------------------------------------------------------------------------- 1 | region: ap-northeast-1 2 | cluster: my-cluster 3 | service: my-service 4 | -------------------------------------------------------------------------------- /examples/ecs-task-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | family: 'my-oneshot-task', 3 | cpu: '256', 4 | memory: '512', 5 | networkMode: 'awsvpc', 6 | taskRoleArn: 'arn:aws:iam::xxx:role/my-role', 7 | executionRoleArn: 'arn:aws:iam::xxx:role/my-exec-role', 8 | requiresCompatibilities: ['FARGATE'], 9 | } 10 | -------------------------------------------------------------------------------- /examples/ecs-container-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | name: 'oneshot', 3 | cpu: 0, 4 | essential: true, 5 | logConfiguration: { 6 | logDriver: 'awslogs', 7 | options: { 8 | 'awslogs-group': '/ecs/oneshot', 9 | 'awslogs-region': 'ap-northeast-1', 10 | 'awslogs-stream-prefix': 'ecs', 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /examples/ecs-service-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | launchType: 'FARGATE', 3 | networkConfiguration: { 4 | awsvpcConfiguration: { 5 | assignPublicIp: 'DISABLED', 6 | securityGroups: ['sg-xxx'], 7 | subnets: ['subnet-xxx'], 8 | }, 9 | }, 10 | enableExecuteCommand: true, 11 | //capacityProviderStrategy: [ 12 | // { 13 | // capacityProvider: 'FARGATE_SPOT', 14 | // weight: 1, 15 | // }, 16 | //], 17 | } 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | VERSION := v0.6.3 3 | GOOS := $(shell go env GOOS) 4 | GOARCH := $(shell go env GOARCH) 5 | 6 | .PHONY: all 7 | all: vet build 8 | 9 | .PHONY: build 10 | build: 11 | go build -ldflags "-X main.version=$(VERSION)" ./cmd/demitas 12 | 13 | .PHONY: vet 14 | vet: 15 | go vet 16 | 17 | .PHONY: package 18 | package: clean vet build 19 | tar zcf demitas_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz demitas demitas-* 20 | shasum -a 256 demitas_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz > demitas_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz.sha256sum 21 | 22 | .PHONY: clean 23 | clean: 24 | rm -f demitas 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/winebarrel/demitas 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/evanphx/json-patch v0.5.2 7 | github.com/goccy/go-yaml v1.9.4 8 | github.com/google/go-jsonnet v0.17.0 9 | github.com/integrii/flaggy v1.4.4 10 | github.com/valyala/fastjson v1.6.3 11 | ) 12 | 13 | require ( 14 | github.com/fatih/color v1.10.0 // indirect 15 | github.com/mattn/go-colorable v0.1.8 // indirect 16 | github.com/mattn/go-isatty v0.0.12 // indirect 17 | github.com/pkg/errors v0.9.1 // indirect 18 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae // indirect 19 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | "os/signal" 8 | "sync" 9 | ) 10 | 11 | func runCommand(cmdWithArgs []string) error { 12 | cmd := exec.Command(cmdWithArgs[0], cmdWithArgs[1:]...) 13 | 14 | outReader, err := cmd.StdoutPipe() 15 | 16 | if err != nil { 17 | return err 18 | } 19 | 20 | errReader, err := cmd.StderrPipe() 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | wg := &sync.WaitGroup{} 27 | wg.Add(2) 28 | 29 | sig := make(chan os.Signal, 1) 30 | signal.Notify(sig) 31 | 32 | go func() { 33 | for { 34 | s := <-sig 35 | _ = cmd.Process.Signal(s) 36 | } 37 | }() 38 | 39 | go func() { 40 | _, _ = io.Copy(os.Stdout, outReader) 41 | wg.Done() 42 | }() 43 | 44 | go func() { 45 | _, _ = io.Copy(os.Stderr, errReader) 46 | wg.Done() 47 | }() 48 | 49 | err = cmd.Start() 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | err = cmd.Wait() 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | wg.Wait() 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Genki Sugawara 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /service_definition.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "fmt" 5 | 6 | jsonpatch "github.com/evanphx/json-patch" 7 | "github.com/winebarrel/demitas/utils" 8 | ) 9 | 10 | const ( 11 | ServiceDefinitionName = "ecs-service-def.json" 12 | ) 13 | 14 | type ServiceDefinition struct { 15 | Content []byte 16 | } 17 | 18 | func NewServiceDefinition(path string) (*ServiceDefinition, error) { 19 | content, err := utils.ReadJSONorJsonnet(path) 20 | 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to load ECS service definition: %w: %s", err, path) 23 | } 24 | 25 | svrDef := &ServiceDefinition{ 26 | Content: content, 27 | } 28 | 29 | return svrDef, nil 30 | } 31 | 32 | func (svrDef *ServiceDefinition) Patch(overrides []byte) error { 33 | patchedContent, err := jsonpatch.MergePatch(svrDef.Content, overrides) 34 | 35 | if err != nil { 36 | return fmt.Errorf("failed to patch ECS service definition: %w", err) 37 | } 38 | 39 | svrDef.Content = patchedContent 40 | 41 | return nil 42 | } 43 | 44 | func (svrDef *ServiceDefinition) Print() { 45 | fmt.Printf("# %s\n%s\n", ServiceDefinitionName, utils.PrettyJSON(svrDef.Content)) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/demitas/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/winebarrel/demitas" 8 | ) 9 | 10 | func init() { 11 | log.SetFlags(0) 12 | } 13 | 14 | func main() { 15 | opts := parseArgs() 16 | 17 | containerDef, err := demitas.BuildContainerDefinition(opts.ContainerDefSrc, opts.TaskDefSrc, opts.ContainerDefOverrides) 18 | 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | taskDef, err := demitas.BuildTaskDefinition(opts.TaskDefSrc, opts.TaskDefOverrides, containerDef) 24 | 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | svrDef, err := demitas.BuildServiceDefinition(opts.ServiceDefSrc, opts.ServiceDefOverrides) 30 | 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | ecsConf, err := demitas.BuildEcspressoConfig(opts.EcspressoConfigSrc, opts.EcspressoConfigOverrides) 36 | 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | if opts.PrintConfig { 42 | ecsConf.Print() 43 | fmt.Println() 44 | svrDef.Print() 45 | fmt.Println() 46 | taskDef.Print() 47 | } else { 48 | err = demitas.RunTask(&opts.RunOptions, ecsConf, svrDef, taskDef) 49 | 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ecspresso_config.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | 8 | jsonpatch "github.com/evanphx/json-patch" 9 | "github.com/winebarrel/demitas/utils" 10 | ) 11 | 12 | const ( 13 | EcspressoConfigName = "ecspresso.yml" 14 | ) 15 | 16 | type EcspressoConfig struct { 17 | Content []byte 18 | } 19 | 20 | func NewEcspressoConfig(path string) (*EcspressoConfig, error) { 21 | content, err := ioutil.ReadFile(path) 22 | 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to load ecspresso config: %w: %s", err, path) 25 | } 26 | 27 | js, err := utils.YAMLToJSON(content) 28 | 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to parse ecspresso config: %w: %s", err, path) 31 | } 32 | 33 | ecsConf := &EcspressoConfig{ 34 | Content: js, 35 | } 36 | 37 | return ecsConf, nil 38 | } 39 | 40 | func (ecsConf *EcspressoConfig) Patch(overrides []byte) error { 41 | patchedContent, err := jsonpatch.MergePatch(ecsConf.Content, overrides) 42 | 43 | if err != nil { 44 | return fmt.Errorf("failed to patch ecspresso config: %w", err) 45 | } 46 | 47 | ecsConf.Content = patchedContent 48 | 49 | return nil 50 | } 51 | 52 | func (ecsConf *EcspressoConfig) Print() { 53 | ym, _ := utils.JSONToYAML(ecsConf.Content) 54 | fmt.Printf("# %s\n%s\n", EcspressoConfigName, strings.TrimSpace(string(ym))) 55 | } 56 | -------------------------------------------------------------------------------- /task_definition.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "fmt" 5 | 6 | jsonpatch "github.com/evanphx/json-patch" 7 | "github.com/winebarrel/demitas/utils" 8 | ) 9 | 10 | const ( 11 | TaskDefinitionName = "ecs-task-def.json" 12 | ) 13 | 14 | type TaskDefinition struct { 15 | Content []byte 16 | } 17 | 18 | func NewTaskDefinition(path string) (*TaskDefinition, error) { 19 | content, err := utils.ReadJSONorJsonnet(path) 20 | 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to load ECS task definition: %w: %s", err, path) 23 | } 24 | 25 | taskDef := &TaskDefinition{ 26 | Content: content, 27 | } 28 | 29 | return taskDef, nil 30 | } 31 | 32 | func (taskDef *TaskDefinition) Patch(overrides []byte, containerDef *ContainerDefinition) error { 33 | patchedContent := taskDef.Content 34 | var err error 35 | 36 | if len(overrides) > 0 { 37 | patchedContent, err = jsonpatch.MergePatch(patchedContent, overrides) 38 | 39 | if err != nil { 40 | return fmt.Errorf("failed to patch ECS task definition: %w", err) 41 | } 42 | } 43 | 44 | containerDefinitions := fmt.Sprintf(`{"containerDefinitions":[%s]}`, string(containerDef.Content)) 45 | patchedContent, err = jsonpatch.MergePatch(patchedContent, []byte(containerDefinitions)) 46 | fmt.Println(utils.PrettyJSON(patchedContent)) 47 | 48 | if err != nil { 49 | return fmt.Errorf("failed to patch containerDefinitions: %w", err) 50 | } 51 | 52 | taskDef.Content = patchedContent 53 | 54 | return nil 55 | } 56 | 57 | func (taskDef *TaskDefinition) Print() { 58 | fmt.Printf("# %s\n%s\n", TaskDefinitionName, utils.PrettyJSON(taskDef.Content)) 59 | } 60 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/goccy/go-yaml" 11 | "github.com/google/go-jsonnet" 12 | ) 13 | 14 | func IsJSON(data []byte) bool { 15 | var js json.RawMessage 16 | return json.Unmarshal(data, &js) == nil 17 | } 18 | 19 | func YAMLToJSON(data []byte) ([]byte, error) { 20 | var v interface{} 21 | 22 | if err := yaml.UnmarshalWithOptions(data, &v, yaml.UseOrderedMap()); err != nil { 23 | return nil, err 24 | } 25 | 26 | js, err := yaml.MarshalWithOptions(v, yaml.JSON()) 27 | 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return js, nil 33 | } 34 | 35 | func JSONToYAML(data []byte) ([]byte, error) { 36 | var v interface{} 37 | 38 | if err := yaml.UnmarshalWithOptions(data, &v, yaml.UseOrderedMap()); err != nil { 39 | return nil, err 40 | } 41 | 42 | ym, err := yaml.Marshal(v) 43 | 44 | if err != nil { 45 | return nil, err 46 | } 47 | return ym, nil 48 | } 49 | 50 | func EvaluateJsonnet(filename string) ([]byte, error) { 51 | vm := jsonnet.MakeVM() 52 | js, err := vm.EvaluateFile(filename) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return []byte(js), nil 59 | } 60 | 61 | func PrettyJSON(data []byte) string { 62 | var js json.RawMessage 63 | _ = json.Unmarshal(data, &js) 64 | js, _ = json.MarshalIndent(js, "", " ") 65 | return string(js) 66 | } 67 | 68 | func ReadJSONorJsonnet(path string) ([]byte, error) { 69 | var content []byte 70 | 71 | _, err := os.Stat(path) 72 | 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if filepath.Ext(path) == ".jsonnet" { 78 | content, err = EvaluateJsonnet(path) 79 | 80 | if err != nil { 81 | return nil, err 82 | } 83 | } else { 84 | content, err = ioutil.ReadFile(path) 85 | 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | if !IsJSON(content) { 91 | return nil, fmt.Errorf("definition is not JSON: %s", path) 92 | } 93 | } 94 | 95 | return content, nil 96 | } 97 | -------------------------------------------------------------------------------- /container_definition.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | jsonpatch "github.com/evanphx/json-patch" 8 | "github.com/valyala/fastjson" 9 | "github.com/winebarrel/demitas/utils" 10 | ) 11 | 12 | const ( 13 | ContainerDefinitionName = "ecs-container-def.json" 14 | ) 15 | 16 | type ContainerDefinition struct { 17 | Content []byte 18 | } 19 | 20 | func NewContainerDefinition(path string, taskDefPath string) (*ContainerDefinition, error) { 21 | var content []byte 22 | var err error 23 | 24 | if _, err = os.Stat(path); err != nil { 25 | content, err = readContainerDefFromTaskDef(taskDefPath) 26 | 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to load ECS task definition (instead of ECS container definition): %w: %s", err, taskDefPath) 29 | } 30 | } else { 31 | content, err = utils.ReadJSONorJsonnet(path) 32 | 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to load ECS container definition: %w: %s", err, path) 35 | } 36 | } 37 | 38 | containerDef := &ContainerDefinition{ 39 | Content: content, 40 | } 41 | 42 | return containerDef, nil 43 | } 44 | 45 | func (containerDef *ContainerDefinition) Patch(overrides []byte) error { 46 | patchedContent0, err := jsonpatch.MergePatch(containerDef.Content, []byte(`{"logConfiguration":null}`)) 47 | 48 | if err != nil { 49 | return fmt.Errorf("failed to patch ECS container definition: %w", err) 50 | } 51 | 52 | patchedContent, err := jsonpatch.MergePatch(patchedContent0, overrides) 53 | 54 | if err != nil { 55 | return fmt.Errorf("failed to patch ECS container definition: %w", err) 56 | } 57 | 58 | containerDef.Content = patchedContent 59 | 60 | return nil 61 | } 62 | 63 | func readContainerDefFromTaskDef(path string) ([]byte, error) { 64 | content, err := utils.ReadJSONorJsonnet(path) 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | var p fastjson.Parser 71 | v, err := p.ParseBytes(content) 72 | 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | containerDef := v.GetObject("containerDefinitions", "0") 78 | 79 | // NOTE: Ignore dependsOn 80 | containerDef.Del("dependsOn") 81 | 82 | if containerDef == nil { 83 | return nil, fmt.Errorf("'containerDefinitions.0' is not found in ECS task definition: %s", path) 84 | } 85 | 86 | return containerDef.MarshalTo(nil), nil 87 | } 88 | -------------------------------------------------------------------------------- /run_task.go: -------------------------------------------------------------------------------- 1 | package demitas 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | jsonpatch "github.com/evanphx/json-patch" 9 | "github.com/winebarrel/demitas/utils" 10 | ) 11 | 12 | type RunOptions struct { 13 | EcspressoPath string 14 | EcspressoOptions []string 15 | PrintConfig bool 16 | } 17 | 18 | type Runner struct { 19 | *RunOptions 20 | } 21 | 22 | func RunTask(opts *RunOptions, ecsConf *EcspressoConfig, svrDef *ServiceDefinition, taskDef *TaskDefinition) (err error) { 23 | runInTempDir(func() { 24 | err = writeTemporaryConfigs(ecsConf, svrDef, taskDef) 25 | 26 | if err != nil { 27 | return 28 | } 29 | 30 | cmdWithArgs := []string{opts.EcspressoPath, "run"} 31 | 32 | if len(opts.EcspressoOptions) > 0 { 33 | cmdWithArgs = append(cmdWithArgs, opts.EcspressoOptions...) 34 | } 35 | 36 | err = runCommand(cmdWithArgs) 37 | 38 | if err != nil { 39 | return 40 | } 41 | }) 42 | 43 | return 44 | } 45 | 46 | func runInTempDir(callback func()) { 47 | pwd, err := os.Getwd() 48 | 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | tmp, err := ioutil.TempDir("", "demitas") 54 | 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | defer func() { 60 | _ = os.Chdir(pwd) 61 | os.RemoveAll(tmp) 62 | }() 63 | 64 | err = os.Chdir(tmp) 65 | 66 | if err != nil { 67 | panic(err) 68 | } 69 | 70 | callback() 71 | } 72 | 73 | func writeTemporaryConfigs(ecsConf *EcspressoConfig, svrDef *ServiceDefinition, taskDef *TaskDefinition) error { 74 | err := ioutil.WriteFile(TaskDefinitionName, taskDef.Content, os.FileMode(0o644)) 75 | 76 | if err != nil { 77 | return fmt.Errorf("failed to write ECS task definition: %w", err) 78 | } 79 | 80 | err = ioutil.WriteFile(ServiceDefinitionName, svrDef.Content, os.FileMode(0o644)) 81 | 82 | if err != nil { 83 | return fmt.Errorf("failed to write ECS service definition: %w", err) 84 | } 85 | 86 | ecsConfOverrides := fmt.Sprintf(`{"service_definition":"%s","task_definition":"%s"}`, ServiceDefinitionName, TaskDefinitionName) 87 | ecsConfJson, err := jsonpatch.MergePatch(ecsConf.Content, []byte(ecsConfOverrides)) 88 | 89 | if err != nil { 90 | return fmt.Errorf("failed to update ecspresso config: %w", err) 91 | } 92 | 93 | ecsConfYaml, err := utils.JSONToYAML(ecsConfJson) 94 | 95 | if err != nil { 96 | return fmt.Errorf("failed to convert ecspresso config: %w", err) 97 | } 98 | 99 | err = ioutil.WriteFile(EcspressoConfigName, ecsConfYaml, os.FileMode(0o644)) 100 | 101 | if err != nil { 102 | return fmt.Errorf("failed to write ecspresso config: %w", err) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /demitas-pf: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ -n "$DMTS_DEBUG" ]; then 5 | set -x 6 | fi 7 | 8 | : ${DMTS_CONF_DIR:=~/.demitas} 9 | STONE_IMAGE=public.ecr.aws/o0p0b7e7/stone 10 | 11 | function usage() { 12 | echo 'Usage: demitas-pf [ --conf-dir=$DMTS_CONF_DIR ] [-p PROFILE] [ -c CLUSTER ] [ -f family ] -h REMOTE_HOST -r REMOTE_PORT -l LOCAL_PORT' 1>&2 13 | } 14 | 15 | function stop_task() { 16 | echo "Stopping ECS task... (Please wait for a while): $TASK_ID" 17 | aws ecs stop-task --cluster "$CLUSTER" --task "$TASK_ID" > /dev/null 18 | echo 'done' 19 | } 20 | 21 | if [ $# -eq 0 ]; then 22 | usage 23 | exit 0 24 | fi 25 | 26 | while getopts p:c:h:r:l:f:-: OPT; do 27 | optarg="$OPTARG" 28 | 29 | if [ "$OPT" = - ]; then 30 | OPT="-${OPTARG%%=*}" 31 | optarg="${OPTARG/${OPTARG%%=*}/}" 32 | optarg="${optarg#=}" 33 | fi 34 | 35 | case "-$OPT" in 36 | -p) 37 | PROFILE="$optarg" 38 | ;; 39 | -c) 40 | CLUSTER="$optarg" 41 | ;; 42 | -h) 43 | REMOTE_HOST="$optarg" 44 | ;; 45 | -r) 46 | REMOTE_PORT="$optarg" 47 | ;; 48 | -l) 49 | LOCAL_PORT="$optarg" 50 | ;; 51 | -f) 52 | FAMILY="$optarg" 53 | ;; 54 | --conf-dir) 55 | DMTS_CONF_DIR="$optarg" 56 | ;; 57 | *) 58 | usage 59 | exit 1 60 | ;; 61 | esac 62 | done 63 | 64 | if [ -z "$REMOTE_HOST" -o -z "$REMOTE_PORT" -o -z "$LOCAL_PORT" ]; then 65 | usage 66 | exit 1 67 | fi 68 | 69 | if [ -z "$CLUSTER" ]; then 70 | set +e 71 | CLUSTER_LINE=( $(grep ^cluster: $DMTS_CONF_DIR/$PROFILE/ecspresso.yml 2> /dev/null) ) 72 | set -e 73 | CLUSTER="${CLUSTER_LINE[1]}" 74 | 75 | if [ -z "$CLUSTER" ]; then 76 | echo 'Please specify ECS cluster' 1>&2 77 | exit 1 78 | fi 79 | fi 80 | 81 | if [ -n "$PROFILE" ]; then 82 | DEMITAS_OPTS="$DEMITAS_OPTS -p $PROFILE" 83 | fi 84 | 85 | if [ -n "$FAMILY" ]; then 86 | TASK_OVERRIDES='{family:"'"$FAMILY"'"}' 87 | fi 88 | 89 | export DMTS_CONF_DIR 90 | CONTAINER_OVERRIDES='{image: "'"$STONE_IMAGE"'", command:["'"$REMOTE_HOST:$REMOTE_PORT"'", "'"$REMOTE_PORT"'"]}' 91 | ECSPRESSO_POTS='--wait-until=running' 92 | 93 | echo 'Start ECS task for port forwarding...' 94 | 95 | set +e 96 | DEMITAS_OUT=$(demitas $DEMITAS_OPTS -t "$TASK_OVERRIDES" -c "$CONTAINER_OVERRIDES" -- $ECSPRESSO_POTS --dry-run 2>&1 > /dev/null) 97 | 98 | if [ $? -ne 0 ]; then 99 | echo "demitas dry-run failed: demitas $DEMITAS_OPTS -t '$TASK_OVERRIDES' -c '$CONTAINER_OVERRIDES' -- $ECSPRESSO_POTS --dry-run" 100 | echo $DEMITAS_OUT 101 | exit 1 102 | fi 103 | set -e 104 | 105 | LINE=( $(demitas $DEMITAS_OPTS -t "$TASK_OVERRIDES" -c "$CONTAINER_OVERRIDES" -- $ECSPRESSO_POTS | grep 'Task ID') ) 106 | TASK_ID="${LINE[5]}" 107 | 108 | if [ -z "$TASK_ID" ]; then 109 | echo 'error: Started task cannot be found' 110 | fi 111 | 112 | echo "ECS task is running: $TASK_ID" 113 | 114 | trap stop_task SIGINT 115 | 116 | echo 'Start port forwarding...' 117 | 118 | ecs-exec-pf -c "$CLUSTER" -t "$TASK_ID" -p "$REMOTE_PORT" -l "$LOCAL_PORT" 119 | -------------------------------------------------------------------------------- /demitas-exec: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ -n "$DMTS_DEBUG" ]; then 5 | set -x 6 | fi 7 | 8 | : ${DMTS_CONF_DIR:=~/.demitas} 9 | : ${DMTS_EXEC_IMAGE:=public.ecr.aws/lts/ubuntu:latest} 10 | : ${DMTS_EXEC_COMMAND:=bash} 11 | 12 | function usage() { 13 | echo 'Usage: demitas-exec [ --conf-dir=$DMTS_CONF_DIR ] [-p PROFILE] [ -c CLUSTER ] [ -i IMAGE ] [ -e COMMAND ] [ -f family ] [ --use-def-image ] [ --skip-stop ]' 1>&2 14 | } 15 | 16 | if [ $# -eq 0 ]; then 17 | usage 18 | exit 0 19 | fi 20 | 21 | function stop_task() { 22 | trap ':' SIGINT 23 | echo "Stopping ECS task... (Please wait for a while): $TASK_ID" 24 | aws ecs stop-task --cluster "$CLUSTER" --task "$TASK_ID" > /dev/null 25 | echo 'done' 26 | } 27 | 28 | while getopts p:c:i:e:f:-: OPT; do 29 | optarg="$OPTARG" 30 | 31 | if [ "$OPT" = - ]; then 32 | OPT="-${OPTARG%%=*}" 33 | optarg="${OPTARG/${OPTARG%%=*}/}" 34 | optarg="${optarg#=}" 35 | fi 36 | 37 | case "-$OPT" in 38 | -p) 39 | PROFILE="$optarg" 40 | ;; 41 | -c) 42 | CLUSTER="$optarg" 43 | ;; 44 | -i) 45 | DMTS_EXEC_IMAGE="$optarg" 46 | ;; 47 | -e) 48 | DMTS_EXEC_COMMAND="$optarg" 49 | ;; 50 | -f) 51 | FAMILY="$optarg" 52 | ;; 53 | --conf-dir) 54 | DMTS_CONF_DIR="$optarg" 55 | ;; 56 | --use-def-image) 57 | USE_DEF_IMAGE=1 58 | ;; 59 | --skip-stop) 60 | SKIP_STOP=1 61 | ;; 62 | *) 63 | usage 64 | exit 1 65 | ;; 66 | esac 67 | done 68 | 69 | if [ $USE_DEF_IMAGE ]; then 70 | CONTAINER_OVERRIDES='{entryPoint:["sleep", "infinity"]}' 71 | else 72 | CONTAINER_OVERRIDES='{image: "'"$DMTS_EXEC_IMAGE"'", entryPoint:["sleep", "infinity"]}' 73 | fi 74 | 75 | if [ -z "$CLUSTER" ]; then 76 | set +e 77 | CLUSTER_LINE=( $(grep ^cluster: $DMTS_CONF_DIR/$PROFILE/*.{yml,yaml} 2> /dev/null) ) 78 | set -e 79 | CLUSTER="${CLUSTER_LINE[1]}" 80 | 81 | if [ -z "$CLUSTER" ]; then 82 | echo 'Please specify ECS cluster' 1>&2 83 | exit 1 84 | fi 85 | fi 86 | 87 | if [ -n "$PROFILE" ]; then 88 | DEMITAS_OPTS="$DEMITAS_OPTS -p $PROFILE" 89 | fi 90 | 91 | if [ -n "$(aws ecs describe-clusters --cluster $CLUSTER --output text --query 'failures')" ]; then 92 | echo "error: Cluster not found: $CLUSTER" 93 | exit 1 94 | fi 95 | 96 | if [ -n "$FAMILY" ]; then 97 | TASK_OVERRIDES='{family:"'"$FAMILY"'"}' 98 | fi 99 | 100 | export DMTS_CONF_DIR 101 | ECSPRESSO_OPTS="--wait-until=running ${ECSPRESSO_OPTS}" 102 | 103 | echo 'Start ECS task...' 104 | 105 | set +e 106 | DEMITAS_OUT=$(demitas $DEMITAS_OPTS -t "$TASK_OVERRIDES" -c "$CONTAINER_OVERRIDES" -- $ECSPRESSO_OPTS --dry-run 2>&1 > /dev/null) 107 | 108 | if [ $? -ne 0 ]; then 109 | echo "demitas dry-run failed: demitas $DEMITAS_OPTS -t '$TASK_OVERRIDES' -c '$CONTAINER_OVERRIDES' -- $ECSPRESSO_OPTS --dry-run" 110 | echo $DEMITAS_OUT 111 | exit 1 112 | fi 113 | set -e 114 | 115 | LINE=( $(demitas $DEMITAS_OPTS -t "$TASK_OVERRIDES" -c "$CONTAINER_OVERRIDES" -- $ECSPRESSO_OPTS | grep 'Task ID') ) 116 | TASK_ID="${LINE[5]}" 117 | 118 | if [ -z "$TASK_ID" ]; then 119 | echo 'error: Started task cannot be found' 120 | fi 121 | 122 | echo "ECS task is running: $TASK_ID" 123 | 124 | if [ ! $SKIP_STOP ]; then 125 | trap stop_task SIGINT 126 | fi 127 | 128 | set +e 129 | 130 | while true; do 131 | aws ecs execute-command --cluster "$CLUSTER" --task "$TASK_ID" --interactive --command echo >/dev/null 2>/dev/null 132 | [ $? -eq 0 ] && break 133 | sleep 1 134 | done 135 | 136 | aws ecs execute-command --cluster "$CLUSTER" --task "$TASK_ID" --interactive --command "$DMTS_EXEC_COMMAND" 137 | 138 | if [ ! $SKIP_STOP ]; then 139 | stop_task 140 | else 141 | cat < 0 && !utils.IsJSON(opts.EcspressoConfigOverrides) { 114 | js, err := utils.YAMLToJSON(opts.EcspressoConfigOverrides) 115 | 116 | if err != nil { 117 | log.Fatalf("'--ecspresso-config-overrides' value is not valid: %s", string(opts.EcspressoConfigOverrides)) 118 | } 119 | 120 | opts.EcspressoConfigOverrides = js 121 | } 122 | 123 | if len(opts.ServiceDefOverrides) > 0 && !utils.IsJSON(opts.ServiceDefOverrides) { 124 | js, err := utils.YAMLToJSON(opts.ServiceDefOverrides) 125 | 126 | if err != nil { 127 | log.Fatalf("'--service-def-overrides' value is not valid: %s", string(opts.ServiceDefOverrides)) 128 | } 129 | 130 | opts.ServiceDefOverrides = js 131 | } 132 | 133 | if len(opts.TaskDefOverrides) > 0 && !utils.IsJSON(opts.TaskDefOverrides) { 134 | js, err := utils.YAMLToJSON(opts.TaskDefOverrides) 135 | 136 | if err != nil { 137 | log.Fatalf("'--task-def-overrides' value is not valid: %s", string(opts.TaskDefOverrides)) 138 | } 139 | 140 | opts.TaskDefOverrides = js 141 | } 142 | 143 | if len(opts.ContainerDefOverrides) > 0 && !utils.IsJSON(opts.ContainerDefOverrides) { 144 | js, err := utils.YAMLToJSON(opts.ContainerDefOverrides) 145 | 146 | if err != nil { 147 | log.Fatalf("'--container-def-overrides' value is not valid: %s", string(opts.ContainerDefOverrides)) 148 | } 149 | 150 | opts.ContainerDefOverrides = js 151 | } 152 | 153 | opts.EcspressoOptions = make([]string, len(flaggy.TrailingArguments)) 154 | copy(opts.EcspressoOptions, flaggy.TrailingArguments) 155 | 156 | return opts 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **⚠️NOTE** 2 | 3 | **A new version is under development.** 4 | 5 | **see https://github.com/winebarrel/demitas2** 6 | 7 | # demitas 8 | 9 | Wrapper for [ecspresso](https://github.com/kayac/ecspresso) that creates task definitions at run time. 10 | 11 | ## Usage 12 | 13 | ``` 14 | demitas - Wrapper for ecspresso that creates task definitions at run time. 15 | 16 | Flags: 17 | --version Displays the program version string. 18 | -h --help Displays help with available flag, subcommand, and positional value parameters. 19 | --ecsp-cmd ecspresso command path. (default: ecspresso) 20 | -d --conf-dir Configuration file base directory. (default: ~/.demitas) 21 | -p --profile Configuration profile directory. 22 | -E --ecsp-conf-src ecspresso config source path. (default: ~/.demitas/ecspresso.yml) 23 | -S --svr-def-src ECS service definition source path. (default: ~/.demitas/ecs-service-def.jsonnet) 24 | -T --task-def-src ECS task definition source path. (default: ~/.demitas/ecs-task-def.jsonnet) 25 | -C --cont-def-src ECS container definition source path. (default: ~/.demitas/ecs-container-def.jsonnet) 26 | -e --ecsp-conf-ovr JSON/YAML string that overrides ecspresso config source. 27 | -s --svr-def-ovr JSON/YAML string that overrides ECS service definition source. 28 | -t --task-def-ovr JSON/YAML string that overrides ECS task definition source. 29 | -c --cont-def-ovr JSON/YAML string that overrides ECS container definition source. 30 | -n --print-conf Display configs only. 31 | 32 | Trailing Arguments: 33 | Arguments after "--" is passed to "ecspresso run". 34 | e.g. demitas -c 'image: ...' -- --color --wait-until=running --debug 35 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 36 | 37 | Environment Variables: 38 | DMTS_CONF_DIR (--conf-dir) Configuration file base directory. (default: ~/.demitas) 39 | DMTS_PROFILE (--profile) Configuration profile directory. 40 | If "database" is set, configs file will be read from "$DMTS_CONF_DIR/database/..." 41 | ``` 42 | 43 | ## Installation 44 | 45 | **NOTE: Requires [ecspresso](https://github.com/kayac/ecspresso).** 46 | 47 | ```sh 48 | brew tap winebarrel/ecs-exec-pf 49 | brew install ecs-exec-pf 50 | brew tap winebarrel/demitas 51 | brew install demitas 52 | ``` 53 | 54 | ## Example Configurations 55 | 56 | 57 | ### `~/.demitas/ecspresso.yml` 58 | 59 | ```yml 60 | region: ap-northeast-1 61 | cluster: my-cluster 62 | service: my-service 63 | ``` 64 | 65 | ### `~/.demitas/ecs-service-def.jsonnet` 66 | 67 | ```jsonnet 68 | { 69 | launchType: 'FARGATE', 70 | networkConfiguration: { 71 | awsvpcConfiguration: { 72 | assignPublicIp: 'DISABLED', 73 | securityGroups: ['sg-xxx'], 74 | subnets: ['subnet-xxx'], 75 | }, 76 | }, 77 | enableExecuteCommand: true, 78 | //capacityProviderStrategy: [ 79 | // { 80 | // capacityProvider: 'FARGATE_SPOT', 81 | // weight: 1, 82 | // }, 83 | //], 84 | } 85 | ``` 86 | 87 | ### `~/.demitas/ecs-task-def.jsonnet` 88 | 89 | ```jsonnet 90 | { 91 | family: 'my-oneshot-task', 92 | cpu: '256', 93 | memory: '512', 94 | networkMode: 'awsvpc', 95 | taskRoleArn: 'arn:aws:iam::xxx:role/my-role', 96 | executionRoleArn: 'arn:aws:iam::xxx:role/my-exec-role', 97 | requiresCompatibilities: ['FARGATE'], 98 | } 99 | ``` 100 | 101 | ### `~/.demitas/ecs-container-def.jsonnet` 102 | 103 | ```jsonnet 104 | { 105 | name: 'oneshot', 106 | cpu: 0, 107 | essential: true, 108 | logConfiguration: { 109 | logDriver: 'awslogs', 110 | options: { 111 | 'awslogs-group': '/ecs/oneshot', 112 | 'awslogs-region': 'ap-northeast-1', 113 | 'awslogs-stream-prefix': 'ecs', 114 | }, 115 | }, 116 | } 117 | ``` 118 | 119 | ## Execution Example 120 | 121 | ```sh 122 | $ demitas \ 123 | -e 'service: my-service2' \ 124 | -s 'networkConfiguration: {awsvpcConfiguration: {securityGroups: [sg-zzz]}}' \ 125 | -c '{image: "public.ecr.aws/runecast/busybox:1.33.1", command: [echo, hello]}' \ 126 | -- --dry-run 127 | 128 | 2021/10/10 22:33:44 my-service2/my-cluster Running task 129 | 2021/10/10 22:33:44 my-service2/my-cluster task definition: 130 | { 131 | "containerDefinitions": [ 132 | { 133 | "command": [ 134 | "echo", 135 | "hello" 136 | ], 137 | "cpu": 0, 138 | "essential": true, 139 | "image": "public.ecr.aws/runecast/busybox:1.33.1", 140 | "logConfiguration": { 141 | "logDriver": "awslogs", 142 | "options": { 143 | "awslogs-group": "/ecs/busybox", 144 | "awslogs-region": "ap-northeast-1", 145 | "awslogs-stream-prefix": "ecs" 146 | } 147 | }, 148 | "name": "oneshot" 149 | } 150 | ], 151 | "cpu": "256", 152 | "executionRoleArn": "arn:aws:iam::xxx:role/my-role", 153 | "family": "my-oneshot-task", 154 | "memory": "512", 155 | "networkMode": "awsvpc", 156 | "requiresCompatibilities": [ 157 | "FARGATE" 158 | ], 159 | "taskRoleArn": "arn:aws:iam::xxx:role/my-exec-role", 160 | } 161 | 2021/10/10 22:33:44 my-service2/my-cluster DRY RUN OK 162 | ``` 163 | 164 | ### Port forwarding 165 | 166 | NOTE: Please install [ecs-exec-pf](https://github.com/winebarrel/ecs-exec-pf) in advance. 167 | 168 | ```sh 169 | $ demitas-pf 170 | Usage: demitas-pf [-p PROFILE] [-c CLUSTER] -h REMOTE_HOST -r REMOTE_PORT -l LOCAL_PORT 171 | ``` 172 | 173 | ```sh 174 | $ demitas-pf -p my-profile -h my-db -r 5432 -l 15432 175 | Start ECS task for port forwarding... 176 | ECS task is running: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 177 | Start port forwarding... 178 | 179 | Starting session with SessionId: user-xxxxxxxxxxxxxxxxx 180 | Port 15432 opened for sessionId user-xxxxxxxxxxxxxxxxx. 181 | Waiting for connections... 182 | ``` 183 | 184 | ```sh 185 | $ nc -vz localhost 15432 186 | Connection to localhost port 15432 [tcp/*] succeeded! 187 | ``` 188 | 189 | ### ECS Exec with new container 190 | 191 | ```sh 192 | $ demitas-exec -p pool-app-stg-db 193 | Start ECS task... 194 | ECS task is running: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 195 | 196 | The Session Manager plugin was installed successfully. Use the AWS CLI to start a session. 197 | 198 | 199 | Starting session with SessionId: ecs-execute-command-xxxxxxxxxxxxxxxxx 200 | root@ip-10-10-10-10:/# 201 | ``` 202 | --------------------------------------------------------------------------------