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)