diff --git a/Gopkg.lock b/Gopkg.lock index 76c0848e..2168e101 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,18 +1,71 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + branch = "master" + name = "github.com/alecthomas/template" + packages = [".","parse"] + revision = "a0175ee3bccc567396460bf5acd36800cb10c49c" + +[[projects]] + branch = "master" + name = "github.com/alecthomas/units" + packages = ["."] + revision = "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" + +[[projects]] + name = "github.com/aws/aws-sdk-go" + packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/json/jsonutil","private/protocol/jsonrpc","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/xml/xmlutil","service/ecs","service/sts"] + revision = "a201bf33b18ad4ab54344e4bc26b87eb6ad37b8e" + version = "v1.12.25" + +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "f280b3ba517bf5fc98922624f21fb0e7a92adaec" + version = "v1.30.3" + +[[projects]] + name = "github.com/jmespath/go-jmespath" + packages = ["."] + revision = "0b12d6b5" + [[projects]] branch = "json" name = "github.com/kayac/go-config" packages = ["."] revision = "350764ec60ee2f9f7262587e7525e858ce53fd71" +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + branch = "master" + name = "github.com/morikuni/aec" + packages = ["."] + revision = "39771216ff4c63d11f5e604076f9c45e8be1067b" + [[projects]] name = "github.com/pkg/errors" packages = ["."] revision = "645ef00459ed84a119197bfb8d8205042c6df63d" version = "v0.8.0" +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "4b45465282a4624cf39876842a017334f13b8aff" + +[[projects]] + name = "gopkg.in/alecthomas/kingpin.v2" + packages = ["."] + revision = "1087e65c9441605df944fb12c33f0fe7072d18ca" + version = "v2.2.5" + [[projects]] branch = "v2" name = "gopkg.in/yaml.v2" @@ -22,6 +75,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "4e1e6fce25ff5768b8e028dff6e51a350a69d4766d5525922b8a9f801a8bcecc" + inputs-digest = "8076855fce7f91325f92d016c1c26588c5911592d33eee78170008e32b357374" solver-name = "gps-cdcl" solver-version = 1 diff --git a/README.md b/README.md index e25d7a2a..8713dece 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,28 @@ ecspresso is a deployment tool for Amazon ECS. (pronounced same as "espresso") -# Usage +## Usage ``` -$ ecspresso -h -Usage of ecspresso: - -cluster string - ECS cluster name(required) - -config string - Config file - -region string - aws region - -service string - ECS service name(required) - -task-definition string - task definition path(required) - -timeout int - timeout (sec) (default 300) -``` +usage: ecspresso --config=CONFIG [] [ ...] -ecspresso works as below. +Flags: + --help Show context-sensitive help (also try --help-long and --help-man). + --config=CONFIG config file -- Register a new task definition from JSON file. - - JSON file is same format as `aws ecs describe-task-definition` output. - - Replace `{{ env "FOO" "bar" }}` syntax in the JSON file to environment variable "FOO". - - If "FOO" is not defined, replaced by "bar" - - Replace `{{ must_env "FOO" }}` syntax in the JSON file to environment variable "FOO". - - If "FOO" is not defined, abort immediately. -- Update a service definition. -- Wait a service stable. +Commands: + help [...] + Show help. + + deploy [] + deploy service + + status [] + show status of service + + rollback [] + rollback service +``` ### Configuration file @@ -46,19 +39,35 @@ task_definition: myTask.json timeout: 5m ``` -Keys are equal to comand line options. +ecspresso works as below. + +- Register a new task definition from JSON file. + - JSON file is same format as `aws ecs describe-task-definition` output. + - Replace `{{ env "FOO" "bar" }}` syntax in the JSON file to environment variable "FOO". + - If "FOO" is not defined, replaced by "bar" + - Replace `{{ must_env "FOO" }}` syntax in the JSON file to environment variable "FOO". + - If "FOO" is not defined, abort immediately. +- Update a service definition. +- Wait a service stable. ## Example ``` -$ ecspresso -region ap-northeast-1 -cluster default -service myService -task-definition myTask.json -2017/11/07 09:07:12 myService/default Starting ecspresso -2017/11/07 09:07:12 myService/default Creating a new task definition by app.json -2017/11/07 09:07:12 myService/default Registering a new task definition... -2017/11/07 09:07:15 myService/default Task definition is registered myService:2 -2017/11/07 09:07:15 myService/default Updating service... -2017/11/07 09:07:16 myService/default Waiting for service stable...(it will takea few minutes) -2017/11/07 09:10:02 myService/default Service is stable now. Completed! +$ ecspresso deploy --config preview.yaml +2017/11/09 23:20:13 myService/default Starting deploy +Service: myService +Cluster: default +TaskDefinition: myService:3 +Deployments: + PRIMARY myService:3 desired:1 pending:0 running:1 +Events: +2017/11/09 23:20:13 myService/default Creating a new task definition by task-definition/myService.json +2017/11/09 23:20:13 myService/default Registering a new task definition... +2017/11/09 23:20:13 myService/default Task definition is registered myService:4 +2017/11/09 23:20:13 myService/default Updating service... +2017/11/09 23:20:13 myService/default Waiting for service stable...(it will take a few minutes) +2017/11/09 23:23:23 myService/default PRIMARY myService:4 desired:1 pending:0 running:1 +2017/11/09 23:23:29 myService/default Service is stable now. Completed! ``` # LICENCE diff --git a/cmd/ecspresso/main.go b/cmd/ecspresso/main.go index 0e9463a1..973e86c5 100644 --- a/cmd/ecspresso/main.go +++ b/cmd/ecspresso/main.go @@ -1,13 +1,12 @@ package main import ( - "flag" "log" "os" - "time" "github.com/kayac/ecspresso" config "github.com/kayac/go-config" + kingpin "gopkg.in/alecthomas/kingpin.v2" ) func main() { @@ -15,40 +14,41 @@ func main() { } func _main() int { - var ( - region, conf, service, cluster, path string - timeout int - ) - - flag.StringVar(®ion, "region", os.Getenv("AWS_REGION"), "aws region") - flag.StringVar(&conf, "config", "", "Config file") - flag.StringVar(&service, "service", "", "ECS service name(required)") - flag.StringVar(&cluster, "cluster", "", "ECS cluster name(required)") - flag.StringVar(&path, "task-definition", "", "task definition path(required)") - flag.IntVar(&timeout, "timeout", 300, "timeout (sec)") - flag.Parse() - - c := ecspresso.Config{ - Region: region, - Service: service, - Cluster: cluster, - TaskDefinitionPath: path, - Timeout: time.Duration(timeout) * time.Second, - } - if conf != "" { - if err := config.Load(&c, conf); err != nil { - log.Println("Cloud not load config file", conf, err) - return 1 - } + conf := kingpin.Flag("config", "config file").Required().String() + + deploy := kingpin.Command("deploy", "deploy service") + deployDryRun := deploy.Flag("dry-run", "dry-run").Bool() + + status := kingpin.Command("status", "show status of service") + statusEvents := status.Flag("events", "show events num").Default("2").Int() + + rollback := kingpin.Command("rollback", "rollback service") + rollbackDryRun := rollback.Flag("dry-run", "dry-run").Bool() + + sub := kingpin.Parse() + + c := ecspresso.NewDefaultConfig() + if err := config.Load(c, *conf); err != nil { + log.Println("Cloud not load config file", conf, err) + kingpin.Usage() + return 1 } - if err := (&c).Validate(); err != nil { + app, err := ecspresso.NewApp(c) + if err != nil { log.Println(err) - flag.PrintDefaults() return 1 } - if err := ecspresso.Run(&c); err != nil { - log.Println("FAILED:", err) + switch sub { + case "deploy": + err = app.Deploy(*deployDryRun) + case "status": + err = app.Status(*statusEvents) + case "rollback": + err = app.Rollback(*rollbackDryRun) + } + if err != nil { + log.Printf("%s FAILED. %s", sub, err) return 1 } diff --git a/config.go b/config.go index 000981ce..8f1320af 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package ecspresso import ( "errors" + "os" "time" ) @@ -25,3 +26,10 @@ func (c *Config) Validate() error { } return nil } + +func NewDefaultConfig() *Config { + return &Config{ + Region: os.Getenv("AWS_REGION"), + Timeout: 300 * time.Second, + } +} diff --git a/ecspresso.go b/ecspresso.go index 6746e17b..916806b4 100644 --- a/ecspresso.go +++ b/ecspresso.go @@ -4,14 +4,21 @@ import ( "context" "fmt" "log" + "os" + "strings" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ecs" "github.com/kayac/go-config" + "github.com/mattn/go-isatty" + "github.com/morikuni/aec" + "github.com/pkg/errors" ) +var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) + func taskDefinitionName(t *ecs.TaskDefinition) string { return fmt.Sprintf("%s:%d", *t.Family, *t.Revision) } @@ -22,6 +29,7 @@ type App struct { Cluster string TaskDefinition *ecs.TaskDefinition Registered *ecs.TaskDefinition + config *Config } func (d *App) DescribeServicesInput() *ecs.DescribeServicesInput { @@ -31,27 +39,51 @@ func (d *App) DescribeServicesInput() *ecs.DescribeServicesInput { } } -func (d *App) DescribeServiceDeployments(ctx context.Context) error { +func (d *App) DescribeServiceStatus(ctx context.Context, events int) (*ecs.Service, error) { out, err := d.ecs.DescribeServicesWithContext(ctx, d.DescribeServicesInput()) if err != nil { - return err + return nil, errors.Wrap(err, "describe services failed") + } + if len(out.Services) == 0 { + return nil, errors.New("no services found") + } + s := out.Services[0] + fmt.Println("Service:", *s.ServiceName) + fmt.Println("Cluster:", arnToName(*s.ClusterArn)) + fmt.Println("TaskDefinition:", arnToName(*s.TaskDefinition)) + fmt.Println("Deployments:") + for _, dep := range s.Deployments { + fmt.Println(" ", formatDeployment(dep)) } - if len(out.Services) > 0 { - for _, dep := range out.Services[0].Deployments { - d.Log(formatDeployment(dep)) + fmt.Println("Events:") + for i, event := range s.Events { + if i >= events { + break } + fmt.Println(" ", formatEvent(event)) } - return nil + return s, nil } -func Run(conf *Config) error { - var cancel context.CancelFunc - ctx := context.Background() - if conf.Timeout > 0 { - ctx, cancel = context.WithTimeout(ctx, conf.Timeout) - defer cancel() +func (d *App) DescribeServiceDeployments(ctx context.Context) (int, error) { + out, err := d.ecs.DescribeServicesWithContext(ctx, d.DescribeServicesInput()) + if err != nil { + return 0, err } + if len(out.Services) == 0 { + return 0, nil + } + s := out.Services[0] + for _, dep := range s.Deployments { + d.Log(formatDeployment(dep)) + } + return len(s.Deployments), nil +} +func NewApp(conf *Config) (*App, error) { + if err := conf.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid configuration") + } sess := session.Must(session.NewSession( &aws.Config{Region: aws.String(conf.Region)}, )) @@ -59,29 +91,119 @@ func Run(conf *Config) error { Service: conf.Service, Cluster: conf.Cluster, ecs: ecs.New(sess), + config: conf, } - d.Log("Starting ecspresso") + return d, nil +} - if err := d.DescribeServiceDeployments(ctx); err != nil { - return err +func (d *App) Start() (context.Context, context.CancelFunc) { + log.SetOutput(os.Stdout) + + if d.config.Timeout > 0 { + return context.WithTimeout(context.Background(), d.config.Timeout) + } else { + return context.Background(), func() {} } - if err := d.LoadTaskDefinition(conf.TaskDefinitionPath); err != nil { - return err +} + +func (d *App) Status(events int) error { + ctx, cancel := d.Start() + defer cancel() + _, err := d.DescribeServiceStatus(ctx, events) + return err +} + +func (d *App) Deploy(dryRun bool) error { + ctx, cancel := d.Start() + defer cancel() + + d.Log("Starting deploy") + if _, err := d.DescribeServiceStatus(ctx, 0); err != nil { + return errors.Wrap(err, "deploy failed") + } + if err := d.LoadTaskDefinition(d.config.TaskDefinitionPath); err != nil { + return errors.Wrap(err, "deploy failed") } + if dryRun { + d.Log("DRY RUN OK") + return nil + } + if err := d.RegisterTaskDefinition(ctx); err != nil { - return err + return errors.Wrap(err, "deploy failed") } - if err := d.UpdateService(ctx); err != nil { - return err + if err := d.UpdateService(ctx, *d.Registered.TaskDefinitionArn); err != nil { + return errors.Wrap(err, "deploy failed") } if err := d.WaitServiceStable(ctx); err != nil { - return err + return errors.Wrap(err, "deploy failed") + } + + d.Log("Service is stable now. Completed!") + return nil +} + +func (d *App) Rollback(dryRun bool) error { + ctx, cancel := d.Start() + defer cancel() + + d.Log("Starting rollback") + service, err := d.DescribeServiceStatus(ctx, 0) + if err != nil { + return errors.Wrap(err, "rollback failed") + } + targetArn, err := d.FindRollbackTarget(ctx, *service.TaskDefinition) + if err != nil { + return errors.Wrap(err, "rollback failed") + } + d.Log("Rollbacking to", arnToName(targetArn)) + if dryRun { + d.Log("DRY RUN OK") + return nil + } + + if err := d.UpdateService(ctx, targetArn); err != nil { + return errors.Wrap(err, "rollback failed") + } + if err := d.WaitServiceStable(ctx); err != nil { + return errors.Wrap(err, "rollback failed") } d.Log("Service is stable now. Completed!") return nil } +func (d *App) FindRollbackTarget(ctx context.Context, taskDefinitionArn string) (string, error) { + var found bool + var nextToken *string + family := strings.Split(arnToName(taskDefinitionArn), ":")[0] + for { + out, err := d.ecs.ListTaskDefinitionsWithContext(ctx, + &ecs.ListTaskDefinitionsInput{ + NextToken: nextToken, + FamilyPrefix: aws.String(family), + MaxResults: aws.Int64(100), + Sort: aws.String("DESC"), + }, + ) + if err != nil { + return "", errors.Wrap(err, "list taskdefinitions failed") + } + if len(out.TaskDefinitionArns) == 0 { + return "", errors.New("rollback target is not found") + } + nextToken = out.NextToken + for _, tdArn := range out.TaskDefinitionArns { + if found { + return *tdArn, nil + } + if *tdArn == taskDefinitionArn { + found = true + } + } + } +} + func (d *App) Name() string { return fmt.Sprintf("%s/%s", d.Service, d.Cluster) } @@ -100,12 +222,16 @@ func (d *App) WaitServiceStable(ctx context.Context) error { go func() { tick := time.Tick(10 * time.Second) + var lines int for { select { case <-waitCtx.Done(): return case <-tick: - d.DescribeServiceDeployments(waitCtx) + if isTerminal && lines > 0 { + fmt.Print(aec.Up(uint(lines))) + } + lines, _ = d.DescribeServiceDeployments(waitCtx) } } }() @@ -113,7 +239,7 @@ func (d *App) WaitServiceStable(ctx context.Context) error { return d.ecs.WaitUntilServicesStableWithContext(ctx, d.DescribeServicesInput()) } -func (d *App) UpdateService(ctx context.Context) error { +func (d *App) UpdateService(ctx context.Context, taskDefinitionArn string) error { d.Log("Updating service...") _, err := d.ecs.UpdateServiceWithContext( @@ -121,7 +247,7 @@ func (d *App) UpdateService(ctx context.Context) error { &ecs.UpdateServiceInput{ Service: aws.String(d.Service), Cluster: aws.String(d.Cluster), - TaskDefinition: d.Registered.TaskDefinitionArn, + TaskDefinition: aws.String(taskDefinitionArn), }, ) return err diff --git a/service.go b/service.go index 0746cb9c..d3f423a1 100644 --- a/service.go +++ b/service.go @@ -3,16 +3,30 @@ package ecspresso import ( "fmt" "strings" + "time" "github.com/aws/aws-sdk-go/service/ecs" ) +var timezone, _ = time.LoadLocation("Local") + +func arnToName(s string) string { + ns := strings.Split(s, "/") + return ns[len(ns)-1] +} + func formatDeployment(d *ecs.Deployment) string { - td := strings.Split(*d.TaskDefinition, "/") return fmt.Sprintf( "%8s %s desired:%d pending:%d running:%d", *d.Status, - td[len(td)-1], + arnToName(*d.TaskDefinition), *d.DesiredCount, *d.PendingCount, *d.RunningCount, ) } + +func formatEvent(e *ecs.ServiceEvent) string { + return fmt.Sprintf("%s: %s", + e.CreatedAt.In(timezone).Format(time.RFC3339), + *e.Message, + ) +}