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 }