diff --git a/internal/command/machine/list.go b/internal/command/machine/list.go index 9defd7380b..55748da8eb 100644 --- a/internal/command/machine/list.go +++ b/internal/command/machine/list.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "fmt" + "io" "os" + "strconv" "strings" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/olekukonko/tablewriter/pkg/twwidth" "github.com/samber/lo" "github.com/spf13/cobra" fly "github.com/superfly/fly-go" + "github.com/superfly/fly-go/flaps" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/command" "github.com/superfly/flyctl/internal/config" @@ -20,7 +24,30 @@ import ( "github.com/superfly/flyctl/iostreams" ) -const defaultMachineListPager = "less -RSX -+F -P'Use left/right arrows to scroll; q to quit'" +const ( + defaultMachineListPager = "less -RSX -+F -P'Use left/right arrows to scroll; q to quit'" + defaultMachineListPageSize = 500 + + machineListQuit machineListAction = 'q' + machineListNextPage machineListAction = 'n' + machineListPrevPage machineListAction = 'p' +) + +type machineListAction rune + +// machineListCachedPage caches pages of machines when paginating through a +// large result set. +type machineListCachedPage struct { + machines []*fly.Machine + nextCursor string +} + +// machineListNavigation captures where we are in the paginated machine list. +type machineListNavigation struct { + hasNext bool + hasPrev bool + page int +} func newList() *cobra.Command { const ( @@ -48,6 +75,10 @@ func newList() *cobra.Command { Shorthand: "q", Description: "Only list machine ids", }, + flag.Int{ + Name: "limit", + Description: "Number of machines to return per page; 0 returns all machines", + }, ) return cmd @@ -59,17 +90,82 @@ func runMachineList(ctx context.Context) (err error) { io = iostreams.FromContext(ctx) silence = flag.GetBool(ctx, "quiet") cfg = config.FromContext(ctx) + limit = flag.GetInt(ctx, "limit") ) + if limit < 0 { + return fmt.Errorf("--limit must be 0 or greater") + } flapsClient := flapsutil.ClientFromContext(ctx) + pageLister, ok := flapsClient.(machinePageLister) + if !ok { + return fmt.Errorf("Machines API client does not support paginated machine lists") + } - machines, err := flapsClient.List(ctx, appName, "") + seenMachines := make(map[string]struct{}) + seenCursors := make(map[string]struct{}) + machines, nextCursor, err := loadMachineDisplayPage(ctx, pageLister, appName, limit, "", seenMachines, seenCursors) if err != nil { return err } - if cfg.JSONOutput { - return render.JSON(io.Out, machines) + interactivePagination := limit > 0 && io.IsInteractive() && !silence && !cfg.JSONOutput && nextCursor != "" + if !interactivePagination { + _, err := renderMachineListPage(io, appName, machines, silence, cfg.JSONOutput, nil) + return err + } + + pages := []machineListCachedPage{ + {machines: machines, nextCursor: nextCursor}, + } + pageIndex := 0 + for { + page := pages[pageIndex] + action, err := renderMachineListPage(io, appName, page.machines, silence, cfg.JSONOutput, &machineListNavigation{ + hasNext: page.nextCursor != "", + hasPrev: pageIndex > 0, + page: pageIndex + 1, + }) + if err != nil { + return err + } + + switch action { + case machineListQuit: + return nil + case machineListPrevPage: + if pageIndex == 0 { + continue + } + pageIndex-- + case machineListNextPage: + if pageIndex+1 < len(pages) { + pageIndex++ + } else if page.nextCursor != "" { + machines, nextCursor, err := loadMachineDisplayPage(ctx, pageLister, appName, limit, page.nextCursor, seenMachines, seenCursors) + if err != nil { + return err + } + if len(machines) == 0 { + pages[pageIndex].nextCursor = "" + continue + } + pages = append(pages, machineListCachedPage{machines: machines, nextCursor: nextCursor}) + pageIndex++ + } else { + continue + } + default: + return nil + } + + clearMachineListPage(io.Out) + } +} + +func renderMachineListPage(io *iostreams.IOStreams, appName string, machines []*fly.Machine, silence, jsonOutput bool, navigation *machineListNavigation) (machineListAction, error) { + if jsonOutput { + return machineListQuit, render.JSON(io.Out, machines) } if len(machines) == 0 { @@ -77,7 +173,7 @@ func runMachineList(ctx context.Context) (err error) { fmt.Fprintf(io.Out, "No machines are available on this app %s\n", appName) } - return nil + return machineListQuit, nil } rows := [][]string{} @@ -175,24 +271,102 @@ func runMachineList(ctx context.Context) (err error) { "Size", } - writeMachineListTable(io, appName, rows, headers) + footer := "" if unreachableMachines { - fmt.Fprintln(io.Out, "* These Machines' hosts could not be reached.") + footer = "* These Machines' hosts could not be reached." } + return writeMachineListTable(io, appName, rows, headers, footer, navigation) } - return nil + return machineListQuit, nil } -func writeMachineListTable(io *iostreams.IOStreams, appName string, rows [][]string, headers []string) { +type machinePageLister interface { + ListMachines(context.Context, string, *flaps.ListMachinesOpts) (*flaps.ListMachinesResponse, error) +} + +func loadMachineDisplayPage(ctx context.Context, client machinePageLister, appName string, limit int, cursor string, seenMachines, seenCursors map[string]struct{}) ([]*fly.Machine, string, error) { + initialCapacity := defaultMachineListPageSize + if limit > 0 { + initialCapacity = min(limit, defaultMachineListPageSize) + } + machines := make([]*fly.Machine, 0, initialCapacity) + + for { + if cursor != "" { + if _, ok := seenCursors[cursor]; ok { + return nil, "", fmt.Errorf("Machines API returned a repeated pagination cursor") + } + seenCursors[cursor] = struct{}{} + } + pageSize := defaultMachineListPageSize + if limit > 0 { + pageSize = min(pageSize, limit-len(machines)) + } + resp, err := client.ListMachines(ctx, appName, &flaps.ListMachinesOpts{ + Limit: pageSize, + Cursor: cursor, + }) + if err != nil { + return nil, "", err + } + + for _, machine := range resp.Machines { + if _, ok := seenMachines[machine.ID]; ok { + continue + } + seenMachines[machine.ID] = struct{}{} + machines = append(machines, machine) + // We have all the machines we need to display the next machine + // page: return the batch. + if limit > 0 && len(machines) == limit { + return machines, resp.NextCursor, nil + } + } + // This is the last batch of machines. + if resp.NextCursor == "" { + return machines, "", nil + } + cursor = resp.NextCursor + } +} + +func writeMachineListTable(io *iostreams.IOStreams, appName string, rows [][]string, headers []string, footer string, navigation *machineListNavigation) (machineListAction, error) { if !io.IsInteractive() { _ = render.Table(io.Out, appName, rows, headers...) + if footer != "" { + fmt.Fprintln(io.Out, footer) + } - return + return machineListQuit, nil } var output bytes.Buffer _ = render.Table(&output, appName, rows, headers...) + if footer != "" { + fmt.Fprintln(&output, footer) + } + + if navigation != nil { + io.SetPager(machineListNavigationPager(*navigation)) + if err := io.StartPager(); err != nil { + _, _ = io.Out.Write(output.Bytes()) + return readMachineListNavigation(io, *navigation) + } + if _, err := io.Out.Write(output.Bytes()); err != nil { + io.StopPager() + return machineListQuit, err + } + + // Use the pager's exit code to figure out the next action. + exitCode := io.StopPagerWithExitCode() + if exitCode == 1 { + _, _ = io.Out.Write(output.Bytes()) + return readMachineListNavigation(io, *navigation) + } + + return machineListActionFromExitCode(exitCode), nil + } if shouldPageMachineListTable(output.String(), io.TerminalWidth()) { if _, pagerSet := os.LookupEnv("PAGER"); !pagerSet { @@ -204,6 +378,96 @@ func writeMachineListTable(io *iostreams.IOStreams, appName string, rows [][]str } _, _ = io.Out.Write(output.Bytes()) + return machineListQuit, nil +} + +func machineListNavigationPager(navigation machineListNavigation) string { + return fmt.Sprintf("less --lesskey-content=%s -RSX -+F -P%s", + strconv.Quote(machineListNavigationKeys(navigation)), + strconv.Quote(machineListNavigationPrompt(navigation, true)), + ) +} + +func machineListNavigationKeys(navigation machineListNavigation) string { + // Create custom keybindings for the pager, so we can figure out what key + // the user pressed. + bindings := []string{"#command", "q quit q"} + if navigation.hasNext { + bindings = append(bindings, "n quit n") + } + if navigation.hasPrev { + bindings = append(bindings, "p quit p") + } + + return strings.Join(bindings, ";") +} + +func readMachineListNavigation(io *iostreams.IOStreams, navigation machineListNavigation) (machineListAction, error) { + in, inOK := io.In.(terminal.FileReader) + out, outOK := io.Out.(terminal.FileWriter) + if !inOK || !outOK { + return machineListQuit, nil + } + + reader := terminal.NewRuneReader(terminal.Stdio{In: in, Out: out, Err: io.ErrOut}) + if err := reader.SetTermMode(); err != nil { + return machineListQuit, nil + } + defer reader.RestoreTermMode() //nolint:errcheck + + menu := machineListNavigationPrompt(navigation, false) + fmt.Fprint(io.Out, menu) + defer fmt.Fprintln(io.Out) + for { + key, _, err := reader.ReadRune() + if err != nil { + return machineListQuit, err + } + switch machineListAction(key) { + case machineListNextPage: + if navigation.hasNext { + return machineListNextPage, nil + } + case machineListPrevPage: + if navigation.hasPrev { + return machineListPrevPage, nil + } + case machineListQuit, machineListAction(terminal.KeyInterrupt), machineListAction(terminal.KeyEscape): + return machineListQuit, nil + } + } +} + +func machineListNavigationPrompt(navigation machineListNavigation, canScroll bool) string { + actions := []string{fmt.Sprintf("page %d", navigation.page)} + if navigation.hasNext { + actions = append(actions, "[n] next") + } + if navigation.hasPrev { + actions = append(actions, "[p] previous") + } + actions = append(actions, "[q] quit") + if canScroll { + actions = append(actions, "arrows scroll") + } + + return strings.Join(actions, ", ") +} + +func machineListActionFromExitCode(exitCode int) machineListAction { + switch machineListAction(exitCode) { + case machineListNextPage: + return machineListNextPage + case machineListPrevPage: + return machineListPrevPage + default: + return machineListQuit + } +} + +func clearMachineListPage(out io.Writer) { + // Clear the terminal screen and move cursor to the top-left corner. + _, _ = io.WriteString(out, "\x1b[2J\x1b[H") } func shouldPageMachineListTable(output string, terminalWidth int) bool { diff --git a/internal/command/machine/list_test.go b/internal/command/machine/list_test.go new file mode 100644 index 0000000000..53833314c4 --- /dev/null +++ b/internal/command/machine/list_test.go @@ -0,0 +1,148 @@ +package machine + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/fly-go/flaps" +) + +type machineListPage struct { + response *flaps.ListMachinesResponse + err error +} + +type fakeMachinePageLister struct { + pages map[string]machineListPage + opts []*flaps.ListMachinesOpts + appIDs []string +} + +func (f *fakeMachinePageLister) ListMachines(_ context.Context, appName string, opts *flaps.ListMachinesOpts) (*flaps.ListMachinesResponse, error) { + f.appIDs = append(f.appIDs, appName) + f.opts = append(f.opts, opts) + page := f.pages[opts.Cursor] + return page.response, page.err +} + +func TestMachineListNavigationPager(t *testing.T) { + command := machineListNavigationPager(machineListNavigation{hasNext: true, hasPrev: true, page: 2}) + for _, want := range []string{"n quit n", "p quit p", "q quit q", "page 2", "[n] next", "[p] previous", "[q] quit"} { + assert.Contains(t, command, want) + } + + firstPageCommand := machineListNavigationPager(machineListNavigation{hasNext: true, page: 1}) + assert.NotContains(t, firstPageCommand, "[p] previous") + assert.NotContains(t, firstPageCommand, "p quit p") + + lastPageCommand := machineListNavigationPager(machineListNavigation{hasPrev: true, page: 3}) + assert.NotContains(t, lastPageCommand, "n quit n") +} + +func TestMachineListActionFromExitCode(t *testing.T) { + for _, test := range []struct { + exitCode int + want machineListAction + }{ + {exitCode: int('n'), want: machineListNextPage}, + {exitCode: int('p'), want: machineListPrevPage}, + {exitCode: int('q'), want: machineListQuit}, + {exitCode: 0, want: machineListQuit}, + } { + assert.Equal(t, test.want, machineListActionFromExitCode(test.exitCode)) + } +} + +func TestListMachinePagePaginatesAndDeduplicates(t *testing.T) { + client := &fakeMachinePageLister{pages: map[string]machineListPage{ + "": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-1"}, {ID: "machine-2"}}, + NextCursor: "page-2", + }, + }, + "page-2": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-2"}, {ID: "machine-3"}}, + }, + }, + }} + + machines, nextCursor, err := loadMachineDisplayPage(t.Context(), client, "test-app", 0, "", map[string]struct{}{}, map[string]struct{}{}) + require.NoError(t, err) + assert.Empty(t, nextCursor) + + require.Len(t, machines, 3) + assert.Equal(t, "machine-1", machines[0].ID) + assert.Equal(t, "machine-2", machines[1].ID) + assert.Equal(t, "machine-3", machines[2].ID) + require.Len(t, client.opts, 2) + assert.Equal(t, 500, client.opts[0].Limit) + assert.Equal(t, "page-2", client.opts[1].Cursor) + assert.Equal(t, []string{"test-app", "test-app"}, client.appIDs) +} + +func TestListMachinePageRejectsRepeatedCursor(t *testing.T) { + client := &fakeMachinePageLister{pages: map[string]machineListPage{ + "": {response: &flaps.ListMachinesResponse{NextCursor: "page-2"}}, + "page-2": {response: &flaps.ListMachinesResponse{NextCursor: "page-2"}}, + }} + + _, _, err := loadMachineDisplayPage(context.Background(), client, "test-app", 0, "", map[string]struct{}{}, map[string]struct{}{}) + require.EqualError(t, err, "Machines API returned a repeated pagination cursor") +} + +func TestListMachinePageStopsAtLimit(t *testing.T) { + client := &fakeMachinePageLister{pages: map[string]machineListPage{ + "": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-1"}, {ID: "machine-2"}}, + NextCursor: "page-2", + }, + }, + "page-2": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-3"}}, + NextCursor: "page-3", + }, + }, + }} + + machines, nextCursor, err := loadMachineDisplayPage(t.Context(), client, "test-app", 3, "", map[string]struct{}{}, map[string]struct{}{}) + require.NoError(t, err) + require.Len(t, machines, 3) + require.Len(t, client.opts, 2) + assert.Equal(t, 3, client.opts[0].Limit) + assert.Equal(t, 1, client.opts[1].Limit) + assert.Equal(t, "page-3", nextCursor) +} + +func TestListMachinePageContinuesFromCursor(t *testing.T) { + client := &fakeMachinePageLister{pages: map[string]machineListPage{ + "": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-1"}, {ID: "machine-2"}}, + NextCursor: "page-2", + }, + }, + "page-2": { + response: &flaps.ListMachinesResponse{ + Machines: []*fly.Machine{{ID: "machine-3"}, {ID: "machine-4"}}, + }, + }, + }} + seenMachines := map[string]struct{}{} + seenCursors := map[string]struct{}{} + + firstPage, nextCursor, err := loadMachineDisplayPage(t.Context(), client, "test-app", 2, "", seenMachines, seenCursors) + require.NoError(t, err) + secondPage, nextCursor, err := loadMachineDisplayPage(t.Context(), client, "test-app", 2, nextCursor, seenMachines, seenCursors) + require.NoError(t, err) + + assert.Len(t, firstPage, 2) + assert.Len(t, secondPage, 2) + assert.Empty(t, nextCursor) +} diff --git a/iostreams/iostreams.go b/iostreams/iostreams.go index 7c1376f814..1697af4f4d 100644 --- a/iostreams/iostreams.go +++ b/iostreams/iostreams.go @@ -231,17 +231,29 @@ func (s *IOStreams) StartPager() error { } func (s *IOStreams) StopPager() { + _ = s.StopPagerWithExitCode() +} + +// StopPagerWithExitCode stops the active pager and returns its exit code. +// A pager that exits normally, or no active pager, returns zero. +func (s *IOStreams) StopPagerWithExitCode() int { if s.pagerProcess == nil { - return + return 0 } if closer, ok := s.Out.(io.Closer); ok { _ = closer.Close() } - _ = s.pagerProcess.Wait() + err := s.pagerProcess.Wait() + exitCode := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } s.pagerProcess = nil s.Out = s.pagerOut s.pagerOut = nil + + return exitCode } func (s *IOStreams) CanPrompt() bool {