github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/replicate-backlog.go (about)

     1  // Copyright (c) 2022 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package cmd
    19  
    20  import (
    21  	"context"
    22  	"fmt"
    23  	"path"
    24  	"path/filepath"
    25  	"strconv"
    26  	"strings"
    27  
    28  	"github.com/charmbracelet/bubbles/help"
    29  	"github.com/charmbracelet/bubbles/key"
    30  	"github.com/charmbracelet/bubbles/spinner"
    31  	"github.com/charmbracelet/bubbles/table"
    32  	"github.com/fatih/color"
    33  
    34  	tea "github.com/charmbracelet/bubbletea"
    35  	"github.com/charmbracelet/lipgloss"
    36  	"github.com/minio/cli"
    37  	json "github.com/minio/colorjson"
    38  	"github.com/minio/madmin-go/v3"
    39  	"github.com/minio/mc/pkg/probe"
    40  	"github.com/minio/pkg/v2/console"
    41  )
    42  
    43  var replicateBacklogFlags = []cli.Flag{
    44  	cli.StringFlag{
    45  		Name:  "arn",
    46  		Usage: "unique role ARN",
    47  	},
    48  	cli.BoolFlag{
    49  		Name:  "verbose,v",
    50  		Usage: "include replicated versions",
    51  	},
    52  	cli.StringFlag{
    53  		Name:  "nodes,n",
    54  		Usage: "show most recent failures for one or more nodes. Valid values are 'all', or node name",
    55  		Value: "all",
    56  	},
    57  	cli.BoolFlag{
    58  		Name:  "full,a",
    59  		Usage: "list and show all replication failures for bucket",
    60  	},
    61  }
    62  
    63  var replicateBacklogCmd = cli.Command{
    64  	Name:          "backlog",
    65  	Aliases:       []string{"diff"},
    66  	HiddenAliases: true,
    67  	Usage:         "show unreplicated object versions",
    68  	Action:        mainReplicateBacklog,
    69  	OnUsageError:  onUsageError,
    70  	Before:        setGlobalsFromContext,
    71  	Flags:         append(globalFlags, replicateBacklogFlags...),
    72  	CustomHelpTemplate: `NAME:
    73    {{.HelpName}} - {{.Usage}}
    74  
    75  USAGE:
    76    {{.HelpName}} TARGET
    77  
    78  FLAGS:
    79    {{range .VisibleFlags}}{{.}}
    80    {{end}}
    81  EXAMPLES:
    82    1. Show most recent replication failures on "myminio" alias for objects in bucket "mybucket"
    83       {{.Prompt}} {{.HelpName}} myminio/mybucket
    84  
    85    2. Show all unreplicated objects on "myminio" alias for objects in prefix "path/to/prefix" of "mybucket" for all targets.
    86       This will perform full listing of all objects in the prefix to find unreplicated objects.
    87       {{.Prompt}} {{.HelpName}} myminio/mybucket/path/to/prefix --full
    88  `,
    89  }
    90  
    91  // checkReplicateBacklogSyntax - validate all the passed arguments
    92  func checkReplicateBacklogSyntax(ctx *cli.Context) {
    93  	if len(ctx.Args()) != 1 {
    94  		showCommandHelpAndExit(ctx, 1) // last argument is exit code
    95  	}
    96  }
    97  
    98  type replicateMRFMessage struct {
    99  	Op     string `json:"op"`
   100  	Status string `json:"status"`
   101  	madmin.ReplicationMRF
   102  }
   103  
   104  func (m replicateMRFMessage) JSON() string {
   105  	m.Status = "success"
   106  	jsonMessageBytes, e := json.MarshalIndent(m, "", " ")
   107  	fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
   108  	return string(jsonMessageBytes)
   109  }
   110  
   111  func (m replicateMRFMessage) String() string {
   112  	return console.Colorize("", newPrettyTable(" | ",
   113  		Field{getNodeTheme(m.ReplicationMRF.NodeName), len(m.ReplicationMRF.NodeName) + 3},
   114  		Field{"Count", 7},
   115  		Field{"Object", -1},
   116  	).buildRow(m.ReplicationMRF.NodeName, fmt.Sprintf("Retry=%d", m.ReplicationMRF.RetryCount), fmt.Sprintf("%s (%s)", m.ReplicationMRF.Object, m.ReplicationMRF.VersionID)))
   117  }
   118  
   119  type replicateBacklogMessage struct {
   120  	Op       string                `json:"op"`
   121  	Diff     madmin.DiffInfo       `json:"diff,omitempty"`
   122  	MRF      madmin.ReplicationMRF `json:"mrf,omitempty"`
   123  	OpStatus string                `json:"opStatus"`
   124  	arn      string                `json:"-"`
   125  	verbose  bool                  `json:"-"`
   126  }
   127  
   128  func (r replicateBacklogMessage) JSON() string {
   129  	var e error
   130  	var jsonMessageBytes []byte
   131  	switch r.Op {
   132  	case "diff":
   133  		jsonMessageBytes, e = json.MarshalIndent(r.Diff, "", " ")
   134  
   135  	case "mrf":
   136  		jsonMessageBytes, e = json.MarshalIndent(r.MRF, "", " ")
   137  	}
   138  	fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
   139  	return string(jsonMessageBytes)
   140  }
   141  
   142  func (r replicateBacklogMessage) toRow() (row table.Row) {
   143  	switch r.Op {
   144  	case "diff":
   145  		return r.toDiffRow()
   146  	case "mrf":
   147  		return r.toMRFRow()
   148  	}
   149  	return
   150  }
   151  
   152  func (r replicateBacklogMessage) toDiffRow() (row table.Row) {
   153  	d := r.Diff
   154  	if d.Object == "" {
   155  		return
   156  	}
   157  	op := ""
   158  	if d.VersionID != "" {
   159  		switch d.IsDeleteMarker {
   160  		case true:
   161  			op = "DEL"
   162  		default:
   163  			op = "PUT"
   164  		}
   165  	}
   166  	st := r.replStatus()
   167  	replTimeStamp := d.ReplicationTimestamp.Format(printDate)
   168  	switch {
   169  	case st == "PENDING":
   170  		replTimeStamp = ""
   171  	case op == "DEL":
   172  		replTimeStamp = ""
   173  	}
   174  	return table.Row{
   175  		replTimeStamp, d.LastModified.Format(printDate), st, d.VersionID, op, d.Object,
   176  	}
   177  }
   178  
   179  func (r replicateBacklogMessage) toMRFRow() (row table.Row) {
   180  	d := r.MRF
   181  	if d.Object == "" {
   182  		return
   183  	}
   184  	return table.Row{
   185  		d.NodeName, d.VersionID, strconv.Itoa(d.RetryCount), path.Join(d.Bucket, d.Object),
   186  	}
   187  }
   188  
   189  func (r *replicateBacklogMessage) replStatus() string {
   190  	var st string
   191  	d := r.Diff
   192  	if r.arn == "" { // report overall replication status
   193  		if d.DeleteReplicationStatus != "" {
   194  			st = d.DeleteReplicationStatus
   195  		} else {
   196  			st = d.ReplicationStatus
   197  		}
   198  	} else { // report target replication diff
   199  		for arn, t := range d.Targets {
   200  			if arn != r.arn {
   201  				continue
   202  			}
   203  			if t.DeleteReplicationStatus != "" {
   204  				st = t.DeleteReplicationStatus
   205  			} else {
   206  				st = t.ReplicationStatus
   207  			}
   208  		}
   209  		if len(d.Targets) == 0 {
   210  			st = ""
   211  		}
   212  	}
   213  	return st
   214  }
   215  
   216  type replicateBacklogUI struct {
   217  	spinner  spinner.Model
   218  	sub      interface{}
   219  	diffCh   chan madmin.DiffInfo
   220  	mrfCh    chan madmin.ReplicationMRF
   221  	arn      string
   222  	op       string
   223  	quitting bool
   224  	table    table.Model
   225  	rows     []table.Row
   226  	help     help.Model
   227  	keymap   keyMap
   228  	count    int
   229  }
   230  type keyMap struct {
   231  	quit  key.Binding
   232  	up    key.Binding
   233  	down  key.Binding
   234  	enter key.Binding
   235  }
   236  
   237  func newKeyMap() keyMap {
   238  	return keyMap{
   239  		up: key.NewBinding(
   240  			key.WithKeys("k", "up", "left", "shift+tab"),
   241  			key.WithHelp("↑/k", "Move up"),
   242  		),
   243  		down: key.NewBinding(
   244  			key.WithKeys("j", "down", "right", "tab"),
   245  			key.WithHelp("↓/j", "Move down"),
   246  		),
   247  		enter: key.NewBinding(
   248  			key.WithKeys("enter", " "),
   249  			key.WithHelp("enter/spacebar", ""),
   250  		),
   251  		quit: key.NewBinding(
   252  			key.WithKeys("ctrl+c", "q"),
   253  			key.WithHelp("q", "quit"),
   254  		),
   255  	}
   256  }
   257  
   258  func initReplicateBacklogUI(arn, op string, diffCh interface{}) *replicateBacklogUI {
   259  	s := spinner.New()
   260  	s.Spinner = spinner.Points
   261  	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
   262  	columns := getBacklogHeader(op)
   263  
   264  	t := table.New(
   265  		table.WithColumns(columns),
   266  		table.WithFocused(true),
   267  		table.WithHeight(7),
   268  	)
   269  
   270  	ts := getBacklogStyles()
   271  	t.SetStyles(ts)
   272  
   273  	ui := &replicateBacklogUI{
   274  		spinner: s,
   275  		sub:     diffCh,
   276  		op:      op,
   277  		arn:     arn,
   278  		table:   t,
   279  		help:    help.New(),
   280  		keymap:  newKeyMap(),
   281  	}
   282  	if ch, ok := diffCh.(chan madmin.DiffInfo); ok {
   283  		ui.diffCh = ch
   284  	}
   285  	if ch, ok := diffCh.(chan madmin.ReplicationMRF); ok {
   286  		ui.mrfCh = ch
   287  	}
   288  	return ui
   289  }
   290  
   291  func (m *replicateBacklogUI) Init() tea.Cmd {
   292  	return tea.Batch(
   293  		m.spinner.Tick,
   294  		waitForActivity(m.sub, m.op), // wait for activity
   295  	)
   296  }
   297  
   298  const rowLimit = 10000
   299  
   300  // A command that waits for the activity on a channel.
   301  func waitForActivity(sub interface{}, op string) tea.Cmd {
   302  	return func() tea.Msg {
   303  		switch op {
   304  		case "diff":
   305  			msg := <-sub.(<-chan madmin.DiffInfo)
   306  			return msg
   307  		case "mrf":
   308  			msg := <-sub.(<-chan madmin.ReplicationMRF)
   309  			return msg
   310  		}
   311  		return "unexpected message"
   312  	}
   313  }
   314  
   315  func getBacklogHeader(op string) []table.Column {
   316  	switch op {
   317  	case "diff":
   318  		return getBacklogDiffHeader()
   319  	case "mrf":
   320  		return getBacklogMRFHeader()
   321  	}
   322  	return nil
   323  }
   324  
   325  func getBacklogDiffHeader() []table.Column {
   326  	return []table.Column{
   327  		{Title: "Attempted At", Width: 23},
   328  		{Title: "Created", Width: 23},
   329  		{Title: "Status", Width: 9},
   330  		{Title: "VersionID", Width: 36},
   331  		{Title: "Op", Width: 3},
   332  		{Title: "Object", Width: 60},
   333  	}
   334  }
   335  
   336  func getBacklogMRFHeader() []table.Column {
   337  	return []table.Column{
   338  		{Title: "Node", Width: 40},
   339  		{Title: "VersionID", Width: 36},
   340  		{Title: "Retry", Width: 5},
   341  		{Title: "Object", Width: 60},
   342  	}
   343  }
   344  
   345  func getBacklogStyles() table.Styles {
   346  	ts := table.DefaultStyles()
   347  	ts.Header = ts.Header.
   348  		BorderStyle(lipgloss.NormalBorder()).
   349  		BorderForeground(lipgloss.Color("240")).
   350  		BorderBottom(true).
   351  		Bold(false)
   352  	ts.Selected = ts.Selected.
   353  		Foreground(lipgloss.Color("229")).
   354  		Background(lipgloss.Color("300")).
   355  		Bold(false)
   356  	return ts
   357  }
   358  
   359  func (m *replicateBacklogUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   360  	var cmd tea.Cmd
   361  	switch msg := msg.(type) {
   362  	case tea.KeyMsg:
   363  		switch msg.String() {
   364  		case "esc":
   365  			if m.table.Focused() {
   366  				m.table.Blur()
   367  			} else {
   368  				m.table.Focus()
   369  			}
   370  		case "ctrl+c", "q":
   371  			m.quitting = true
   372  			return m, tea.Quit
   373  		case "enter":
   374  			columns := getBacklogHeader(m.op)
   375  			ts := getBacklogStyles()
   376  			m.table = table.New(
   377  				table.WithColumns(columns),
   378  				table.WithRows(m.rows),
   379  				table.WithFocused(true),
   380  				table.WithHeight(10),
   381  			)
   382  			m.table.SetStyles(ts)
   383  		default:
   384  		}
   385  	case madmin.DiffInfo:
   386  		if msg.Object != "" {
   387  			m.count++
   388  			if m.count <= rowLimit { // don't buffer more than 10k entries
   389  				rdif := replicateBacklogMessage{
   390  					Op:   "diff",
   391  					Diff: msg,
   392  					arn:  m.arn,
   393  				}
   394  				m.rows = append(m.rows, rdif.toRow())
   395  			}
   396  			return m, waitForActivity(m.sub, m.op)
   397  		}
   398  		m.quitting = true
   399  		columns := getBacklogDiffHeader()
   400  		ts := getBacklogStyles()
   401  		m.table = table.New(
   402  			table.WithColumns(columns),
   403  			table.WithRows(m.rows),
   404  			table.WithFocused(true),
   405  			table.WithHeight(10),
   406  		)
   407  		m.table.SetStyles(ts)
   408  		return m, nil
   409  	case madmin.ReplicationMRF:
   410  		if msg.Object != "" {
   411  			m.count++
   412  			if m.count <= rowLimit { // don't buffer more than 10k entries
   413  				rdif := replicateBacklogMessage{
   414  					Op:  "mrf",
   415  					MRF: msg,
   416  					arn: m.arn,
   417  				}
   418  				m.rows = append(m.rows, rdif.toRow())
   419  			}
   420  			return m, waitForActivity(m.sub, m.op)
   421  		}
   422  		m.quitting = true
   423  		columns := getBacklogMRFHeader()
   424  		ts := getBacklogStyles()
   425  		m.table = table.New(
   426  			table.WithColumns(columns),
   427  			table.WithRows(m.rows),
   428  			table.WithFocused(true),
   429  			table.WithHeight(10),
   430  		)
   431  		m.table.SetStyles(ts)
   432  		return m, nil
   433  	case spinner.TickMsg:
   434  		var cmd tea.Cmd
   435  		if !m.quitting {
   436  			m.spinner, cmd = m.spinner.Update(msg)
   437  			return m, cmd
   438  		}
   439  	}
   440  
   441  	m.table, cmd = m.table.Update(msg)
   442  
   443  	return m, cmd
   444  }
   445  
   446  var baseStyle = lipgloss.NewStyle().
   447  	BorderStyle(lipgloss.NormalBorder()).
   448  	BorderForeground(lipgloss.Color("240"))
   449  
   450  var descStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
   451  	Light: "#B2B2B2",
   452  	Dark:  "#4A4A4A",
   453  })
   454  
   455  var (
   456  	subtle  = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
   457  	special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
   458  
   459  	divider = lipgloss.NewStyle().
   460  		SetString("•").
   461  		Padding(0, 1).
   462  		Foreground(subtle).
   463  		String()
   464  
   465  	advisory  = lipgloss.NewStyle().Foreground(special).Render
   466  	infoStyle = lipgloss.NewStyle().
   467  			BorderStyle(lipgloss.NormalBorder()).
   468  			BorderTop(true).
   469  			BorderForeground(subtle)
   470  )
   471  
   472  func (m *replicateBacklogUI) helpView() string {
   473  	return "\n" + m.help.ShortHelpView([]key.Binding{
   474  		m.keymap.enter,
   475  		m.keymap.down,
   476  		m.keymap.up,
   477  		m.keymap.quit,
   478  	})
   479  }
   480  
   481  func (m *replicateBacklogUI) View() string {
   482  	var sb strings.Builder
   483  	if !m.quitting {
   484  		sb.WriteString(fmt.Sprintf("%s\n", m.spinner.View()))
   485  	}
   486  
   487  	if m.count > 0 {
   488  		advisoryStr := ""
   489  		if m.count > rowLimit {
   490  			advisoryStr = "[ use --json flag for full listing]"
   491  		}
   492  		desc := lipgloss.JoinVertical(lipgloss.Left,
   493  			descStyle.Render("Unreplicated versions summary"),
   494  			infoStyle.Render(fmt.Sprintf("Total Unreplicated: %d", m.count)+divider+advisory(advisoryStr+"\n")))
   495  		row := lipgloss.JoinHorizontal(lipgloss.Top, desc)
   496  		sb.WriteString(row + "\n\n")
   497  		sb.WriteString(baseStyle.Render(m.table.View()))
   498  	}
   499  	sb.WriteString(m.helpView())
   500  
   501  	return sb.String()
   502  }
   503  
   504  func mainReplicateBacklog(cliCtx *cli.Context) error {
   505  	checkReplicateBacklogSyntax(cliCtx)
   506  	console.SetColor("diff-msg", color.New(color.FgHiCyan, color.Bold))
   507  	// Get the alias parameter from cli
   508  	args := cliCtx.Args()
   509  	aliasedURL := args.Get(0)
   510  	aliasedURL = filepath.ToSlash(aliasedURL)
   511  	splits := splitStr(aliasedURL, "/", 3)
   512  	bucket, prefix := splits[1], splits[2]
   513  	if bucket == "" {
   514  		fatalIf(errInvalidArgument(), "bucket not specified in `"+aliasedURL+"`.")
   515  	}
   516  	ctx, cancel := context.WithCancel(globalContext)
   517  	defer cancel()
   518  
   519  	// Create a new MinIO Admin Client
   520  	client, cerr := newAdminClient(aliasedURL)
   521  	fatalIf(cerr, "Unable to initialize admin connection.")
   522  	if !cliCtx.IsSet("full") {
   523  		mrfCh := client.BucketReplicationMRF(ctx, bucket, cliCtx.String("nodes"))
   524  		if globalJSON {
   525  			for mrf := range mrfCh {
   526  				if mrf.Err != "" {
   527  					fatalIf(probe.NewError(fmt.Errorf("%s", mrf.Err)), "Unable to fetch replication backlog.")
   528  				}
   529  				printMsg(replicateMRFMessage{
   530  					Op:             "mrf",
   531  					Status:         "success",
   532  					ReplicationMRF: mrf,
   533  				})
   534  			}
   535  			return nil
   536  		}
   537  		ui := tea.NewProgram(initReplicateBacklogUI("", "mrf", mrfCh))
   538  		if _, e := ui.Run(); e != nil {
   539  			cancel()
   540  			fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch replication backlog")
   541  		}
   542  		return nil
   543  	}
   544  
   545  	verbose := cliCtx.Bool("verbose")
   546  	arn := cliCtx.String("arn")
   547  	diffCh := client.BucketReplicationDiff(ctx, bucket, madmin.ReplDiffOpts{
   548  		Verbose: verbose,
   549  		ARN:     arn,
   550  		Prefix:  prefix,
   551  	})
   552  	if globalJSON {
   553  		for di := range diffCh {
   554  			console.Println(replicateBacklogMessage{
   555  				Op:      "diff",
   556  				Diff:    di,
   557  				arn:     arn,
   558  				verbose: verbose,
   559  			}.JSON())
   560  		}
   561  		return nil
   562  	}
   563  
   564  	ui := tea.NewProgram(initReplicateBacklogUI(arn, "diff", diffCh))
   565  	if _, e := ui.Run(); e != nil {
   566  		cancel()
   567  		fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch replication backlog")
   568  	}
   569  
   570  	return nil
   571  }