diff --git a/README.md b/README.md index c279fdd..d8bd90e 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Flags: --help Show context-sensitive help (also try --help-long and --help-man). --config="./deployman.json" [OPTIONAL] Configuration file path. By default, this value is './deployman.json'. If this file does not exist, an error will occur. --verbose [OPTIONAL] A detailed log containing call stacks will be error messages. + --output="table" [OPTIONAL] Output format (table, json). Default is table. ``` - output sample: This example shows that the bundle deployed in blue-AutoScalingGroup is #1 and the bundle deployed in green-AutoScaling is #2. ```shell @@ -168,7 +169,7 @@ Flags: +----+---------------------------+-----------------------------+----------------+ ``` -### bundle acrivate +### bundle activate ```shell usage: deployman bundle activate --target=TARGET --name=NAME @@ -205,6 +206,7 @@ Flags: --help Show context-sensitive help (also try --help-long and --help-man). --config="./deployman.json" [OPTIONAL] Configuration file path. By default, this value is './deployman.json'. If this file does not exist, an error will occur. --verbose [OPTIONAL] A detailed log containing call stacks will be error messages. + --output="table" [OPTIONAL] Output format (table, json). Default is table. ``` - output sample: TARGET is a blue/green classification. It displays the percentage of each traffic weight and the status of the associated AutoScalingGroup and TargetGroup. ```shell diff --git a/cmd/deployman/main.go b/cmd/deployman/main.go index e73da9c..af1b7a9 100644 --- a/cmd/deployman/main.go +++ b/cmd/deployman/main.go @@ -29,7 +29,8 @@ var ( bundleRegisterName = bundleRegister.Flag("name", "[REQUIRED] Name of bundle to be registered").Required().String() bundleRegisterActivate = bundleRegister.Flag("with-activate", "[OPTIONAL] Associate (activate) this bundle with an idle AutoScalingGroup.").Bool() - bundleList = bundle.Command("list", "List registered application bundles.") + bundleList = bundle.Command("list", "List registered application bundles.") + bundleListOutput = bundleList.Flag("output", "Output format (table, json). Default is table.").Default("table").Enum("table", "json") bundleActivate = bundle.Command("activate", "Activate one of the registered bundles. The active bundle will be used for the next deployment or scale-out.") bundleActivateTarget = bundleActivate.Flag("target", "[REQUIRED] Target type for bundle. Valid values are either 'blue' or 'green'. The 'ec2 status' command allows you to check the target details.").Required().Enum("blue", "green") @@ -40,7 +41,8 @@ var ( ec2 = app.Command("ec2", "") - ec2status = ec2.Command("status", "Show current deployment status.") + ec2status = ec2.Command("status", "Show current deployment status.") + ec2statusOutput = ec2status.Flag("output", "Output format (table, json). Default is table.").Default("table").Enum("table", "json") ec2deploy = ec2.Command("deploy", "Deploy a new application to an idling AutoScalingGroup.") ec2deploySilent = ec2deploy.Flag("silent", "[OPTIONAL] Skip confirmation before process.").Bool() @@ -122,7 +124,7 @@ func main() { } case bundleList.FullCommand(): - err = bundler.ListBundles(ctx) + err = bundler.ListBundles(ctx, *bundleListOutput) case bundleActivate.FullCommand(): err = bundler.Activate(ctx, internal.TargetType(*bundleActivateTarget), *bundleActivateName) @@ -131,10 +133,10 @@ func main() { err = bundler.Download(ctx, internal.TargetType(*bundleDownloadTarget)) case ec2status.FullCommand(): - err = deployer.ShowStatus(ctx) + err = deployer.ShowStatus(ctx, *ec2statusOutput) case ec2deploy.FullCommand(): - if err = deployer.ShowStatus(ctx); err != nil { + if err = deployer.ShowStatus(ctx, "table"); err != nil { break } if *ec2deploySilent == false && internal.AskToContinue() == false { @@ -146,7 +148,7 @@ func main() { } case ec2rollback.FullCommand(): - if err = deployer.ShowStatus(ctx); err != nil { + if err = deployer.ShowStatus(ctx, "table"); err != nil { break } if *ec2rollbackSilent == false && internal.AskToContinue() == false { diff --git a/internal/bundler.go b/internal/bundler.go index f606552..d019a78 100644 --- a/internal/bundler.go +++ b/internal/bundler.go @@ -3,7 +3,9 @@ package internal import ( "bytes" "context" + "encoding/json" "fmt" + "io" "os" "sort" "strconv" @@ -28,11 +30,53 @@ type Bundler struct { logger Logger } -type BundleInfo struct { +type ActiveBundle struct { Value string LastModified *time.Time } +type BundleListItem struct { + Number int `json:"number"` + LastUpdated string `json:"lastUpdated"` + BundleName string `json:"bundleName"` + ActiveTargets []string `json:"activeTargets"` +} + +type BundleListOutput struct { + BucketName string `json:"bucket"` + Bundles []BundleListItem `json:"bundles"` +} + +func (b *BundleListOutput) AsJSON(w io.Writer) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(b) +} + +func (b *BundleListOutput) AsTable(w io.Writer) error { + var data [][]string + for _, item := range b.Bundles { + status := "" + if len(item.ActiveTargets) > 0 { + status = "active:[" + strings.Join(item.ActiveTargets, ", ") + "]" + } + data = append(data, []string{ + strconv.Itoa(item.Number), + item.LastUpdated, + item.BundleName, + status, + }) + } + + fmt.Fprintf(w, "Bucket: %s\n", b.BucketName) + table := tablewriter.NewWriter(w) + table.SetHeader([]string{"#", "last updated", "bundle name", "status"}) + table.AppendBulk(data) + table.Render() + + return nil +} + func NewBundler(deployConfig *Config, awsClient AwsClient, logger Logger) *Bundler { return &Bundler{ config: deployConfig, @@ -55,25 +99,26 @@ func (b *Bundler) listBundles(ctx context.Context, bucket string) ([]s3Types.Obj return objects, nil } -func (b *Bundler) ListBundles(ctx context.Context) error { - hasError := func(err error) bool { - if err == nil { - return false - } - var apiErr smithy.APIError - if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchKey" { - return false +func (b *Bundler) ListBundles(ctx context.Context, outputFormat string) error { + getActiveBundleOrNil := func(targetType TargetType) (*ActiveBundle, error) { + bundle, err := b.getActiveBundle(ctx, targetType) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchKey" { + return nil, nil + } + return nil, err } - return true + return bundle, nil } - blueBundle, err := b.getActiveBundle(ctx, BlueTargetType) - if hasError(err) { + blueBundle, err := getActiveBundleOrNil(BlueTargetType) + if err != nil { return err } - greenBundle, err := b.getActiveBundle(ctx, GreenTargetType) - if hasError(err) { + greenBundle, err := getActiveBundleOrNil(GreenTargetType) + if err != nil { return err } @@ -82,7 +127,7 @@ func (b *Bundler) ListBundles(ctx context.Context) error { return err } - var data [][]string + var bundles []BundleListItem for i, bundleObject := range bundleObjects { var targets []string if blueBundle != nil && strings.Contains(*bundleObject.Key, blueBundle.Value) { @@ -91,26 +136,27 @@ func (b *Bundler) ListBundles(ctx context.Context) error { if greenBundle != nil && strings.Contains(*bundleObject.Key, greenBundle.Value) { targets = append(targets, "green") } - status := "" - if len(targets) > 0 { - status = "active:[" + strings.Join(targets, ", ") + "]" - } location := b.config.TimeZone.CurrentLocation() - data = append(data, []string{ - strconv.Itoa(i + 1), - bundleObject.LastModified.In(location).Format(time.RFC3339), - strings.Replace(*bundleObject.Key, BundlePrefix, "", 1), - status, + lastUpdated := bundleObject.LastModified.In(location).Format(time.RFC3339) + bundleName := strings.Replace(*bundleObject.Key, BundlePrefix, "", 1) + + bundles = append(bundles, BundleListItem{ + Number: i + 1, + LastUpdated: lastUpdated, + BundleName: bundleName, + ActiveTargets: targets, }) } - fmt.Printf("Bucket: %s\n", b.config.BundleBucket) - table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"#", "last updated", "bundle name", "status"}) - table.AppendBulk(data) - table.Render() + output := &BundleListOutput{ + BucketName: b.config.BundleBucket, + Bundles: bundles, + } - return nil + if outputFormat == "json" { + return output.AsJSON(os.Stdout) + } + return output.AsTable(os.Stdout) } func (b *Bundler) Register(ctx context.Context, uploadFile string, bundleName string) error { @@ -173,7 +219,7 @@ func (b *Bundler) Register(ctx context.Context, uploadFile string, bundleName st return nil } -func (b *Bundler) getActiveBundle(ctx context.Context, targetType TargetType) (*BundleInfo, error) { +func (b *Bundler) getActiveBundle(ctx context.Context, targetType TargetType) (*ActiveBundle, error) { output, err := b.client.GetS3BucketObject(ctx, b.config.BundleBucket, ActiveBundleKeyPrefix+string(targetType)) if err != nil { return nil, err @@ -185,7 +231,7 @@ func (b *Bundler) getActiveBundle(ctx context.Context, targetType TargetType) (* return nil, errors.WithStack(err) } - return &BundleInfo{ + return &ActiveBundle{ Value: buf.String(), LastModified: output.LastModified, }, nil diff --git a/internal/deployer.go b/internal/deployer.go index 50fc9c2..736506d 100644 --- a/internal/deployer.go +++ b/internal/deployer.go @@ -2,7 +2,9 @@ package internal import ( "context" + "encoding/json" "fmt" + "io" "os" "strconv" "strings" @@ -51,6 +53,121 @@ type HealthInfo struct { DrainingCount int } +type ASGStatus struct { + Name string `json:"name"` + DesiredCapacity int32 `json:"desiredCapacity"` + MinSize int32 `json:"minSize"` + MaxSize int32 `json:"maxSize"` + Lifecycles []ASGLifeCycleStatus `json:"lifecycles"` +} + +func newASGStatus(autoScalingGroup *asgTypes.AutoScalingGroup) *ASGStatus { + lifecycleStates := map[asgTypes.LifecycleState]int{} + for _, ins := range autoScalingGroup.Instances { + lifecycleStates[ins.LifecycleState]++ + } + var states []ASGLifeCycleStatus + for state, count := range lifecycleStates { + states = append(states, ASGLifeCycleStatus{ + State: string(state), + Count: count, + }) + } + return &ASGStatus{ + Name: *autoScalingGroup.AutoScalingGroupName, + DesiredCapacity: *autoScalingGroup.DesiredCapacity, + MinSize: *autoScalingGroup.MinSize, + MaxSize: *autoScalingGroup.MaxSize, + Lifecycles: states, + } +} + +func (a *ASGStatus) StringLifecycles() string { + var parts []string + for _, lifecycle := range a.Lifecycles { + parts = append(parts, lifecycle.String()) + } + return strings.Join(parts, ",") +} + +type ASGLifeCycleStatus struct { + State string `json:"state"` + Count int `json:"count"` +} + +func (a *ASGLifeCycleStatus) String() string { + return fmt.Sprintf("%s:%d", a.State, a.Count) +} + +type ELBStatus struct { + TargetGroupName string `json:"targetGroupName"` + Total int `json:"total"` + Healthy int `json:"healthy"` + Unhealthy int `json:"unhealthy"` + Unused int `json:"unused"` + Initial int `json:"initial"` + Draining int `json:"draining"` +} + +type TargetStatus struct { + TargetType string `json:"target"` + TrafficWeight int32 `json:"trafficWeight"` + AutoScalingGroup ASGStatus `json:"autoScalingGroup"` + LoadBalancer ELBStatus `json:"loadBalancer"` +} + +type StatusOutput struct { + targets []TargetStatus +} + +func (s *StatusOutput) AsJSON(w io.Writer) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(s.targets) +} + +func (s *StatusOutput) AsTable(w io.Writer) error { + var data [][]string + for _, target := range s.targets { + data = append(data, []string{ + target.TargetType, + strconv.Itoa(int(target.TrafficWeight)), + target.AutoScalingGroup.Name, + strconv.Itoa(int(target.AutoScalingGroup.DesiredCapacity)), + strconv.Itoa(int(target.AutoScalingGroup.MinSize)), + strconv.Itoa(int(target.AutoScalingGroup.MaxSize)), + target.AutoScalingGroup.StringLifecycles(), + target.LoadBalancer.TargetGroupName, + strconv.Itoa(target.LoadBalancer.Total), + strconv.Itoa(target.LoadBalancer.Healthy), + strconv.Itoa(target.LoadBalancer.Unhealthy), + strconv.Itoa(target.LoadBalancer.Unused), + strconv.Itoa(target.LoadBalancer.Initial), + strconv.Itoa(target.LoadBalancer.Draining), + }) + } + table := tablewriter.NewWriter(w) + table.SetHeader([]string{ + "target", + "traffic(%)", + "asg:name", + "asg:desired", + "asg:min", + "asg:max", + "asg:lifecycle", + "elb:tgname", + "elb:total", + "elb:healthy", + "elb:unhealthy", + "elb:unused", + "elb:initial", + "elb:draining", + }) + table.AppendBulk(data) + table.Render() + return nil +} + func NewDeployer(deployConfig *Config, awsClient AwsClient, logger Logger) *Deployer { return &Deployer{ config: deployConfig, @@ -82,22 +199,6 @@ func (d *Deployer) getHealthInfo(ctx context.Context, targetGroupArn string) (*H }, nil } -func (d *Deployer) lifecycleStateToString(autoScalingGroup *asgTypes.AutoScalingGroup) string { - lifecycleStates := map[asgTypes.LifecycleState]int{} - for _, ins := range autoScalingGroup.Instances { - lifecycleStates[ins.LifecycleState]++ - } - var states []string - for state, count := range lifecycleStates { - states = append(states, fmt.Sprintf("%s:%d", string(state), count)) - } - result := "" - if len(states) > 0 { - result = strings.Join(states, ",") - } - return result -} - func (d *Deployer) GetDeployTarget( ctx context.Context, rule *albTypes.Rule, targetType TargetType) (*DeployTarget, error) { @@ -171,7 +272,7 @@ func (d *Deployer) GetDeployInfo(ctx context.Context) (*DeployInfo, error) { } } -func (d *Deployer) ShowStatus(ctx context.Context) error { +func (d *Deployer) ShowStatus(ctx context.Context, outputFormat string) error { //DeployTarget rule, err := d.client.GetALBListenerRule(ctx, d.config.ListenerRuleArn) if err != nil { @@ -213,47 +314,34 @@ func (d *Deployer) ShowStatus(ctx context.Context) error { return err } - toData := func(target *DeployTarget, targetGroupName string, health *HealthInfo) []string { - return []string{ - string(target.Type), - strconv.Itoa(int(*target.TargetGroup.Weight)), - *target.AutoScalingGroup.AutoScalingGroupName, - strconv.Itoa(int(*target.AutoScalingGroup.DesiredCapacity)), - strconv.Itoa(int(*target.AutoScalingGroup.MinSize)), - strconv.Itoa(int(*target.AutoScalingGroup.MaxSize)), - d.lifecycleStateToString(target.AutoScalingGroup), - targetGroupName, - strconv.Itoa(health.TotalCount), - strconv.Itoa(health.HealthyCount), - strconv.Itoa(health.UnhealthyCount), - strconv.Itoa(health.UnusedCount), - strconv.Itoa(health.InitialCount), - strconv.Itoa(health.DrainingCount), + toStatus := func(target *DeployTarget, targetGroupName string, health *HealthInfo) TargetStatus { + return TargetStatus{ + TargetType: string(target.Type), + TrafficWeight: *target.TargetGroup.Weight, + AutoScalingGroup: *newASGStatus(target.AutoScalingGroup), + LoadBalancer: ELBStatus{ + TargetGroupName: targetGroupName, + Total: health.TotalCount, + Healthy: health.HealthyCount, + Unhealthy: health.UnhealthyCount, + Unused: health.UnusedCount, + Initial: health.InitialCount, + Draining: health.DrainingCount, + }, } } - data := [][]string{toData(blueTarget, blueTGName, blueHealth), toData(greenTarget, greenTGName, greenHealth)} - table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{ - "target", - "traffic(%)", - "asg:name", - "asg:desired", - "asg:min", - "asg:max", - "asg:lifecycle", - "elb:tgname", - "elb:total", - "elb:healthy", - "elb:unhealthy", - "elb:unused", - "elb:initial", - "elb:draining", - }) - table.AppendBulk(data) - table.Render() + output := &StatusOutput{ + targets: []TargetStatus{ + toStatus(blueTarget, blueTGName, blueHealth), + toStatus(greenTarget, greenTGName, greenHealth), + }, + } - return nil + if outputFormat == "json" { + return output.AsJSON(os.Stdout) + } + return output.AsTable(os.Stdout) } func (d *Deployer) Deploy( @@ -307,7 +395,7 @@ func (d *Deployer) Deploy( } d.logger.Info("Health check completed.") - if err := d.ShowStatus(ctx); err != nil { + if err := d.ShowStatus(ctx, "table"); err != nil { return err } @@ -318,7 +406,7 @@ func (d *Deployer) Deploy( } d.logger.Info("Traffic swap completed.") - if err := d.ShowStatus(ctx); err != nil { + if err := d.ShowStatus(ctx, "table"); err != nil { return err } } @@ -340,7 +428,7 @@ func (d *Deployer) Deploy( if err != nil { return err } - if err = d.ShowStatus(ctx); err != nil { + if err = d.ShowStatus(ctx, "table"); err != nil { return err } } @@ -499,7 +587,7 @@ func (d *Deployer) CleanupAutoScalingGroup(ctx context.Context, autoScalingGroup *current.MinSize, *current.MaxSize, len(current.Instances), - d.lifecycleStateToString(current), + newASGStatus(current).StringLifecycles(), )) return ContinueRetry, nil diff --git a/test/e2e_test.go b/test/e2e_test.go index a235a07..e31b105 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -58,7 +58,7 @@ func TestE2E(t *testing.T) { assert.Success(t, bundler.Activate(ctx, internal.BlueTargetType, bundleName)) assert.Success(t, bundler.Activate(ctx, internal.GreenTargetType, bundleName)) - assert.Success(t, bundler.ListBundles(ctx)) + assert.Success(t, bundler.ListBundles(ctx, "table")) t.Cleanup(func() { _ = os.Remove(bundleName) // Measures to clean up downloaded files later