From 3b9bbc3fb8279caa6e15751e6689fa4f8e584a16 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Sat, 24 Feb 2024 22:01:09 +0900 Subject: [PATCH] Introduce tui for listing up cloudformation stack --- app/domain/model/cloudformation.go | 60 ++++++++- cmd/subcmd/cfn/interactive.go | 8 ++ cmd/subcmd/cfn/ls.go | 60 +-------- cmd/subcmd/cfn/root.go | 5 + ui/cfn/command.go | 30 +++++ ui/cfn/list.go | 197 +++++++++++++++++++++++++++++ ui/cfn/root.go | 105 +++++++++++++++ ui/cfn/status.go | 21 +++ 8 files changed, 430 insertions(+), 56 deletions(-) create mode 100644 cmd/subcmd/cfn/interactive.go create mode 100644 ui/cfn/command.go create mode 100644 ui/cfn/list.go create mode 100644 ui/cfn/root.go create mode 100644 ui/cfn/status.go diff --git a/app/domain/model/cloudformation.go b/app/domain/model/cloudformation.go index 695bec7..7672ccf 100644 --- a/app/domain/model/cloudformation.go +++ b/app/domain/model/cloudformation.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/fatih/color" +) const ( // CloudFormationRetryMaxAttempts is the maximum number of retries for CloudFormation. @@ -18,6 +22,60 @@ func (s StackStatus) String() string { return string(s) } +// StringWithColor returns the string representation of StackStatus with color. +func (s StackStatus) StringWithColor() string { + switch s { + case StackStatusCreateComplete: + return color.GreenString("CREATE_COMPLETE") + case StackStatusCreateFailed: + return color.RedString("CREATE_FAILED") + case StackStatusCreateInProgress: + return color.YellowString("CREATE_IN_PROGRESS") + case StackStatusDeleteComplete: + return color.GreenString("DELETE_COMPLETE") + case StackStatusDeleteFailed: + return color.RedString("DELETE_FAILED") + case StackStatusDeleteInProgress: + return color.YellowString("DELETE_IN_PROGRESS") + case StackStatusRollbackComplete: + return color.GreenString("ROLLBACK_COMPLETE") + case StackStatusRollbackFailed: + return color.RedString("ROLLBACK_FAILED") + case StackStatusRollbackInProgress: + return color.YellowString("ROLLBACK_IN_PROGRESS") + case StackStatusUpdateComplete: + return color.GreenString("UPDATE_COMPLETE") + case StackStatusUpdateCompleteCleanupInProgress: + return color.YellowString("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS") + case StackStatusUpdateInProgress: + return color.YellowString("UPDATE_IN_PROGRESS") + case StackStatusUpdateRollbackComplete: + return color.GreenString("UPDATE_ROLLBACK_COMPLETE") + case StackStatusUpdateRollbackCompleteCleanupInProgress: + return color.YellowString("UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS") + case StackStatusUpdateRollbackFailed: + return color.RedString("UPDATE_ROLLBACK_FAILED") + case StackStatusUpdateFailed: + return color.RedString("UPDATE_FAILED") + case StackStatusUpdateRollbackInProgress: + return color.YellowString("UPDATE_ROLLBACK_IN_PROGRESS") + case StackStatusReviewInProgress: + return color.YellowString("REVIEW_IN_PROGRESS") + case StackStatusImportInProgress: + return color.YellowString("IMPORT_IN_PROGRESS") + case StackStatusImportComplete: + return color.GreenString("IMPORT_COMPLETE") + case StackStatusImportRollbackInProgress: + return color.YellowString("IMPORT_ROLLBACK_IN_PROGRESS") + case StackStatusImportRollbackFailed: + return color.RedString("IMPORT_ROLLBACK_FAILED") + case StackStatusImportRollbackComplete: + return color.GreenString("IMPORT_ROLLBACK_COMPLETE") + default: + return color.RedString("UNKNOWN") + } +} + // CloudFormation stack status constants // Ref. https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html const ( diff --git a/cmd/subcmd/cfn/interactive.go b/cmd/subcmd/cfn/interactive.go new file mode 100644 index 0000000..360dacf --- /dev/null +++ b/cmd/subcmd/cfn/interactive.go @@ -0,0 +1,8 @@ +package cfn + +import tui "github.com/nao1215/rainbow/ui/cfn" + +// interactive starts cfn command interactive UI. +func interactive() error { + return tui.RunCfnUI() +} diff --git a/cmd/subcmd/cfn/ls.go b/cmd/subcmd/cfn/ls.go index 14f45df..75ea5b0 100644 --- a/cmd/subcmd/cfn/ls.go +++ b/cmd/subcmd/cfn/ls.go @@ -52,64 +52,14 @@ func (l *lsCmd) Do() error { } for _, stack := range out.Stacks { + if stack.StackName == nil || stack.StackStatus == model.StackStatusDeleteComplete { + continue + } + l.printf(" %s (status=%s, updated_at=%s)\n", color.GreenString(*stack.StackName), - stackStatusString(stack.StackStatus), + stack.StackStatus.StringWithColor(), stack.LastUpdatedTime.Format("2006-01-02 15:04:05")) } return nil } - -// stackStatusString returns a string representation of the stack status. -func stackStatusString(status model.StackStatus) string { - switch status { - case model.StackStatusCreateComplete: - return color.GreenString("CREATE_COMPLETE") - case model.StackStatusCreateFailed: - return color.RedString("CREATE_FAILED") - case model.StackStatusCreateInProgress: - return color.YellowString("CREATE_IN_PROGRESS") - case model.StackStatusDeleteComplete: - return color.GreenString("DELETE_COMPLETE") - case model.StackStatusDeleteFailed: - return color.RedString("DELETE_FAILED") - case model.StackStatusDeleteInProgress: - return color.YellowString("DELETE_IN_PROGRESS") - case model.StackStatusRollbackComplete: - return color.GreenString("ROLLBACK_COMPLETE") - case model.StackStatusRollbackFailed: - return color.RedString("ROLLBACK_FAILED") - case model.StackStatusRollbackInProgress: - return color.YellowString("ROLLBACK_IN_PROGRESS") - case model.StackStatusUpdateComplete: - return color.GreenString("UPDATE_COMPLETE") - case model.StackStatusUpdateCompleteCleanupInProgress: - return color.YellowString("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS") - case model.StackStatusUpdateInProgress: - return color.YellowString("UPDATE_IN_PROGRESS") - case model.StackStatusUpdateRollbackComplete: - return color.GreenString("UPDATE_ROLLBACK_COMPLETE") - case model.StackStatusUpdateRollbackCompleteCleanupInProgress: - return color.YellowString("UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS") - case model.StackStatusUpdateRollbackFailed: - return color.RedString("UPDATE_ROLLBACK_FAILED") - case model.StackStatusUpdateFailed: - return color.RedString("UPDATE_FAILED") - case model.StackStatusUpdateRollbackInProgress: - return color.YellowString("UPDATE_ROLLBACK_IN_PROGRESS") - case model.StackStatusReviewInProgress: - return color.YellowString("REVIEW_IN_PROGRESS") - case model.StackStatusImportInProgress: - return color.YellowString("IMPORT_IN_PROGRESS") - case model.StackStatusImportComplete: - return color.GreenString("IMPORT_COMPLETE") - case model.StackStatusImportRollbackInProgress: - return color.YellowString("IMPORT_ROLLBACK_IN_PROGRESS") - case model.StackStatusImportRollbackFailed: - return color.RedString("IMPORT_ROLLBACK_FAILED") - case model.StackStatusImportRollbackComplete: - return color.GreenString("IMPORT_ROLLBACK_COMPLETE") - default: - return color.RedString("UNKNOWN") - } -} diff --git a/cmd/subcmd/cfn/root.go b/cmd/subcmd/cfn/root.go index ff0f132..1ff3ddc 100644 --- a/cmd/subcmd/cfn/root.go +++ b/cmd/subcmd/cfn/root.go @@ -2,11 +2,16 @@ package cfn import ( + "os" + "github.com/spf13/cobra" ) // Execute starts the root command of cfn command. func Execute() error { + if len(os.Args) == 1 { + return interactive() + } if err := newRootCmd().Execute(); err != nil { return err } diff --git a/ui/cfn/command.go b/ui/cfn/command.go new file mode 100644 index 0000000..81ec130 --- /dev/null +++ b/ui/cfn/command.go @@ -0,0 +1,30 @@ +package cfn + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/usecase" + "github.com/nao1215/rainbow/ui" +) + +// fetchStacks is the message that is sent when the user wants to fetch the list of the CloudFormation stacks. +type fetchStacks struct { + stacks []*model.Stack +} + +func fetchStacksCmd(ctx context.Context, app *di.CFnApp, region model.Region) tea.Cmd { + return tea.Cmd(func() tea.Msg { + output, err := app.CFnStackLister.ListCFnStack(ctx, &usecase.CFnStackListerInput{ + Region: region, + }) + if err != nil { + return ui.ErrMsg(err) + } + return fetchStacks{ + stacks: output.Stacks, + } + }) +} diff --git a/ui/cfn/list.go b/ui/cfn/list.go new file mode 100644 index 0000000..efe5699 --- /dev/null +++ b/ui/cfn/list.go @@ -0,0 +1,197 @@ +package cfn + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/ui" +) + +// cfnListStackModel is the model for listing the CloudFormation stacks. +type cfnListStackModel struct { + // err is the error that occurred during the operation. + err error + // awsConfig is the AWS configuration. + awsConfig *model.AWSConfig + // awsProfile is the AWS profile. + awsProfile model.AWSProfile + // region is the AWS region that the user wants to create the S3 bucket. + region model.Region + // choice is the currently selected menu item. + choice *ui.Choice + // app is the CFn application service. + app *di.CFnApp + // ctx is the context. + ctx context.Context + // stacks is the list of the CloudFormation stacks. + stacks []*model.Stack + // status is the status of the operation. + status status + // toggle is the currently selected menu item. + toggles ui.ToggleSets +} + +const ( + windowHeight = 10 +) + +// newCFnListStackModel returns the new cfnListStackModel for listing the CloudFormation stacks. +func newCFnListStackModel(region model.Region) (*cfnListStackModel, error) { + ctx := context.Background() + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(ctx, profile, region) + if err != nil { + return nil, err + } + + app, err := di.NewCFnApp(ctx, profile, region) + if err != nil { + return nil, err + } + + return &cfnListStackModel{ + awsConfig: cfg, + awsProfile: profile, + region: region, + app: app, + stacks: []*model.Stack{}, + status: statusNone, + choice: ui.NewChoice(0, 0), + ctx: ctx, + }, nil +} + +// Init initializes the model. +func (m *cfnListStackModel) Init() tea.Cmd { + return nil // Not called this method +} + +// Update updates the model. +func (m *cfnListStackModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.err != nil { + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "j", "down": + m.choice.Increment() + case "k", "up": + m.choice.Decrement() + case "ctrl+c": + m.status = statusQuit + return m, tea.Quit + case "q", "esc": + m.status = statusReturnToTop + return newCFnRootModel(m.awsProfile, m.awsConfig), nil + case "D": + // TODO: implement delete stack + case "enter": + if m.status == statusReturnToTop { + return newCFnRootModel(m.awsProfile, m.awsConfig), nil + } + case " ": + if m.status == statusStacksListed { + m.toggles[m.choice.Choice].Toggle() + } + } + case fetchStacks: + m.status = statusStacksFetched + m.stacks = make([]*model.Stack, 0, len(msg.stacks)) + for _, stack := range msg.stacks { + if stack.StackName == nil || stack.StackStatus == model.StackStatusDeleteComplete { + continue + } + m.stacks = append(m.stacks, stack) + } + m.choice = ui.NewChoice(0, len(m.stacks)-1) + m.toggles = ui.NewToggleSets(len(m.stacks)) + return m, nil + case ui.ErrMsg: + m.err = msg + m.status = statusQuit + return m, tea.Quit + default: + return m, nil + } + return m, nil +} + +// View renders the application's UI. +func (m *cfnListStackModel) View() string { + if m.err != nil { + m.status = statusQuit + return ui.ErrorMessage(m.err) + } + + switch m.status { + case statusQuit: + return ui.GoodByeMessage() + case statusNone, statusStacksFetching: + return fmt.Sprintf( + "fetching the list of the CloudForamtion Stack (profile=%s)\n", + m.awsProfile.String()) + case statusStacksFetched: + return m.stackListString() + default: + return m.stackListString() + } +} + +// stackListString returns the string of the list of the CloudFormation stacks. +func (m *cfnListStackModel) stackListString() string { + switch len(m.stacks) { + case 0: + return m.emptyStacksString() + default: + return m.stacksListStrWithCheckBox() + } +} + +// emptyStacksString returns the string of the empty list of the CloudFormation stacks. +func (m *cfnListStackModel) emptyStacksString() string { + m.status = statusReturnToTop + return fmt.Sprintf("No CloudFormation Stacks (profile=%s)\n\n%s\n", + m.awsProfile.String(), + ui.Subtle(": return to the top")) +} + +// stacksListStrWithCheckBox returns the string of the list of the CloudFormation stacks with checkbox. +func (m *cfnListStackModel) stacksListStrWithCheckBox() string { + startIndex := 0 + endIndex := len(m.stacks) + + if m.choice.Choice >= windowHeight { + startIndex = m.choice.Choice - windowHeight + 1 + endIndex = startIndex + windowHeight + if endIndex > len(m.stacks) { + startIndex = len(m.stacks) - windowHeight + endIndex = len(m.stacks) + } + } else { + if len(m.stacks) > windowHeight { + endIndex = windowHeight + } + } + + m.status = statusStacksListed + s := fmt.Sprintf("CloudForamtion Stacks %d/%d (profile=%s)\n\n", m.choice.Choice+1, len(m.stacks), m.awsProfile.String()) + for i := startIndex; i < endIndex; i++ { + stack := m.stacks[i] + s += fmt.Sprintf("%s\n", + ui.ToggleWidget( + fmt.Sprintf( + " %s (status=%s, updated_at=%s)", + color.GreenString(*stack.StackName), + stack.StackStatus.StringWithColor(), + stack.LastUpdatedTime.Format("2006-01-02 15:04:05")), + m.choice.Choice == i, m.toggles[i].Enabled)) + } + s += ui.Subtle("\n: return to the top | : quit | up/down: select\n") + return s +} diff --git a/ui/cfn/root.go b/ui/cfn/root.go new file mode 100644 index 0000000..6f57c6d --- /dev/null +++ b/ui/cfn/root.go @@ -0,0 +1,105 @@ +// Package cfn is the text-based user interface for cfn command. +package cfn + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/ui" +) + +// cfnRootModel is the top-level model for the application. +type cfnRootModel struct { + // err is the error that occurred during the operation. + err error + // state is the status of the create bucket operation. + status status + // region is the AWS region that the user wants to create the S3 bucket. + region model.Region + // awsConfig is the AWS configuration. + awsConfig *model.AWSConfig + // awsProfile is the AWS profile. + awsProfile model.AWSProfile + // quitting is true when the user has quit the application. + quitting bool +} + +// RunCfnUI start cfn command interactive UI. +func RunCfnUI() error { + ctx := context.Background() + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(ctx, profile, "") + if err != nil { + return err + } + _, err = tea.NewProgram(newCFnRootModel(profile, cfg)).Run() + return err +} + +// newCFnRootModel creates a new cfnRootModel. +func newCFnRootModel(profile model.AWSProfile, cfg *model.AWSConfig) *cfnRootModel { + return &cfnRootModel{ + status: statusRegionSelecting, + region: cfg.Region(), + awsConfig: cfg, + awsProfile: profile, + } +} + +// Init initializes the model. +func (m *cfnRootModel) Init() tea.Cmd { + return nil +} + +// Update is the main update function. +func (m *cfnRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.err != nil { + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "h", "left": + m.region = m.region.Prev() + case "l", "right": + m.region = m.region.Next() + case "enter": + model, err := newCFnListStackModel(m.region) + if err != nil { + m.err = err + return m, tea.Quit + } + return model, fetchStacksCmd(model.ctx, model.app, model.region) + case "ctrl+c", "esc", "q": + m.quitting = true + return m, tea.Quit + } + case ui.ErrMsg: + m.err = msg + return m, nil + } + return m, nil +} + +// View renders the model. +func (m cfnRootModel) View() string { + if m.err != nil { + return ui.ErrorMessage(m.err) + } + + if m.quitting { + return ui.GoodByeMessage() + } + + return fmt.Sprintf( + "%s\n\n[ AWS Profile ] %s\n[ ◀︎ %s ▶︎ ] %s\n\n%s\n%s\n", + "Set the region for the CloudFormation stack you want to display.", + m.awsProfile.String(), + ui.Yellow("Region"), + ui.Green(m.region.String()), + ui.Subtle("h/l, left/right: select region | , , q: quit"), + ui.Subtle(": list up the CloudFormation stacks")) +} diff --git a/ui/cfn/status.go b/ui/cfn/status.go new file mode 100644 index 0000000..fe54dcd --- /dev/null +++ b/ui/cfn/status.go @@ -0,0 +1,21 @@ +package cfn + +// status is the status of the cfn operation. +type status uint + +const ( + // statusNone is the status when the cfn operation is not executed. + statusNone status = iota + // statusRegionSelecting is the status when the cfn operation is executed and the region is being selected. + statusRegionSelecting + // statusStacksFetching is the status when the cfn operation is executed and the stacks are being fetched. + statusStacksFetching + // statusStacksFetched is the status when the cfn operation is executed and the stacks are fetched. + statusStacksFetched + // statusStacksListed is the status when the cfn operation is executed and the stacks are listed. + statusStacksListed + // statusReturnToTop is the status when the cfn operation is executed and the user wants to return to the top. + statusReturnToTop + // statusQuit is the status when the cfn operation is executed and the user wants to quit. + statusQuit +)