From 13b7c072d6270d48e5ed33a1ebf126c0e8d2ad82 Mon Sep 17 00:00:00 2001 From: Roland Schilter Date: Thu, 12 Jul 2018 18:51:35 -0700 Subject: [PATCH] Add `flux release --interactive` This adds an `--interactive` option to the `release` command that lets you pick and choose which containers to update. It uses the new `containers` specification in the /v9/update-manifest call. Once the containers are selected, the release is sent off with the constraint that the state of the containers need to be the exact same as when the results were presented to the user. If anything changed in the meanwhile, the whole release will not be processed. --- Gopkg.lock | 2 +- Gopkg.toml | 4 + cmd/fluxctl/release_cmd.go | 39 ++++- update/menu.go | 286 +++++++++++++++++++++++++++++++++++++ update/menu_unix.go | 57 ++++++++ update/menu_win.go | 13 ++ update/print.go | 39 +---- 7 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 update/menu.go create mode 100644 update/menu_unix.go create mode 100644 update/menu_win.go diff --git a/Gopkg.lock b/Gopkg.lock index 411eb5253..c7bcdf5b4 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -883,6 +883,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "06f3e7d274e6c3037b637df418c1cf8772a5e25bdc5e858c9cf9fe44bd80ceb4" + inputs-digest = "753bd801312bfb0aacc2d33e343346c5a73bbdab2cde843d0b31aabefe14895f" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index d7a56a72a..3e2a6d966 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -49,3 +49,7 @@ required = ["k8s.io/code-generator/cmd/client-gen"] [[constraint]] name = "github.com/justinbarrick/go-k8s-portforward" version = "v1.0.0" + +[[constraint]] + branch = "master" + name = "github.com/pkg/term" diff --git a/cmd/fluxctl/release_cmd.go b/cmd/fluxctl/release_cmd.go index 60ac932ee..f7150152b 100644 --- a/cmd/fluxctl/release_cmd.go +++ b/cmd/fluxctl/release_cmd.go @@ -3,10 +3,12 @@ package main import ( "context" "fmt" + "io" "github.com/spf13/cobra" "github.com/weaveworks/flux" + "github.com/weaveworks/flux/job" "github.com/weaveworks/flux/update" ) @@ -19,6 +21,7 @@ type controllerReleaseOpts struct { allImages bool exclude []string dryRun bool + interactive bool outputOpts cause update.Cause @@ -51,6 +54,7 @@ func (opts *controllerReleaseOpts) Command() *cobra.Command { cmd.Flags().BoolVar(&opts.allImages, "update-all-images", false, "Update all images to latest versions") cmd.Flags().StringSliceVar(&opts.exclude, "exclude", []string{}, "List of controllers to exclude") cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Do not release anything; just report back what would have been done") + cmd.Flags().BoolVar(&opts.interactive, "interactive", false, "Select interactively which containers to update") // Deprecated cmd.Flags().StringSliceVarP(&opts.services, "service", "s", []string{}, "Service to release") @@ -104,7 +108,7 @@ func (opts *controllerReleaseOpts) RunE(cmd *cobra.Command, args []string) error } var kind update.ReleaseKind = update.ReleaseKindExecute - if opts.dryRun { + if opts.dryRun || opts.interactive { kind = update.ReleaseKindPlan } @@ -117,7 +121,7 @@ func (opts *controllerReleaseOpts) RunE(cmd *cobra.Command, args []string) error excludes = append(excludes, s) } - if opts.dryRun { + if kind == update.ReleaseKindPlan { fmt.Fprintf(cmd.OutOrStderr(), "Submitting dry-run release...\n") } else { fmt.Fprintf(cmd.OutOrStderr(), "Submitting release ...\n") @@ -139,5 +143,36 @@ func (opts *controllerReleaseOpts) RunE(cmd *cobra.Command, args []string) error return err } + if opts.interactive { + result, err := awaitJob(ctx, opts.API, jobID) + if err != nil { + return err + } + + spec, err := promptSpec(cmd.OutOrStdout(), result, opts.verbosity) + if err != nil { + fmt.Fprintln(cmd.OutOrStderr(), err.Error()) + return nil + } + + fmt.Fprintf(cmd.OutOrStderr(), "Submitting selected release...\n") + jobID, err = opts.API.UpdateManifests(ctx, update.Spec{ + Type: update.Containers, + Cause: opts.cause, + Spec: spec, + }) + + opts.dryRun = false + } return await(ctx, cmd.OutOrStdout(), cmd.OutOrStderr(), opts.API, jobID, !opts.dryRun, opts.verbosity) } + +func promptSpec(out io.Writer, result job.Result, verbosity int) (update.ContainerSpecs, error) { + menu := update.NewMenu(out, result.Result, verbosity) + containerSpecs, err := menu.Run() + return update.ContainerSpecs{ + Kind: update.ReleaseKindExecute, + ContainerSpecs: containerSpecs, + SkipMismatches: false, + }, err +} diff --git a/update/menu.go b/update/menu.go new file mode 100644 index 000000000..ce0b9a6c8 --- /dev/null +++ b/update/menu.go @@ -0,0 +1,286 @@ +package update + +import ( + "bytes" + "errors" + "fmt" + "io" + "text/tabwriter" + + "github.com/weaveworks/flux" +) + +const ( + // Escape sequences. + moveCursorUp = "\033[%dA" + hideCursor = "\033[?25l" + showCursor = "\033[?25h" + + // Glyphs. + glyphSelected = "\u21d2" + glyphChecked = "\u25c9" + glyphUnchecked = "\u25ef" + + tableHeading = "CONTROLLER \tSTATUS \tUPDATES" +) + +type writer struct { + out io.Writer + tw *tabwriter.Writer + lines int // lines written since last clear + width uint16 // terminal width +} + +func newWriter(out io.Writer) *writer { + return &writer{ + out: out, + tw: tabwriter.NewWriter(out, 0, 2, 2, ' ', 0), + width: terminalWidth(), + } +} + +func (c *writer) hideCursor() { + fmt.Fprintf(c.out, hideCursor) +} + +func (c *writer) showCursor() { + fmt.Fprintf(c.out, showCursor) +} + +// writeln counts the lines we output. +func (c *writer) writeln(line string) error { + line += "\n" + c.lines += (len(line)-1)/int(c.width) + 1 + _, err := c.tw.Write([]byte(line)) + return err +} + +// clear moves the terminal cursor up to the beginning of the +// line where we started writing. +func (c *writer) clear() { + if c.lines != 0 { + fmt.Fprintf(c.out, moveCursorUp, c.lines) + } + c.lines = 0 +} + +func (c *writer) flush() error { + return c.tw.Flush() +} + +type menuItem struct { + id flux.ResourceID + status ControllerUpdateStatus + error string + update ContainerUpdate + + checked bool +} + +// Menu presents a list of controllers which can be interacted with. +type Menu struct { + wr *writer + items []menuItem + selectable int + cursor int +} + +// NewMenu creates a menu printer that outputs a result set to +// the `io.Writer` provided, at the given level of verbosity: +// - 2 = include skipped and ignored resources +// - 1 = include skipped resources, exclude ignored resources +// - 0 = exclude skipped and ignored resources +// +// It can print a one time listing with `Print()` or then enter +// interactive mode with `Run()`. +func NewMenu(out io.Writer, results Result, verbosity int) *Menu { + m := &Menu{wr: newWriter(out)} + m.fromResults(results, verbosity) + return m +} + +func (m *Menu) fromResults(results Result, verbosity int) { + for _, serviceID := range results.ServiceIDs() { + resourceID := flux.MustParseResourceID(serviceID) + result := results[resourceID] + switch result.Status { + case ReleaseStatusIgnored: + if verbosity < 2 { + continue + } + case ReleaseStatusSkipped: + if verbosity < 1 { + continue + } + } + + if result.Error != "" { + m.AddItem(menuItem{ + id: resourceID, + status: result.Status, + error: result.Error, + }) + } + for _, upd := range result.PerContainer { + m.AddItem(menuItem{ + id: resourceID, + status: result.Status, + update: upd, + }) + } + if result.Error == "" && len(result.PerContainer) == 0 { + m.AddItem(menuItem{ + id: resourceID, + status: result.Status, + }) + } + } + return +} + +func (m *Menu) AddItem(mi menuItem) { + if mi.checkable() { + mi.checked = true + m.selectable++ + } + m.items = append(m.items, mi) +} + +// Run starts the interactive menu mode. +func (m *Menu) Run() (map[flux.ResourceID][]ContainerUpdate, error) { + specs := make(map[flux.ResourceID][]ContainerUpdate) + if m.selectable == 0 { + return specs, errors.New("No changes found.") + } + + m.printInteractive() + m.wr.hideCursor() + defer m.wr.showCursor() + + for { + ascii, keyCode, err := getChar() + if err != nil { + return specs, err + } + + switch ascii { + case 3, 27, 'q': + return specs, errors.New("Aborted.") + case ' ': + m.toggleSelected() + case 13: + for _, item := range m.items { + if item.checked { + specs[item.id] = append(specs[item.id], item.update) + } + } + m.wr.writeln("") + return specs, nil + case 9, 'j': + m.cursorDown() + case 'k': + m.cursorUp() + default: + switch keyCode { + case 40: + m.cursorDown() + case 38: + m.cursorUp() + } + } + + } +} + +func (m *Menu) Print() { + m.wr.writeln(tableHeading) + var previd flux.ResourceID + for _, item := range m.items { + inline := previd == item.id + m.wr.writeln(m.renderItem(item, inline)) + previd = item.id + } + m.wr.flush() +} + +func (m *Menu) printInteractive() { + m.wr.clear() + m.wr.writeln(" " + tableHeading) + i := 0 + var previd flux.ResourceID + for _, item := range m.items { + inline := previd == item.id + m.wr.writeln(m.renderInteractiveItem(item, inline, i)) + previd = item.id + if item.checkable() { + i++ + } + } + m.wr.writeln("") + m.wr.writeln("Use arrow keys and [Space] to toggle updates; hit [Enter] to release selected.") + + m.wr.flush() +} + +func (m *Menu) renderItem(item menuItem, inline bool) string { + if inline { + return fmt.Sprintf("\t\t%s", item.updates()) + } else { + return fmt.Sprintf("%s\t%s\t%s", item.id, item.status, item.updates()) + } +} + +func (m *Menu) renderInteractiveItem(item menuItem, inline bool, index int) string { + pre := bytes.Buffer{} + if index == m.cursor { + pre.WriteString(glyphSelected) + } else { + pre.WriteString(" ") + } + pre.WriteString(" ") + pre.WriteString(item.checkbox()) + pre.WriteString(" ") + pre.WriteString(m.renderItem(item, inline)) + + return pre.String() +} + +func (m *Menu) toggleSelected() { + m.items[m.cursor].checked = !m.items[m.cursor].checked + m.printInteractive() +} + +func (m *Menu) cursorDown() { + m.cursor = (m.cursor + 1) % m.selectable + m.printInteractive() +} + +func (m *Menu) cursorUp() { + m.cursor = (m.cursor + m.selectable - 1) % m.selectable + m.printInteractive() +} + +func (i menuItem) checkbox() string { + switch { + case !i.checkable(): + return " " + case i.checked: + return glyphChecked + default: + return glyphUnchecked + } +} + +func (i menuItem) checkable() bool { + return i.update.Container != "" +} + +func (i menuItem) updates() string { + if i.update.Container != "" { + return fmt.Sprintf("%s: %s -> %s", + i.update.Container, + i.update.Current.String(), + i.update.Target.Tag) + } + return i.error +} diff --git a/update/menu_unix.go b/update/menu_unix.go new file mode 100644 index 000000000..8e622a483 --- /dev/null +++ b/update/menu_unix.go @@ -0,0 +1,57 @@ +// +build !windows + +package update + +import ( + "os" + + "github.com/pkg/term" + "golang.org/x/sys/unix" +) + +func terminalWidth() uint16 { + ws, _ := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) + if ws != nil && ws.Col != 0 { + return ws.Col + } + return 9999 +} + +// See https://github.com/paulrademacher/climenu/blob/master/getchar.go +func getChar() (ascii int, keyCode int, err error) { + t, _ := term.Open("/dev/tty") + term.RawMode(t) + bs := make([]byte, 3) + + var numRead int + numRead, err = t.Read(bs) + if err != nil { + return + } + if numRead == 3 && bs[0] == 27 && bs[1] == 91 { + // Three-character control sequence, beginning with "ESC-[". + + // Since there are no ASCII codes for arrow keys, we use + // Javascript key codes. + if bs[2] == 65 { + // Up + keyCode = 38 + } else if bs[2] == 66 { + // Down + keyCode = 40 + } else if bs[2] == 67 { + // Right + keyCode = 39 + } else if bs[2] == 68 { + // Left + keyCode = 37 + } + } else if numRead == 1 { + ascii = int(bs[0]) + } else { + // Two characters read?? + } + t.Restore() + t.Close() + return +} diff --git a/update/menu_win.go b/update/menu_win.go new file mode 100644 index 000000000..63e72ef76 --- /dev/null +++ b/update/menu_win.go @@ -0,0 +1,13 @@ +// +build windows + +package update + +import "errors" + +func terminalWidth() uint16 { + return 9999 +} + +func getChar() (ascii int, keyCode int, err error) { + return 0, 0, errors.New("Error: Interactive mode is not supported on Windows") +} diff --git a/update/print.go b/update/print.go index ee21f7efe..d01c4e6ca 100644 --- a/update/print.go +++ b/update/print.go @@ -1,11 +1,7 @@ package update import ( - "fmt" "io" - "text/tabwriter" - - "github.com/weaveworks/flux" ) // PrintResults outputs a result set to the `io.Writer` provided, at @@ -14,38 +10,5 @@ import ( // - 1 = include skipped resources, exclude ignored resources // - 0 = exclude skipped and ignored resources func PrintResults(out io.Writer, results Result, verbosity int) { - w := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - fmt.Fprintln(w, "CONTROLLER \tSTATUS \tUPDATES") - for _, serviceID := range results.ServiceIDs() { - result := results[flux.MustParseResourceID(serviceID)] - switch result.Status { - case ReleaseStatusIgnored: - if verbosity < 2 { - continue - } - case ReleaseStatusSkipped: - if verbosity < 1 { - continue - } - } - - var extraLines []string - if result.Error != "" { - extraLines = append(extraLines, result.Error) - } - for _, update := range result.PerContainer { - extraLines = append(extraLines, fmt.Sprintf("%s: %s -> %s", update.Container, update.Current.String(), update.Target.Tag)) - } - - var inline string - if len(extraLines) > 0 { - inline = extraLines[0] - extraLines = extraLines[1:] - } - fmt.Fprintf(w, "%s\t%s\t%s\n", serviceID, result.Status, inline) - for _, lines := range extraLines { - fmt.Fprintf(w, "\t\t%s\n", lines) - } - } - w.Flush() + NewMenu(out, results, verbosity).Print() }