// Copyright (c) 2015-2022 MinIO, Inc. // // This file is part of MinIO Object Storage stack // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package cmd import ( "context" "errors" "fmt" "path/filepath" "strings" "github.com/fatih/color" "github.com/minio/cli" json "github.com/minio/colorjson" "github.com/minio/mc/pkg/probe" "github.com/minio/pkg/v3/console" ) const ( actionPut = "PUT" actionDelete = "DELETE" ) var undoFlags = []cli.Flag{ cli.IntFlag{ Name: "last", Usage: "undo N last changes", Value: 1, }, cli.BoolFlag{ Name: "recursive, r", Usage: "undo last S3 PUT/DELETE operations recursively", }, cli.BoolFlag{ Name: "force", Usage: "force recursive operation", }, cli.BoolFlag{ Name: "dry-run", Usage: "fake an undo operation", }, cli.StringFlag{ Name: "action", Usage: "undo only if the latest version is of the following type [PUT/DELETE]", }, } var undoCmd = cli.Command{ Name: "undo", Usage: "undo PUT/DELETE operations", Action: mainUndo, OnUsageError: onUsageError, Before: setGlobalsFromContext, Flags: append(undoFlags, globalFlags...), CustomHelpTemplate: `NAME: {{.HelpName}} - {{.Usage}} USAGE: {{.HelpName}} [FLAGS] TARGET FLAGS: {{range .VisibleFlags}}{{.}} {{end}} EXAMPLES: 1. Undo the last 3 uploads and/or removals of a particular object {{.Prompt}} {{.HelpName}} s3/backups/file.zip --last 3 2. Undo the last upload/removal change of all objects under a prefix {{.Prompt}} {{.HelpName}} s3/backups/prefix/ --recursive --force `, } // undoMessage container for undo message structure. type undoMessage struct { Status string `json:"status"` URL string `json:"url,omitempty"` Key string `json:"key,omitempty"` VersionID string `json:"versionId,omitempty"` IsDeleteMarker bool `json:"isDeleteMarker,omitempty"` } // String colorized string message. func (c undoMessage) String() string { var msg string fmt.Print(color.GreenString("\u2713 ")) yellow := color.New(color.FgYellow).SprintFunc() if c.IsDeleteMarker { msg += "Last " + color.RedString("delete") + " of `" + yellow(c.Key) + "` is reverted" } else { msg += "Last " + color.BlueString("upload") + " of `" + yellow(c.Key) + "` (vid=" + c.VersionID + ") is reverted" } msg += "." return msg } // JSON jsonified content message. func (c undoMessage) JSON() string { c.Status = "success" jsonMessageBytes, e := json.MarshalIndent(c, "", " ") fatalIf(probe.NewError(e), "Unable to marshal into JSON.") return string(jsonMessageBytes) } // parseUndoSyntax performs command-line input validation for cat command. func parseUndoSyntax(ctx *cli.Context) (targetAliasedURL string, last int, recursive, dryRun bool, action string) { targetAliasedURL = ctx.Args().Get(0) if targetAliasedURL == "" { fatalIf(errInvalidArgument().Trace(), "The argument should not be empty") } last = ctx.Int("last") if last < 1 { fatalIf(errInvalidArgument().Trace(), "--last value should be a positive integer") } recursive = ctx.Bool("recursive") force := ctx.Bool("force") if recursive && !force { fatalIf(errInvalidArgument().Trace(), "This is a dangerous operation, you need to provide --force flag as well") } dryRun = ctx.Bool("dry-run") action = strings.ToUpper(ctx.String("action")) if action != actionPut && action != actionDelete && action != "" { fatalIf(errInvalidArgument().Trace(), "unsupported action specified, supported actions are PUT, DELETE or empty (default)") } if (action == actionPut || action == actionDelete) && last != 1 { fatalIf(errInvalidArgument().Trace(), "--action if specified requires that you must specify --last=1") } return } func undoLastNOperations(ctx context.Context, clnt Client, objectVersions []*ClientContent, last int, dryRun bool) (exitErr error) { if last == 0 { return } sortObjectVersions(objectVersions) if len(objectVersions) > last { objectVersions = objectVersions[:last] } contentCh := make(chan *ClientContent) resultCh := clnt.Remove(ctx, false, false, false, false, contentCh) prefixPath := clnt.GetURL().Path prefixPath = filepath.ToSlash(prefixPath) if !strings.HasSuffix(prefixPath, "/") { prefixPath = prefixPath[:strings.LastIndex(prefixPath, "/")+1] } prefixPath = strings.TrimPrefix(prefixPath, "./") go func() { for _, objectVersion := range objectVersions { if !dryRun { contentCh <- objectVersion } // Convert any os specific delimiters to "/". contentURL := filepath.ToSlash(objectVersion.URL.Path) // Trim prefix path from the content path. keyName := strings.TrimPrefix(contentURL, prefixPath) printMsg(undoMessage{ Status: "success", Key: getOSDependantKey(keyName, objectVersion.Type.IsDir()), URL: objectVersion.URL.String(), VersionID: objectVersion.VersionID, IsDeleteMarker: objectVersion.IsDeleteMarker, }) } close(contentCh) }() for result := range resultCh { if result.Err != nil { errorIf(result.Err.Trace(), "Unable to undo") exitErr = exitStatus(globalErrorExitStatus) // Set the exit status. } } return } func undoURL(ctx context.Context, aliasedURL string, last int, recursive, dryRun bool, action string) (exitErr error) { clnt, err := newClient(aliasedURL) fatalIf(err.Trace(aliasedURL), "Unable to initialize target `"+aliasedURL+"`.") alias, _, _ := mustExpandAlias(aliasedURL) var ( lastObjectPath string perObjectVersions []*ClientContent atLeastOneUndoApplied bool ) remove := true for content := range clnt.List(ctx, ListOptions{ Recursive: recursive, WithOlderVersions: true, WithDeleteMarkers: true, ShowDir: DirNone, }) { if content.Err != nil { fatalIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list folder.") } if content.StorageClass == s3StorageClassGlacier { continue } if !recursive { if getStandardizedURL(alias+getKey(content)) != getStandardizedURL(aliasedURL) { break } } if lastObjectPath != content.URL.Path { // Print any object in the current list before reinitializing it if remove { exitErr = undoLastNOperations(ctx, clnt, perObjectVersions, last, dryRun) } remove = true lastObjectPath = content.URL.Path perObjectVersions = []*ClientContent{} } if !remove { continue } if (content.IsLatest && action == actionDelete && !content.IsDeleteMarker) || (content.IsLatest && action == actionPut && content.IsDeleteMarker) { remove = false continue } perObjectVersions = append(perObjectVersions, content) atLeastOneUndoApplied = true } // Undo the remaining versions found if any if len(perObjectVersions) > 0 && remove { exitErr = undoLastNOperations(ctx, clnt, perObjectVersions, last, dryRun) } if !atLeastOneUndoApplied { errorIf(errDummy().Trace(clnt.GetURL().String()), "Unable to find any object version to undo.") exitErr = exitStatus(globalErrorExitStatus) // Set the exit status. } return } func checkIfBucketIsVersioned(ctx context.Context, aliasedURL string) (versioned bool) { client, err := newClient(aliasedURL) fatalIf(err, "Unable to parse `%s`", aliasedURL) versioningConfig, err := client.GetVersion(ctx) if err != nil { if errors.As(err.ToGoError(), &APINotImplemented{}) { return false } fatalIf(err.Trace(), "Unable to get bucket versioning info") } if versioningConfig.Status == "Enabled" { return true } return false } func checkUndoSyntax(cliCtx *cli.Context) { if !cliCtx.Args().Present() { showCommandHelpAndExit(cliCtx, 1) } } // mainUndo is the main entry point for undo command. func mainUndo(cliCtx *cli.Context) error { checkUndoSyntax(cliCtx) ctx, cancelCat := context.WithCancel(globalContext) defer cancelCat() console.SetColor("Success", color.New(color.FgGreen, color.Bold)) // check 'undo' cli arguments. targetAliasedURL, last, recursive, dryRun, action := parseUndoSyntax(cliCtx) if !checkIfBucketIsVersioned(ctx, targetAliasedURL) { fatalIf(errDummy().Trace(), "Undo command works only with S3 versioned-enabled buckets.") } return undoURL(ctx, targetAliasedURL, last, recursive, dryRun, action) }