github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/undo-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  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"path/filepath"
    25  	"strings"
    26  
    27  	"github.com/fatih/color"
    28  	"github.com/minio/cli"
    29  	json "github.com/minio/colorjson"
    30  	"github.com/minio/mc/pkg/probe"
    31  	"github.com/minio/pkg/v2/console"
    32  )
    33  
    34  const (
    35  	actionPut    = "PUT"
    36  	actionDelete = "DELETE"
    37  )
    38  
    39  var undoFlags = []cli.Flag{
    40  	cli.IntFlag{
    41  		Name:  "last",
    42  		Usage: "undo N last changes",
    43  		Value: 1,
    44  	},
    45  	cli.BoolFlag{
    46  		Name:  "recursive, r",
    47  		Usage: "undo last S3 PUT/DELETE operations recursively",
    48  	},
    49  	cli.BoolFlag{
    50  		Name:  "force",
    51  		Usage: "force recursive operation",
    52  	},
    53  	cli.BoolFlag{
    54  		Name:  "dry-run",
    55  		Usage: "fake an undo operation",
    56  	},
    57  	cli.StringFlag{
    58  		Name:  "action",
    59  		Usage: "undo only if the latest version is of the following type [PUT/DELETE]",
    60  	},
    61  }
    62  
    63  var undoCmd = cli.Command{
    64  	Name:         "undo",
    65  	Usage:        "undo PUT/DELETE operations",
    66  	Action:       mainUndo,
    67  	OnUsageError: onUsageError,
    68  	Before:       setGlobalsFromContext,
    69  	Flags:        append(undoFlags, globalFlags...),
    70  	CustomHelpTemplate: `NAME:
    71    {{.HelpName}} - {{.Usage}}
    72  
    73  USAGE:
    74    {{.HelpName}} [FLAGS] TARGET
    75  
    76  FLAGS:
    77    {{range .VisibleFlags}}{{.}}
    78    {{end}}
    79  EXAMPLES:
    80    1. Undo the last 3 uploads and/or removals of a particular object
    81       {{.Prompt}} {{.HelpName}} s3/backups/file.zip --last 3
    82  
    83    2. Undo the last upload/removal change of all objects under a prefix
    84       {{.Prompt}} {{.HelpName}} s3/backups/prefix/ --recursive --force
    85  `,
    86  }
    87  
    88  // undoMessage container for undo message structure.
    89  type undoMessage struct {
    90  	Status         string `json:"status"`
    91  	URL            string `json:"url,omitempty"`
    92  	Key            string `json:"key,omitempty"`
    93  	VersionID      string `json:"versionId,omitempty"`
    94  	IsDeleteMarker bool   `json:"isDeleteMarker,omitempty"`
    95  }
    96  
    97  // String colorized string message.
    98  func (c undoMessage) String() string {
    99  	var msg string
   100  	fmt.Print(color.GreenString("\u2713 "))
   101  	yellow := color.New(color.FgYellow).SprintFunc()
   102  	if c.IsDeleteMarker {
   103  		msg += "Last " + color.RedString("delete") + " of `" + yellow(c.Key) + "` is reverted"
   104  	} else {
   105  		msg += "Last " + color.BlueString("upload") + " of `" + yellow(c.Key) + "` (vid=" + c.VersionID + ") is reverted"
   106  	}
   107  	msg += "."
   108  	return msg
   109  }
   110  
   111  // JSON jsonified content message.
   112  func (c undoMessage) JSON() string {
   113  	c.Status = "success"
   114  	jsonMessageBytes, e := json.MarshalIndent(c, "", " ")
   115  	fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
   116  
   117  	return string(jsonMessageBytes)
   118  }
   119  
   120  // parseUndoSyntax performs command-line input validation for cat command.
   121  func parseUndoSyntax(ctx *cli.Context) (targetAliasedURL string, last int, recursive, dryRun bool, action string) {
   122  	targetAliasedURL = ctx.Args().Get(0)
   123  	if targetAliasedURL == "" {
   124  		fatalIf(errInvalidArgument().Trace(), "The argument should not be empty")
   125  	}
   126  
   127  	last = ctx.Int("last")
   128  	if last < 1 {
   129  		fatalIf(errInvalidArgument().Trace(), "--last value should be a positive integer")
   130  	}
   131  
   132  	recursive = ctx.Bool("recursive")
   133  	force := ctx.Bool("force")
   134  	if recursive && !force {
   135  		fatalIf(errInvalidArgument().Trace(), "This is a dangerous operation, you need to provide --force flag as well")
   136  	}
   137  
   138  	dryRun = ctx.Bool("dry-run")
   139  	action = strings.ToUpper(ctx.String("action"))
   140  	if action != actionPut && action != actionDelete && action != "" {
   141  		fatalIf(errInvalidArgument().Trace(), "unsupported action specified, supported actions are PUT, DELETE or empty (default)")
   142  	}
   143  	if (action == actionPut || action == actionDelete) && last != 1 {
   144  		fatalIf(errInvalidArgument().Trace(), "--action if specified requires that you must specify --last=1")
   145  	}
   146  	return
   147  }
   148  
   149  func undoLastNOperations(ctx context.Context, clnt Client, objectVersions []*ClientContent, last int, dryRun bool) (exitErr error) {
   150  	if last == 0 {
   151  		return
   152  	}
   153  
   154  	sortObjectVersions(objectVersions)
   155  
   156  	if len(objectVersions) > last {
   157  		objectVersions = objectVersions[:last]
   158  	}
   159  
   160  	contentCh := make(chan *ClientContent)
   161  	resultCh := clnt.Remove(ctx, false, false, false, false, contentCh)
   162  
   163  	prefixPath := clnt.GetURL().Path
   164  	prefixPath = filepath.ToSlash(prefixPath)
   165  	if !strings.HasSuffix(prefixPath, "/") {
   166  		prefixPath = prefixPath[:strings.LastIndex(prefixPath, "/")+1]
   167  	}
   168  	prefixPath = strings.TrimPrefix(prefixPath, "./")
   169  
   170  	go func() {
   171  		for _, objectVersion := range objectVersions {
   172  			if !dryRun {
   173  				contentCh <- objectVersion
   174  			}
   175  
   176  			// Convert any os specific delimiters to "/".
   177  			contentURL := filepath.ToSlash(objectVersion.URL.Path)
   178  			// Trim prefix path from the content path.
   179  			keyName := strings.TrimPrefix(contentURL, prefixPath)
   180  
   181  			printMsg(undoMessage{
   182  				Status:         "success",
   183  				Key:            getOSDependantKey(keyName, objectVersion.Type.IsDir()),
   184  				URL:            objectVersion.URL.String(),
   185  				VersionID:      objectVersion.VersionID,
   186  				IsDeleteMarker: objectVersion.IsDeleteMarker,
   187  			})
   188  
   189  		}
   190  		close(contentCh)
   191  	}()
   192  
   193  	for result := range resultCh {
   194  		if result.Err != nil {
   195  			errorIf(result.Err.Trace(), "Unable to undo")
   196  			exitErr = exitStatus(globalErrorExitStatus) // Set the exit status.
   197  		}
   198  	}
   199  
   200  	return
   201  }
   202  
   203  func undoURL(ctx context.Context, aliasedURL string, last int, recursive, dryRun bool, action string) (exitErr error) {
   204  	clnt, err := newClient(aliasedURL)
   205  	fatalIf(err.Trace(aliasedURL), "Unable to initialize target `"+aliasedURL+"`.")
   206  
   207  	alias, _, _ := mustExpandAlias(aliasedURL)
   208  
   209  	var (
   210  		lastObjectPath        string
   211  		perObjectVersions     []*ClientContent
   212  		atLeastOneUndoApplied bool
   213  	)
   214  	remove := true
   215  	for content := range clnt.List(ctx, ListOptions{
   216  		Recursive:         recursive,
   217  		WithOlderVersions: true,
   218  		WithDeleteMarkers: true,
   219  		ShowDir:           DirNone,
   220  	}) {
   221  		if content.Err != nil {
   222  			fatalIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list folder.")
   223  		}
   224  
   225  		if content.StorageClass == s3StorageClassGlacier {
   226  			continue
   227  		}
   228  
   229  		if !recursive {
   230  			if alias+getKey(content) != getStandardizedURL(aliasedURL) {
   231  				break
   232  			}
   233  		}
   234  		if lastObjectPath != content.URL.Path {
   235  			// Print any object in the current list before reinitializing it
   236  			if remove {
   237  				exitErr = undoLastNOperations(ctx, clnt, perObjectVersions, last, dryRun)
   238  			}
   239  			remove = true
   240  			lastObjectPath = content.URL.Path
   241  			perObjectVersions = []*ClientContent{}
   242  		}
   243  		if !remove {
   244  			continue
   245  		}
   246  		if (content.IsLatest && action == actionDelete && !content.IsDeleteMarker) || (content.IsLatest && action == actionPut && content.IsDeleteMarker) {
   247  			remove = false
   248  			continue
   249  		}
   250  		perObjectVersions = append(perObjectVersions, content)
   251  		atLeastOneUndoApplied = true
   252  	}
   253  
   254  	// Undo the remaining versions found if any
   255  	if len(perObjectVersions) > 0 && remove {
   256  		exitErr = undoLastNOperations(ctx, clnt, perObjectVersions, last, dryRun)
   257  	}
   258  
   259  	if !atLeastOneUndoApplied {
   260  		errorIf(errDummy().Trace(clnt.GetURL().String()), "Unable to find any object version to undo.")
   261  		exitErr = exitStatus(globalErrorExitStatus) // Set the exit status.
   262  	}
   263  
   264  	return
   265  }
   266  
   267  func checkIfBucketIsVersioned(ctx context.Context, aliasedURL string) (versioned bool) {
   268  	client, err := newClient(aliasedURL)
   269  	fatalIf(err, "Unable to parse `%s`", aliasedURL)
   270  
   271  	versioningConfig, err := client.GetVersion(ctx)
   272  	if err != nil {
   273  		if errors.As(err.ToGoError(), &APINotImplemented{}) {
   274  			return false
   275  		}
   276  		fatalIf(err.Trace(), "Unable to get bucket versioning info")
   277  	}
   278  
   279  	if versioningConfig.Status == "Enabled" {
   280  		return true
   281  	}
   282  	return false
   283  }
   284  
   285  func checkUndoSyntax(cliCtx *cli.Context) {
   286  	if !cliCtx.Args().Present() {
   287  		showCommandHelpAndExit(cliCtx, 1)
   288  	}
   289  }
   290  
   291  // mainUndo is the main entry point for undo command.
   292  func mainUndo(cliCtx *cli.Context) error {
   293  	checkUndoSyntax(cliCtx)
   294  
   295  	ctx, cancelCat := context.WithCancel(globalContext)
   296  	defer cancelCat()
   297  
   298  	console.SetColor("Success", color.New(color.FgGreen, color.Bold))
   299  
   300  	// check 'undo' cli arguments.
   301  	targetAliasedURL, last, recursive, dryRun, action := parseUndoSyntax(cliCtx)
   302  
   303  	if !checkIfBucketIsVersioned(ctx, targetAliasedURL) {
   304  		fatalIf(errDummy().Trace(), "Undo command works only with S3 versioned-enabled buckets.")
   305  	}
   306  
   307  	return undoURL(ctx, targetAliasedURL, last, recursive, dryRun, action)
   308  }