Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Add flux release --interactive
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rndstr committed Jul 23, 2018
1 parent a1672be commit 13b7c07
Show file tree
Hide file tree
Showing 7 changed files with 399 additions and 41 deletions.
2 changes: 1 addition & 1 deletion Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
39 changes: 37 additions & 2 deletions cmd/fluxctl/release_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -19,6 +21,7 @@ type controllerReleaseOpts struct {
allImages bool
exclude []string
dryRun bool
interactive bool
outputOpts
cause update.Cause

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand All @@ -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")
Expand All @@ -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
}
286 changes: 286 additions & 0 deletions update/menu.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 13b7c07

Please sign in to comment.