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 }