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

     1  // Copyright (c) 2015-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  	"crypto"
    22  	"crypto/tls"
    23  	"encoding/hex"
    24  	"errors"
    25  	"fmt"
    26  	"io"
    27  	"net"
    28  	"net/http"
    29  	"net/url"
    30  	"os"
    31  	"path"
    32  	"path/filepath"
    33  	"runtime"
    34  	"strings"
    35  	"time"
    36  
    37  	_ "crypto/sha256" // needed for selfupdate hashers
    38  
    39  	"github.com/fatih/color"
    40  	"github.com/mattn/go-isatty"
    41  	"github.com/minio/cli"
    42  	json "github.com/minio/colorjson"
    43  	"github.com/minio/mc/pkg/probe"
    44  	"github.com/minio/pkg/v2/env"
    45  	"github.com/minio/selfupdate"
    46  )
    47  
    48  // Check for new software updates.
    49  var updateCmd = cli.Command{
    50  	Name:         "update",
    51  	Usage:        "update mc to latest release",
    52  	Action:       mainUpdate,
    53  	OnUsageError: onUsageError,
    54  	Flags: []cli.Flag{
    55  		cli.BoolFlag{
    56  			Name:  "json",
    57  			Usage: "enable JSON lines formatted output",
    58  		},
    59  	},
    60  	CustomHelpTemplate: `Name:
    61     {{.HelpName}} - {{.Usage}}
    62  
    63  USAGE:
    64     {{.HelpName}}{{if .VisibleFlags}} [FLAGS]{{end}}
    65  {{if .VisibleFlags}}
    66  FLAGS:
    67    {{range .VisibleFlags}}{{.}}
    68    {{end}}{{end}}
    69  EXIT STATUS:
    70    0 - you are already running the most recent version
    71    1 - new update was applied successfully
    72   -1 - error in getting update information
    73  
    74  EXAMPLES:
    75    1. Check and update mc:
    76       {{.Prompt}} {{.HelpName}}
    77  `,
    78  }
    79  
    80  const (
    81  	mcReleaseTagTimeLayout = "2006-01-02T15-04-05Z"
    82  	mcOSARCH               = runtime.GOOS + "-" + runtime.GOARCH
    83  	mcReleaseURL           = "https://dl.min.io/client/mc/release/" + mcOSARCH + "/"
    84  
    85  	envMinisignPubKey = "MC_UPDATE_MINISIGN_PUBKEY"
    86  )
    87  
    88  // For windows our files have .exe additionally.
    89  var mcReleaseWindowsInfoURL = mcReleaseURL + "mc.exe.sha256sum"
    90  
    91  // mcVersionToReleaseTime - parses a standard official release
    92  // mc --version string.
    93  //
    94  // An official binary's version string is the release time formatted
    95  // with RFC3339 (in UTC) - e.g. `2017-09-29T19:16:56Z`
    96  func mcVersionToReleaseTime(version string) (releaseTime time.Time, err *probe.Error) {
    97  	var e error
    98  	releaseTime, e = time.Parse(time.RFC3339, version)
    99  	return releaseTime, probe.NewError(e)
   100  }
   101  
   102  // releaseTagToReleaseTime - releaseTag to releaseTime
   103  func releaseTagToReleaseTime(releaseTag string) (releaseTime time.Time, err *probe.Error) {
   104  	fields := strings.Split(releaseTag, ".")
   105  	if len(fields) < 2 || len(fields) > 4 {
   106  		return releaseTime, probe.NewError(fmt.Errorf("%s is not a valid release tag", releaseTag))
   107  	}
   108  	if fields[0] != "RELEASE" {
   109  		return releaseTime, probe.NewError(fmt.Errorf("%s is not a valid release tag", releaseTag))
   110  	}
   111  	var e error
   112  	releaseTime, e = time.Parse(mcReleaseTagTimeLayout, fields[1])
   113  	return releaseTime, probe.NewError(e)
   114  }
   115  
   116  // getModTime - get the file modification time of `path`
   117  func getModTime(path string) (t time.Time, err *probe.Error) {
   118  	var e error
   119  	path, e = filepath.EvalSymlinks(path)
   120  	if e != nil {
   121  		return t, probe.NewError(fmt.Errorf("Unable to get absolute path of %s. %w", path, e))
   122  	}
   123  
   124  	// Version is mc non-standard, we will use mc binary's
   125  	// ModTime as release time.
   126  	var fi os.FileInfo
   127  	fi, e = os.Stat(path)
   128  	if e != nil {
   129  		return t, probe.NewError(fmt.Errorf("Unable to get ModTime of %s. %w", path, e))
   130  	}
   131  
   132  	// Return the ModTime
   133  	return fi.ModTime().UTC(), nil
   134  }
   135  
   136  // GetCurrentReleaseTime - returns this process's release time.  If it
   137  // is official mc --version, parsed version is returned else mc
   138  // binary's mod time is returned.
   139  func GetCurrentReleaseTime() (releaseTime time.Time, err *probe.Error) {
   140  	if releaseTime, err = mcVersionToReleaseTime(Version); err == nil {
   141  		return releaseTime, nil
   142  	}
   143  
   144  	// Looks like version is mc non-standard, we use mc
   145  	// binary's ModTime as release time:
   146  	path, e := os.Executable()
   147  	if e != nil {
   148  		return releaseTime, probe.NewError(e)
   149  	}
   150  	return getModTime(path)
   151  }
   152  
   153  // IsDocker - returns if the environment mc is running in docker or
   154  // not. The check is a simple file existence check.
   155  //
   156  // https://github.com/moby/moby/blob/master/daemon/initlayer/setup_unix.go#L25
   157  //
   158  //	"/.dockerenv":      "file",
   159  func IsDocker() bool {
   160  	_, e := os.Stat("/.dockerenv")
   161  	if os.IsNotExist(e) {
   162  		return false
   163  	}
   164  
   165  	return e == nil
   166  }
   167  
   168  // IsDCOS returns true if mc is running in DCOS.
   169  func IsDCOS() bool {
   170  	// http://mesos.apache.org/documentation/latest/docker-containerizer/
   171  	// Mesos docker containerizer sets this value
   172  	return os.Getenv("MESOS_CONTAINER_NAME") != ""
   173  }
   174  
   175  // IsKubernetes returns true if MinIO is running in kubernetes.
   176  func IsKubernetes() bool {
   177  	// Kubernetes env used to validate if we are
   178  	// indeed running inside a kubernetes pod
   179  	// is KUBERNETES_SERVICE_HOST but in future
   180  	// we might need to enhance this.
   181  	return os.Getenv("KUBERNETES_SERVICE_HOST") != ""
   182  }
   183  
   184  // IsSourceBuild - returns if this binary is a non-official build from
   185  // source code.
   186  func IsSourceBuild() bool {
   187  	_, err := mcVersionToReleaseTime(Version)
   188  	return err != nil
   189  }
   190  
   191  // DO NOT CHANGE USER AGENT STYLE.
   192  // The style should be
   193  //
   194  //	mc (<OS>; <ARCH>[; dcos][; kubernetes][; docker][; source]) mc/<VERSION> mc/<RELEASE-TAG> mc/<COMMIT-ID>
   195  //
   196  // Any change here should be discussed by opening an issue at
   197  // https://github.com/minio/mc/issues.
   198  func getUserAgent() string {
   199  	userAgentParts := []string{}
   200  	// Helper function to concisely append a pair of strings to a
   201  	// the user-agent slice.
   202  	uaAppend := func(p, q string) {
   203  		userAgentParts = append(userAgentParts, p, q)
   204  	}
   205  
   206  	uaAppend("mc (", runtime.GOOS)
   207  	uaAppend("; ", runtime.GOARCH)
   208  	if IsDCOS() {
   209  		uaAppend("; ", "dcos")
   210  	}
   211  	if IsKubernetes() {
   212  		uaAppend("; ", "kubernetes")
   213  	}
   214  	if IsDocker() {
   215  		uaAppend("; ", "docker")
   216  	}
   217  	if IsSourceBuild() {
   218  		uaAppend("; ", "source")
   219  	}
   220  
   221  	uaAppend(") mc/", Version)
   222  	uaAppend(" mc/", ReleaseTag)
   223  	uaAppend(" mc/", CommitID)
   224  
   225  	return strings.Join(userAgentParts, "")
   226  }
   227  
   228  func downloadReleaseURL(releaseChecksumURL string, timeout time.Duration) (content string, err *probe.Error) {
   229  	req, e := http.NewRequest("GET", releaseChecksumURL, nil)
   230  	if e != nil {
   231  		return content, probe.NewError(e)
   232  	}
   233  	req.Header.Set("User-Agent", getUserAgent())
   234  
   235  	resp, e := httpClient(timeout).Do(req)
   236  	if e != nil {
   237  		return content, probe.NewError(e)
   238  	}
   239  	if resp == nil {
   240  		return content, probe.NewError(fmt.Errorf("No response from server to download URL %s", releaseChecksumURL))
   241  	}
   242  	defer resp.Body.Close()
   243  
   244  	if resp.StatusCode != http.StatusOK {
   245  		return content, probe.NewError(fmt.Errorf("Error downloading URL %s. Response: %v", releaseChecksumURL, resp.Status))
   246  	}
   247  	contentBytes, e := io.ReadAll(resp.Body)
   248  	if e != nil {
   249  		return content, probe.NewError(fmt.Errorf("Error reading response. %s", err))
   250  	}
   251  
   252  	return string(contentBytes), nil
   253  }
   254  
   255  // DownloadReleaseData - downloads release data from mc official server.
   256  func DownloadReleaseData(customReleaseURL string, timeout time.Duration) (data string, err *probe.Error) {
   257  	releaseURL := mcReleaseInfoURL
   258  	if runtime.GOOS == "windows" {
   259  		releaseURL = mcReleaseWindowsInfoURL
   260  	}
   261  	if customReleaseURL != "" {
   262  		releaseURL = customReleaseURL
   263  	}
   264  	return func() (data string, err *probe.Error) {
   265  		data, err = downloadReleaseURL(releaseURL, timeout)
   266  		if err == nil {
   267  			return data, nil
   268  		}
   269  		return data, err.Trace(releaseURL)
   270  	}()
   271  }
   272  
   273  // parseReleaseData - parses release info file content fetched from
   274  // official mc download server.
   275  //
   276  // The expected format is a single line with two words like:
   277  //
   278  // fbe246edbd382902db9a4035df7dce8cb441357d mc.RELEASE.2016-10-07T01-16-39Z
   279  //
   280  // The second word must be `mc.` appended to a standard release tag.
   281  func parseReleaseData(data string) (sha256Hex string, releaseTime time.Time, releaseTag string, err *probe.Error) {
   282  	fields := strings.Fields(data)
   283  	if len(fields) != 2 {
   284  		return sha256Hex, releaseTime, "", probe.NewError(fmt.Errorf("Unknown release data `%s`", data))
   285  	}
   286  
   287  	sha256Hex = fields[0]
   288  	releaseInfo := fields[1]
   289  
   290  	fields = strings.SplitN(releaseInfo, ".", 2)
   291  	if len(fields) != 2 {
   292  		return sha256Hex, releaseTime, "", probe.NewError(fmt.Errorf("Unknown release information `%s`", releaseInfo))
   293  	}
   294  	if fields[0] != "mc" {
   295  		return sha256Hex, releaseTime, "", probe.NewError(fmt.Errorf("Unknown release `%s`", releaseInfo))
   296  	}
   297  
   298  	releaseTime, err = releaseTagToReleaseTime(fields[1])
   299  	if err != nil {
   300  		return sha256Hex, releaseTime, fields[1], err.Trace(fields...)
   301  	}
   302  
   303  	return sha256Hex, releaseTime, fields[1], nil
   304  }
   305  
   306  func getLatestReleaseTime(customReleaseURL string, timeout time.Duration) (sha256Hex string, releaseTime time.Time, releaseTag string, err *probe.Error) {
   307  	data, err := DownloadReleaseData(customReleaseURL, timeout)
   308  	if err != nil {
   309  		return sha256Hex, releaseTime, releaseTag, err.Trace()
   310  	}
   311  
   312  	return parseReleaseData(data)
   313  }
   314  
   315  func getDownloadURL(customReleaseURL, releaseTag string) (downloadURL string) {
   316  	// Check if we are docker environment, return docker update command
   317  	if IsDocker() {
   318  		// Construct release tag name.
   319  		return fmt.Sprintf("docker pull minio/mc:%s", releaseTag)
   320  	}
   321  
   322  	if customReleaseURL == "" {
   323  		return mcReleaseURL + "archive/mc." + releaseTag
   324  	}
   325  
   326  	u, e := url.Parse(customReleaseURL)
   327  	if e != nil {
   328  		return mcReleaseURL + "archive/mc." + releaseTag
   329  	}
   330  
   331  	u.Path = path.Dir(u.Path) + "/mc." + releaseTag
   332  	return u.String()
   333  }
   334  
   335  func getUpdateInfo(customReleaseURL string, timeout time.Duration) (updateMsg, sha256Hex string, currentReleaseTime, latestReleaseTime time.Time, releaseTag string, err *probe.Error) {
   336  	currentReleaseTime, err = GetCurrentReleaseTime()
   337  	if err != nil {
   338  		return updateMsg, sha256Hex, currentReleaseTime, latestReleaseTime, releaseTag, err.Trace()
   339  	}
   340  
   341  	sha256Hex, latestReleaseTime, releaseTag, err = getLatestReleaseTime(customReleaseURL, timeout)
   342  	if err != nil {
   343  		return updateMsg, sha256Hex, currentReleaseTime, latestReleaseTime, releaseTag, err.Trace()
   344  	}
   345  
   346  	var older time.Duration
   347  	var downloadURL string
   348  	if latestReleaseTime.After(currentReleaseTime) {
   349  		older = latestReleaseTime.Sub(currentReleaseTime)
   350  		downloadURL = getDownloadURL(customReleaseURL, releaseTag)
   351  	}
   352  
   353  	return prepareUpdateMessage(downloadURL, older), sha256Hex, currentReleaseTime, latestReleaseTime, releaseTag, nil
   354  }
   355  
   356  var (
   357  	// Check if we stderr, stdout are dumb terminals, we do not apply
   358  	// ansi coloring on dumb terminals.
   359  	isTerminal = func() bool {
   360  		return isatty.IsTerminal(os.Stdout.Fd()) && isatty.IsTerminal(os.Stderr.Fd())
   361  	}
   362  
   363  	colorCyanBold = func() func(a ...interface{}) string {
   364  		if isTerminal() {
   365  			color.New(color.FgCyan, color.Bold).SprintFunc()
   366  		}
   367  		return fmt.Sprint
   368  	}()
   369  
   370  	colorYellowBold = func() func(format string, a ...interface{}) string {
   371  		if isTerminal() {
   372  			return color.New(color.FgYellow, color.Bold).SprintfFunc()
   373  		}
   374  		return fmt.Sprintf
   375  	}()
   376  
   377  	colorGreenBold = func() func(format string, a ...interface{}) string {
   378  		if isTerminal() {
   379  			return color.New(color.FgGreen, color.Bold).SprintfFunc()
   380  		}
   381  		return fmt.Sprintf
   382  	}()
   383  )
   384  
   385  func getUpdateTransport(timeout time.Duration) http.RoundTripper {
   386  	var updateTransport http.RoundTripper = &http.Transport{
   387  		Proxy: http.ProxyFromEnvironment,
   388  		DialContext: (&net.Dialer{
   389  			Timeout:   timeout,
   390  			KeepAlive: timeout,
   391  			DualStack: true,
   392  		}).DialContext,
   393  		IdleConnTimeout:       timeout,
   394  		TLSHandshakeTimeout:   timeout,
   395  		ExpectContinueTimeout: timeout,
   396  		TLSClientConfig: &tls.Config{
   397  			RootCAs: globalRootCAs,
   398  		},
   399  		DisableCompression: true,
   400  	}
   401  	return updateTransport
   402  }
   403  
   404  func getUpdateReaderFromURL(u *url.URL, transport http.RoundTripper) (io.ReadCloser, error) {
   405  	clnt := &http.Client{
   406  		Transport: transport,
   407  	}
   408  	req, e := http.NewRequest(http.MethodGet, u.String(), nil)
   409  	if e != nil {
   410  		return nil, e
   411  	}
   412  	req.Header.Set("User-Agent", getUserAgent())
   413  
   414  	resp, e := clnt.Do(req)
   415  	if e != nil {
   416  		return nil, e
   417  	}
   418  
   419  	if resp.StatusCode != http.StatusOK {
   420  		return nil, errors.New(resp.Status)
   421  	}
   422  
   423  	return newProgressReader(resp.Body, "mc", resp.ContentLength), nil
   424  }
   425  
   426  func doUpdate(customReleaseURL, sha256Hex string, latestReleaseTime time.Time, releaseTag string, ok bool) (updateStatusMsg string, err *probe.Error) {
   427  	fmtReleaseTime := latestReleaseTime.Format(mcReleaseTagTimeLayout)
   428  	if !ok {
   429  		updateStatusMsg = colorGreenBold("mc update to version %s canceled.",
   430  			releaseTag)
   431  		return updateStatusMsg, nil
   432  	}
   433  
   434  	sha256Sum, e := hex.DecodeString(sha256Hex)
   435  	if e != nil {
   436  		return updateStatusMsg, probe.NewError(e)
   437  	}
   438  
   439  	u, e := url.Parse(getDownloadURL(customReleaseURL, releaseTag))
   440  	if err != nil {
   441  		return updateStatusMsg, probe.NewError(e)
   442  	}
   443  
   444  	transport := getUpdateTransport(30 * time.Second)
   445  
   446  	rc, e := getUpdateReaderFromURL(u, transport)
   447  	if e != nil {
   448  		return updateStatusMsg, probe.NewError(e)
   449  	}
   450  	defer rc.Close()
   451  
   452  	opts := selfupdate.Options{
   453  		Hash:     crypto.SHA256,
   454  		Checksum: sha256Sum,
   455  	}
   456  
   457  	minisignPubkey := env.Get(envMinisignPubKey, "")
   458  	if minisignPubkey != "" {
   459  		v := selfupdate.NewVerifier()
   460  		u.Path = path.Dir(u.Path) + "/mc." + releaseTag + ".minisig"
   461  		if e = v.LoadFromURL(u.String(), minisignPubkey, transport); e != nil {
   462  			return updateStatusMsg, probe.NewError(e)
   463  		}
   464  		opts.Verifier = v
   465  	}
   466  
   467  	if e := opts.CheckPermissions(); e != nil {
   468  		permErrMsg := fmt.Sprintf(" failed with: %s", e)
   469  		updateStatusMsg = colorYellowBold("mc update to version RELEASE.%s %s.",
   470  			fmtReleaseTime, permErrMsg)
   471  		return updateStatusMsg, nil
   472  	}
   473  
   474  	if e = selfupdate.Apply(rc, opts); e != nil {
   475  		if re := selfupdate.RollbackError(e); re != nil {
   476  			rollBackErr := fmt.Sprintf("Failed to rollback from bad update: %v", re)
   477  			updateStatusMsg = colorYellowBold("mc update to version RELEASE.%s %s.", fmtReleaseTime, rollBackErr)
   478  			return updateStatusMsg, probe.NewError(e)
   479  		}
   480  
   481  		var pathErr *os.PathError
   482  		if errors.As(e, &pathErr) {
   483  			pathErrMsg := fmt.Sprintf("Unable to update the binary at %s: %v", filepath.Dir(pathErr.Path), pathErr.Err)
   484  			updateStatusMsg = colorYellowBold("mc update to version RELEASE.%s %s.",
   485  				fmtReleaseTime, pathErrMsg)
   486  			return updateStatusMsg, nil
   487  		}
   488  
   489  		return colorYellowBold(fmt.Sprintf("Error in mc update to version RELEASE.%s %v.", fmtReleaseTime, e)), nil
   490  	}
   491  
   492  	return colorGreenBold("mc updated to version RELEASE.%s successfully.", fmtReleaseTime), nil
   493  }
   494  
   495  type updateMessage struct {
   496  	Status  string `json:"status"`
   497  	Message string `json:"message"`
   498  }
   499  
   500  // String colorized make bucket message.
   501  func (s updateMessage) String() string {
   502  	return s.Message
   503  }
   504  
   505  // JSON jsonified make bucket message.
   506  func (s updateMessage) JSON() string {
   507  	s.Status = "success"
   508  	updateJSONBytes, e := json.MarshalIndent(s, "", " ")
   509  	fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
   510  
   511  	return string(updateJSONBytes)
   512  }
   513  
   514  func mainUpdate(ctx *cli.Context) {
   515  	if len(ctx.Args()) > 1 {
   516  		showCommandHelpAndExit(ctx, -1)
   517  	}
   518  
   519  	globalQuiet = ctx.Bool("quiet") || ctx.GlobalBool("quiet")
   520  	globalJSON = ctx.Bool("json") || ctx.GlobalBool("json")
   521  
   522  	customReleaseURL := ctx.Args().Get(0)
   523  
   524  	updateMsg, sha256Hex, _, latestReleaseTime, releaseTag, err := getUpdateInfo(customReleaseURL, 10*time.Second)
   525  	if err != nil {
   526  		errorIf(err, "Unable to update ‘mc’.")
   527  		os.Exit(-1)
   528  	}
   529  
   530  	// Nothing to update running the latest release.
   531  	color.New(color.FgGreen, color.Bold)
   532  	if updateMsg == "" {
   533  		printMsg(updateMessage{
   534  			Status:  "success",
   535  			Message: colorGreenBold("You are already running the most recent version of ‘mc’."),
   536  		})
   537  		os.Exit(0)
   538  	}
   539  
   540  	printMsg(updateMessage{
   541  		Status:  "success",
   542  		Message: updateMsg,
   543  	})
   544  
   545  	// Avoid updating mc development, source builds.
   546  	if updateMsg != "" {
   547  		var updateStatusMsg string
   548  		var err *probe.Error
   549  		updateStatusMsg, err = doUpdate(customReleaseURL, sha256Hex, latestReleaseTime, releaseTag, true)
   550  		if err != nil {
   551  			errorIf(err, "Unable to update ‘mc’.")
   552  			os.Exit(-1)
   553  		}
   554  		printMsg(updateMessage{Status: "success", Message: updateStatusMsg})
   555  		os.Exit(1)
   556  	}
   557  }