github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/support-diag.go (about) 1 // Copyright (c) 2015-2023 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 "bytes" 22 "context" 23 gojson "encoding/json" 24 "errors" 25 "flag" 26 "fmt" 27 "io" 28 "os" 29 "path/filepath" 30 "strings" 31 "syscall" 32 "time" 33 34 "github.com/fatih/color" 35 "github.com/klauspost/compress/gzip" 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 const ( 44 anonymizeFlag = "anonymize" 45 anonymizeStandard = "standard" 46 anonymizeStrict = "strict" 47 ) 48 49 var supportDiagFlags = append([]cli.Flag{ 50 HealthDataTypeFlag{ 51 Name: "test", 52 Usage: "choose specific diagnostics to run [" + options.String() + "]", 53 Value: nil, 54 Hidden: true, 55 }, 56 cli.DurationFlag{ 57 Name: "deadline", 58 Usage: "maximum duration diagnostics should be allowed to run", 59 Value: 1 * time.Hour, 60 Hidden: true, 61 }, 62 cli.StringFlag{ 63 Name: anonymizeFlag, 64 Usage: "Data anonymization mode (standard|strict)", 65 Value: anonymizeStandard, 66 }, 67 }, subnetCommonFlags...) 68 69 var supportDiagCmd = cli.Command{ 70 Name: "diag", 71 Aliases: []string{"diagnostics"}, 72 Usage: "upload health data for diagnostics", 73 OnUsageError: onUsageError, 74 Action: mainSupportDiag, 75 Before: setGlobalsFromContext, 76 Flags: supportDiagFlags, 77 CustomHelpTemplate: `NAME: 78 {{.HelpName}} - {{.Usage}} 79 80 USAGE: 81 {{.HelpName}} TARGET 82 83 FLAGS: 84 {{range .VisibleFlags}}{{.}} 85 {{end}} 86 EXAMPLES: 87 1. Upload MinIO diagnostics report for cluster with alias 'myminio' to SUBNET 88 {{.Prompt}} {{.HelpName}} myminio 89 90 2. Generate MinIO diagnostics report for cluster with alias 'myminio', save and upload to SUBNET manually 91 {{.Prompt}} {{.HelpName}} myminio --airgap 92 93 3. Upload MinIO diagnostics report for cluster with alias 'myminio' to SUBNET, with strict anonymization 94 {{.Prompt}} {{.HelpName}} myminio --anonymize=strict 95 `, 96 } 97 98 type supportDiagMessage struct { 99 Status string `json:"status"` 100 } 101 102 // String colorized status message 103 func (s supportDiagMessage) String() string { 104 return console.Colorize(supportSuccessMsgTag, "MinIO diagnostics report was successfully uploaded to SUBNET.") 105 } 106 107 // JSON jsonified status message 108 func (s supportDiagMessage) JSON() string { 109 s.Status = "success" 110 return toJSON(s) 111 } 112 113 // checkSupportDiagSyntax - validate arguments passed by a user 114 func checkSupportDiagSyntax(ctx *cli.Context) { 115 if len(ctx.Args()) == 0 || len(ctx.Args()) > 1 { 116 showCommandHelpAndExit(ctx, 1) // last argument is exit code 117 } 118 119 anon := ctx.String(anonymizeFlag) 120 if anon != anonymizeStandard && anon != anonymizeStrict { 121 fatal(errDummy().Trace(), "Invalid anonymization mode. Valid options are 'standard' or 'strict'.") 122 } 123 } 124 125 // compress and tar MinIO diagnostics output 126 func tarGZ(healthInfo interface{}, version, filename string) error { 127 data, e := TarGZHealthInfo(healthInfo, version) 128 if e != nil { 129 return e 130 } 131 132 e = os.WriteFile(filename, data, 0o666) 133 if e != nil { 134 return e 135 } 136 137 if globalAirgapped { 138 warningMsgBoundary := "*********************************************************************************" 139 warning := warnText(" WARNING!!") 140 warningContents := infoText(` ** THIS FILE MAY CONTAIN SENSITIVE INFORMATION ABOUT YOUR ENVIRONMENT ** 141 ** PLEASE INSPECT CONTENTS BEFORE SHARING IT ON ANY PUBLIC FORUM **`) 142 143 warningMsgHeader := infoText(warningMsgBoundary) 144 warningMsgTrailer := infoText(warningMsgBoundary) 145 console.Printf("%s\n%s\n%s\n%s\n", warningMsgHeader, warning, warningContents, warningMsgTrailer) 146 console.Infoln("MinIO diagnostics report saved at ", filename) 147 } 148 149 return nil 150 } 151 152 // TarGZHealthInfo - compress and tar MinIO diagnostics output 153 func TarGZHealthInfo(healthInfo interface{}, version string) ([]byte, error) { 154 buffer := bytes.NewBuffer(nil) 155 gzWriter := gzip.NewWriter(buffer) 156 157 enc := gojson.NewEncoder(gzWriter) 158 159 header := struct { 160 Version string `json:"version"` 161 }{Version: version} 162 163 if e := enc.Encode(header); e != nil { 164 return nil, e 165 } 166 167 if e := enc.Encode(healthInfo); e != nil { 168 return nil, e 169 } 170 171 if e := gzWriter.Close(); e != nil { 172 return nil, e 173 } 174 175 return buffer.Bytes(), nil 176 } 177 178 func infoText(s string) string { 179 console.SetColor("INFO", color.New(color.FgGreen, color.Bold)) 180 return console.Colorize("INFO", s) 181 } 182 183 func greenText(s string) string { 184 console.SetColor("GREEN", color.New(color.FgGreen)) 185 return console.Colorize("GREEN", s) 186 } 187 188 func warnText(s string) string { 189 console.SetColor("WARN", color.New(color.FgRed, color.Bold)) 190 return console.Colorize("WARN", s) 191 } 192 193 func mainSupportDiag(ctx *cli.Context) error { 194 checkSupportDiagSyntax(ctx) 195 196 // Get the alias parameter from cli 197 aliasedURL := ctx.Args().Get(0) 198 alias, apiKey := initSubnetConnectivity(ctx, aliasedURL, true) 199 if len(apiKey) == 0 { 200 // api key not passed as flag. Check that the cluster is registered. 201 apiKey = validateClusterRegistered(alias, true) 202 } 203 204 // Create a new MinIO Admin Client 205 client := getClient(aliasedURL) 206 207 // Main execution 208 execSupportDiag(ctx, client, alias, apiKey) 209 210 return nil 211 } 212 213 func execSupportDiag(ctx *cli.Context, client *madmin.AdminClient, alias, apiKey string) { 214 var reqURL string 215 var headers map[string]string 216 setSuccessMessageColor() 217 218 filename := fmt.Sprintf("%s-health_%s.json.gz", filepath.Clean(alias), UTCNow().Format("20060102150405")) 219 if !globalAirgapped { 220 // Retrieve subnet credentials (login/license) beforehand as 221 // it can take a long time to fetch the health information 222 uploadURL := SubnetUploadURL("health") 223 reqURL, headers = prepareSubnetUploadURL(uploadURL, alias, apiKey) 224 } 225 226 healthInfo, version, e := fetchServerDiagInfo(ctx, client) 227 fatalIf(probe.NewError(e), "Unable to fetch health information.") 228 229 if globalJSON && globalAirgapped { 230 switch version { 231 case madmin.HealthInfoVersion0: 232 printMsg(healthInfo.(madmin.HealthInfoV0)) 233 case madmin.HealthInfoVersion2: 234 printMsg(healthInfo.(madmin.HealthInfoV2)) 235 case madmin.HealthInfoVersion: 236 printMsg(healthInfo.(madmin.HealthInfo)) 237 } 238 return 239 } 240 241 e = tarGZ(healthInfo, version, filename) 242 fatalIf(probe.NewError(e), "Unable to save MinIO diagnostics report") 243 244 if !globalAirgapped { 245 _, e = (&SubnetFileUploader{ 246 alias: alias, 247 FilePath: filename, 248 ReqURL: reqURL, 249 Headers: headers, 250 DeleteAfterUpload: true, 251 }).UploadFileToSubnet() 252 fatalIf(probe.NewError(e), "Unable to upload MinIO diagnostics report to SUBNET portal") 253 254 printMsg(supportDiagMessage{}) 255 } 256 } 257 258 func fetchServerDiagInfo(ctx *cli.Context, client *madmin.AdminClient) (interface{}, string, error) { 259 opts := GetHealthDataTypeSlice(ctx, "test") 260 if len(*opts) == 0 { 261 opts = &options 262 } 263 264 optsMap := make(map[madmin.HealthDataType]struct{}) 265 for _, opt := range *opts { 266 optsMap[opt] = struct{}{} 267 } 268 269 spinners := []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"} 270 cont, cancel := context.WithCancel(globalContext) 271 defer cancel() 272 273 startSpinner := func(s string) func() { 274 ctx, cancel := context.WithCancel(cont) 275 printText := func(t, sp string, rewind int) { 276 console.RewindLines(rewind) 277 278 dot := infoText(dot) 279 t = fmt.Sprintf("%s ...", t) 280 t = greenText(t) 281 sp = infoText(sp) 282 toPrint := fmt.Sprintf("%s %s %s ", dot, t, sp) 283 console.Printf("%s\n", toPrint) 284 } 285 i := 0 286 sp := func() string { 287 i = i + 1 288 i = i % len(spinners) 289 return spinners[i] 290 } 291 292 done := make(chan bool) 293 doneToggle := false 294 go func() { 295 printText(s, sp(), 0) 296 for { 297 time.Sleep(500 * time.Millisecond) // 2 fps 298 if ctx.Err() != nil { 299 printText(s, check, 1) 300 done <- true 301 return 302 } 303 printText(s, sp(), 1) 304 } 305 }() 306 return func() { 307 cancel() 308 if !doneToggle { 309 <-done 310 os.Stdout.Sync() 311 doneToggle = true 312 } 313 } 314 } 315 316 spinner := func(resource string, opt madmin.HealthDataType) func(bool) bool { 317 var spinStopper func() 318 done := false 319 320 _, ok := optsMap[opt] // check if option is enabled 321 if globalJSON || !ok { 322 return func(bool) bool { 323 return true 324 } 325 } 326 327 return func(cond bool) bool { 328 if done { 329 return done 330 } 331 if spinStopper == nil { 332 spinStopper = startSpinner(resource) 333 } 334 if cond { 335 done = true 336 spinStopper() 337 } 338 return done 339 } 340 } 341 342 admin := spinner("Admin Info", madmin.HealthDataTypeMinioInfo) 343 cpu := spinner("CPU Info", madmin.HealthDataTypeSysCPU) 344 diskHw := spinner("Disk Info", madmin.HealthDataTypeSysDriveHw) 345 osInfo := spinner("OS Info", madmin.HealthDataTypeSysOsInfo) 346 mem := spinner("Mem Info", madmin.HealthDataTypeSysMem) 347 process := spinner("Process Info", madmin.HealthDataTypeSysLoad) 348 config := spinner("Server Config", madmin.HealthDataTypeMinioConfig) 349 syserr := spinner("System Errors", madmin.HealthDataTypeSysErrors) 350 syssrv := spinner("System Services", madmin.HealthDataTypeSysServices) 351 sysconfig := spinner("System Config", madmin.HealthDataTypeSysConfig) 352 353 progressV0 := func(info madmin.HealthInfoV0) { 354 _ = admin(len(info.Minio.Info.Servers) > 0) && 355 cpu(len(info.Sys.CPUInfo) > 0) && 356 diskHw(len(info.Sys.DiskHwInfo) > 0) && 357 osInfo(len(info.Sys.OsInfo) > 0) && 358 mem(len(info.Sys.MemInfo) > 0) && 359 process(len(info.Sys.ProcInfo) > 0) && 360 config(info.Minio.Config != nil) 361 } 362 363 progressV2 := func(info madmin.HealthInfoV2) { 364 _ = cpu(len(info.Sys.CPUInfo) > 0) && 365 diskHw(len(info.Sys.Partitions) > 0) && 366 osInfo(len(info.Sys.OSInfo) > 0) && 367 mem(len(info.Sys.MemInfo) > 0) && 368 process(len(info.Sys.ProcInfo) > 0) && 369 config(info.Minio.Config.Config != nil) && 370 syserr(len(info.Sys.SysErrs) > 0) && 371 syssrv(len(info.Sys.SysServices) > 0) && 372 sysconfig(len(info.Sys.SysConfig) > 0) && 373 admin(len(info.Minio.Info.Servers) > 0) 374 } 375 376 // Fetch info of all servers (cluster or single server) 377 resp, version, e := client.ServerHealthInfo(cont, *opts, ctx.Duration("deadline"), ctx.String(anonymizeFlag)) 378 if e != nil { 379 cancel() 380 return nil, "", e 381 } 382 383 var healthInfo interface{} 384 385 decoder := json.NewDecoder(resp.Body) 386 switch version { 387 case madmin.HealthInfoVersion0: 388 info := madmin.HealthInfoV0{} 389 for { 390 if e = decoder.Decode(&info); e != nil { 391 if errors.Is(e, io.EOF) { 392 e = nil 393 } 394 395 break 396 } 397 398 progressV0(info) 399 } 400 401 // Old minio versions don't return the MinIO info in 402 // response of the healthinfo api. So fetch it separately 403 minioInfo, e := client.ServerInfo(globalContext) 404 if e != nil { 405 info.Minio.Error = e.Error() 406 } else { 407 info.Minio.Info = minioInfo 408 } 409 410 healthInfo = MapHealthInfoToV1(info, nil) 411 version = madmin.HealthInfoVersion1 412 case madmin.HealthInfoVersion2: 413 info := madmin.HealthInfoV2{} 414 for { 415 if e = decoder.Decode(&info); e != nil { 416 if errors.Is(e, io.EOF) { 417 e = nil 418 } 419 420 break 421 } 422 423 progressV2(info) 424 } 425 healthInfo = info 426 case madmin.HealthInfoVersion: 427 healthInfo, e = receiveHealthInfo(decoder) 428 } 429 430 // cancel the context if supportDiagChan has returned. 431 cancel() 432 return healthInfo, version, e 433 } 434 435 // HealthDataTypeSlice is a typed list of health tests 436 type HealthDataTypeSlice []madmin.HealthDataType 437 438 // Set - sets the flag to the given value 439 func (d *HealthDataTypeSlice) Set(value string) error { 440 for _, v := range strings.Split(value, ",") { 441 if supportDiagData, ok := madmin.HealthDataTypesMap[strings.Trim(v, " ")]; ok { 442 *d = append(*d, supportDiagData) 443 } else { 444 return fmt.Errorf("valid options include %s", options.String()) 445 } 446 } 447 return nil 448 } 449 450 // String - returns the string representation of the health datatypes 451 func (d *HealthDataTypeSlice) String() string { 452 val := "" 453 for _, supportDiagData := range *d { 454 formatStr := "%s" 455 if val != "" { 456 formatStr = fmt.Sprintf("%s,%%s", formatStr) 457 } else { 458 formatStr = fmt.Sprintf("%s%%s", formatStr) 459 } 460 val = fmt.Sprintf(formatStr, val, string(supportDiagData)) 461 } 462 return val 463 } 464 465 // Value - returns the value 466 func (d *HealthDataTypeSlice) Value() []madmin.HealthDataType { 467 return *d 468 } 469 470 // Get - returns the value 471 func (d *HealthDataTypeSlice) Get() interface{} { 472 return *d 473 } 474 475 // HealthDataTypeFlag is a typed flag to represent health datatypes 476 type HealthDataTypeFlag struct { 477 Name string 478 Usage string 479 EnvVar string 480 Hidden bool 481 Value *HealthDataTypeSlice 482 } 483 484 // String - returns the string to be shown in the help message 485 func (f HealthDataTypeFlag) String() string { 486 return cli.FlagStringer(f) 487 } 488 489 // GetName - returns the name of the flag 490 func (f HealthDataTypeFlag) GetName() string { 491 return f.Name 492 } 493 494 // GetHealthDataTypeSlice - returns the list of set health tests 495 func GetHealthDataTypeSlice(c *cli.Context, name string) *HealthDataTypeSlice { 496 generic := c.Generic(name) 497 if generic == nil { 498 return nil 499 } 500 return generic.(*HealthDataTypeSlice) 501 } 502 503 // GetGlobalHealthDataTypeSlice - returns the list of set health tests set globally 504 func GetGlobalHealthDataTypeSlice(c *cli.Context, name string) *HealthDataTypeSlice { 505 generic := c.GlobalGeneric(name) 506 if generic == nil { 507 return nil 508 } 509 return generic.(*HealthDataTypeSlice) 510 } 511 512 // Apply - applies the flag 513 func (f HealthDataTypeFlag) Apply(set *flag.FlagSet) { 514 f.ApplyWithError(set) 515 } 516 517 // ApplyWithError - applies with error 518 func (f HealthDataTypeFlag) ApplyWithError(set *flag.FlagSet) error { 519 if f.EnvVar != "" { 520 for _, envVar := range strings.Split(f.EnvVar, ",") { 521 envVar = strings.TrimSpace(envVar) 522 if envVal, ok := syscall.Getenv(envVar); ok { 523 newVal := &HealthDataTypeSlice{} 524 for _, s := range strings.Split(envVal, ",") { 525 s = strings.TrimSpace(s) 526 if e := newVal.Set(s); e != nil { 527 return fmt.Errorf("could not parse %s as health datatype value for flag %s: %s", envVal, f.Name, e) 528 } 529 } 530 f.Value = newVal 531 break 532 } 533 } 534 } 535 536 for _, name := range strings.Split(f.Name, ",") { 537 name = strings.Trim(name, " ") 538 if f.Value == nil { 539 f.Value = &HealthDataTypeSlice{} 540 } 541 set.Var(f.Value, name, f.Usage) 542 } 543 return nil 544 } 545 546 var options = HealthDataTypeSlice(madmin.HealthDataTypesList)