github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/stat.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 "bytes" 22 "context" 23 "fmt" 24 "os" 25 "path/filepath" 26 "sort" 27 "strings" 28 "time" 29 30 "github.com/dustin/go-humanize" 31 json "github.com/minio/colorjson" 32 "github.com/minio/madmin-go/v3" 33 "github.com/minio/mc/pkg/probe" 34 "github.com/minio/minio-go/v7" 35 "github.com/minio/minio-go/v7/pkg/lifecycle" 36 "github.com/minio/minio-go/v7/pkg/notification" 37 "github.com/minio/minio-go/v7/pkg/replication" 38 "github.com/minio/pkg/v2/console" 39 ) 40 41 // contentMessage container for content message structure. 42 type statMessage struct { 43 Status string `json:"status"` 44 Key string `json:"name"` 45 Date time.Time `json:"lastModified"` 46 Size int64 `json:"size"` 47 ETag string `json:"etag"` 48 Type string `json:"type,omitempty"` 49 Expires *time.Time `json:"expires,omitempty"` 50 Expiration *time.Time `json:"expiration,omitempty"` 51 ExpirationRuleID string `json:"expirationRuleID,omitempty"` 52 ReplicationStatus string `json:"replicationStatus,omitempty"` 53 Metadata map[string]string `json:"metadata,omitempty"` 54 VersionID string `json:"versionID,omitempty"` 55 DeleteMarker bool `json:"deleteMarker,omitempty"` 56 Restore *minio.RestoreInfo `json:"restore,omitempty"` 57 } 58 59 func (stat statMessage) String() (msg string) { 60 var msgBuilder strings.Builder 61 // Format properly for alignment based on maxKey leng 62 stat.Key = fmt.Sprintf("%-10s: %s", "Name", stat.Key) 63 msgBuilder.WriteString(console.Colorize("Name", stat.Key) + "\n") 64 if !stat.Date.IsZero() { 65 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "Date", stat.Date.Format(printDate)) + "\n") 66 } 67 if stat.Type != "folder" { 68 msgBuilder.WriteString(fmt.Sprintf("%-10s: %-6s ", "Size", humanize.IBytes(uint64(stat.Size))) + "\n") 69 } 70 71 if stat.ETag != "" { 72 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "ETag", stat.ETag) + "\n") 73 } 74 if stat.VersionID != "" { 75 versionIDField := stat.VersionID 76 if stat.DeleteMarker { 77 versionIDField += " (delete-marker)" 78 } 79 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "VersionID", versionIDField) + "\n") 80 } 81 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "Type", stat.Type) + "\n") 82 if stat.Expires != nil { 83 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "Expires", stat.Expires.Format(printDate)) + "\n") 84 } 85 if stat.Expiration != nil { 86 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s (lifecycle-rule-id: %s) ", "Expiration", 87 stat.Expiration.Local().Format(printDate), stat.ExpirationRuleID) + "\n") 88 } 89 if stat.Restore != nil { 90 msgBuilder.WriteString(fmt.Sprintf("%-10s:", "Restore") + "\n") 91 msgBuilder.WriteString(fmt.Sprintf(" %-10s: %s", "ExpiryTime", 92 stat.Restore.ExpiryTime.Local().Format(printDate)) + "\n") 93 msgBuilder.WriteString(fmt.Sprintf(" %-10s: %t", "Ongoing", 94 stat.Restore.OngoingRestore) + "\n") 95 } 96 maxKeyMetadata := 0 97 maxKeyEncrypted := 0 98 for k := range stat.Metadata { 99 // Skip encryption headers, we print them later. 100 if !strings.HasPrefix(strings.ToLower(k), serverEncryptionKeyPrefix) { 101 if len(k) > maxKeyMetadata { 102 maxKeyMetadata = len(k) 103 } 104 } else if strings.HasPrefix(strings.ToLower(k), serverEncryptionKeyPrefix) { 105 if len(k) > maxKeyEncrypted { 106 maxKeyEncrypted = len(k) 107 } 108 } 109 } 110 111 if maxKeyEncrypted > 0 { 112 if keyID, ok := stat.Metadata["X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id"]; ok { 113 msgBuilder.WriteString(fmt.Sprintf("%-10s: SSE-%s (%s)\n", "Encryption", "KMS", keyID)) 114 } else if _, ok := stat.Metadata["X-Amz-Server-Side-Encryption-Customer-Key-Md5"]; ok { 115 msgBuilder.WriteString(fmt.Sprintf("%-10s: SSE-%s\n", "Encryption", "C")) 116 } else { 117 msgBuilder.WriteString(fmt.Sprintf("%-10s: SSE-%s\n", "Encryption", "S3")) 118 } 119 } 120 121 if maxKeyMetadata > 0 { 122 msgBuilder.WriteString(fmt.Sprintf("%-10s:", "Metadata") + "\n") 123 for k, v := range stat.Metadata { 124 // Skip encryption headers, we print them later. 125 if !strings.HasPrefix(strings.ToLower(k), serverEncryptionKeyPrefix) { 126 msgBuilder.WriteString(fmt.Sprintf(" %-*.*s: %s ", maxKeyMetadata, maxKeyMetadata, k, v) + "\n") 127 } 128 } 129 } 130 131 if stat.ReplicationStatus != "" { 132 msgBuilder.WriteString(fmt.Sprintf("%-10s: %s ", "Replication Status", stat.ReplicationStatus)) 133 } 134 135 msgBuilder.WriteString("\n") 136 137 return msgBuilder.String() 138 } 139 140 // JSON jsonified content message. 141 func (stat statMessage) JSON() string { 142 stat.Status = "success" 143 jsonMessageBytes, e := json.MarshalIndent(stat, "", " ") 144 fatalIf(probe.NewError(e), "Unable to marshal into JSON.") 145 146 return string(jsonMessageBytes) 147 } 148 149 // parseStat parses client Content container into statMessage struct. 150 func parseStat(c *ClientContent) statMessage { 151 content := statMessage{} 152 content.Date = c.Time.Local() 153 // guess file type. 154 content.Type = func() string { 155 if c.Type.IsDir() { 156 return "folder" 157 } 158 return "file" 159 }() 160 content.Size = c.Size 161 content.VersionID = c.VersionID 162 content.Key = getKey(c) 163 content.Metadata = c.Metadata 164 content.ETag = strings.TrimPrefix(c.ETag, "\"") 165 content.ETag = strings.TrimSuffix(content.ETag, "\"") 166 if !c.Expires.IsZero() { 167 content.Expires = &c.Expires 168 } 169 if !c.Expiration.IsZero() { 170 content.Expiration = &c.Expiration 171 } 172 content.ExpirationRuleID = c.ExpirationRuleID 173 content.ReplicationStatus = c.ReplicationStatus 174 content.Restore = c.Restore 175 return content 176 } 177 178 // Return standardized URL to be used to compare later. 179 func getStandardizedURL(targetURL string) string { 180 return filepath.FromSlash(targetURL) 181 } 182 183 // statURL - uses combination of GET listing and HEAD to fetch information of one or more objects 184 // HEAD can fail with 400 with an SSE-C encrypted object but we still return information gathered 185 // from GET listing. 186 func statURL(ctx context.Context, targetURL, versionID string, timeRef time.Time, includeOlderVersions, isIncomplete, isRecursive bool, encKeyDB map[string][]prefixSSEPair) *probe.Error { 187 clnt, err := newClient(targetURL) 188 if err != nil { 189 return err 190 } 191 192 targetAlias, _, _ := mustExpandAlias(targetURL) 193 prefixPath := clnt.GetURL().Path 194 separator := string(clnt.GetURL().Separator) 195 196 hasTrailingSlash := strings.HasSuffix(prefixPath, separator) 197 198 if !hasTrailingSlash { 199 prefixPath = prefixPath[:strings.LastIndex(prefixPath, separator)+1] 200 } 201 202 // if stat is on a bucket and non-recursive mode, serve the bucket metadata 203 if !isRecursive && !hasTrailingSlash { 204 bstat, err := clnt.GetBucketInfo(ctx) 205 if err == nil { 206 // Convert any os specific delimiters to "/". 207 contentURL := filepath.ToSlash(bstat.URL.Path) 208 prefixPath = filepath.ToSlash(prefixPath) 209 // Trim prefix path from the content path. 210 contentURL = strings.TrimPrefix(contentURL, prefixPath) 211 bstat.URL.Path = contentURL 212 213 if bstat.Date.IsZero() || bstat.Date.Equal(timeSentinel) { 214 bstat.Date = time.Now() 215 } 216 217 var bu madmin.BucketUsageInfo 218 219 adminClient, _ := newAdminClient(targetURL) 220 if adminClient != nil { 221 // Create a new MinIO Admin Client 222 duinfo, e := adminClient.DataUsageInfo(ctx) 223 if e == nil { 224 bu = duinfo.BucketsUsage[bstat.Key] 225 } 226 } 227 228 if prefixPath != "/" { 229 bstat.Prefix = true 230 } 231 232 printMsg(bucketInfoMessage{ 233 Status: "success", 234 BucketInfo: bstat, 235 Usage: bu, 236 }) 237 238 return nil 239 } 240 } 241 242 lstOptions := ListOptions{Recursive: isRecursive, Incomplete: isIncomplete, ShowDir: DirNone} 243 switch { 244 case versionID != "": 245 lstOptions.WithOlderVersions = true 246 lstOptions.WithDeleteMarkers = true 247 case !timeRef.IsZero(), includeOlderVersions: 248 lstOptions.WithOlderVersions = includeOlderVersions 249 lstOptions.WithDeleteMarkers = true 250 lstOptions.TimeRef = timeRef 251 } 252 253 var e error 254 for content := range clnt.List(ctx, lstOptions) { 255 if content.Err != nil { 256 switch content.Err.ToGoError().(type) { 257 // handle this specifically for filesystem related errors. 258 case BrokenSymlink: 259 errorIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list broken link.") 260 continue 261 case TooManyLevelsSymlink: 262 errorIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list too many levels link.") 263 continue 264 case PathNotFound: 265 errorIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list folder.") 266 continue 267 case PathInsufficientPermission: 268 errorIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list folder.") 269 continue 270 } 271 errorIf(content.Err.Trace(clnt.GetURL().String()), "Unable to list folder.") 272 e = exitStatus(globalErrorExitStatus) // Set the exit status. 273 continue 274 } 275 276 if content.StorageClass == s3StorageClassGlacier { 277 continue 278 } 279 280 url := targetAlias + getKey(content) 281 standardizedURL := getStandardizedURL(targetURL) 282 283 if !isRecursive && !strings.HasPrefix(filepath.FromSlash(url), standardizedURL) && !filepath.IsAbs(url) { 284 return errTargetNotFound(targetURL).Trace(url, standardizedURL) 285 } 286 287 if versionID != "" { 288 if versionID != content.VersionID { 289 continue 290 } 291 } 292 _, stat, err := url2Stat(ctx, url2StatOptions{urlStr: url, versionID: content.VersionID, fileAttr: true, encKeyDB: encKeyDB, timeRef: timeRef, isZip: false, ignoreBucketExistsCheck: false}) 293 if err != nil { 294 continue 295 } 296 297 // Convert any os specific delimiters to "/". 298 contentURL := filepath.ToSlash(stat.URL.Path) 299 prefixPath = filepath.ToSlash(prefixPath) 300 // Trim prefix path from the content path. 301 contentURL = strings.TrimPrefix(contentURL, prefixPath) 302 stat.URL.Path = contentURL 303 304 printMsg(parseStat(stat)) 305 } 306 307 return probe.NewError(e) 308 } 309 310 // BucketInfo holds info about a bucket 311 type BucketInfo struct { 312 URL ClientURL `json:"-"` 313 Key string `json:"name"` 314 Date time.Time `json:"lastModified"` 315 Size int64 `json:"size"` 316 Type os.FileMode `json:"-"` 317 Prefix bool `json:"-"` 318 Versioning struct { 319 Status string `json:"status"` 320 MFADelete string `json:"MFADelete"` 321 } `json:"Versioning,omitempty"` 322 Encryption struct { 323 Algorithm string `json:"algorithm,omitempty"` 324 KeyID string `json:"keyId,omitempty"` 325 } `json:"Encryption,omitempty"` 326 Locking struct { 327 Enabled string `json:"enabled"` 328 Mode minio.RetentionMode `json:"mode"` 329 Validity string `json:"validity"` 330 } `json:"ObjectLock,omitempty"` 331 Replication struct { 332 Enabled bool `json:"enabled"` 333 Config replication.Config `json:"config,omitempty"` 334 } `json:"Replication"` 335 Policy struct { 336 Type string `json:"type"` 337 Text string `json:"policy,omitempty"` 338 } `json:"Policy,omitempty"` 339 Location string `json:"location"` 340 Tagging map[string]string `json:"tagging,omitempty"` 341 ILM struct { 342 Config *lifecycle.Configuration `json:"config,omitempty"` 343 } `json:"ilm,omitempty"` 344 Notification struct { 345 Config notification.Configuration `json:"config,omitempty"` 346 } `json:"notification,omitempty"` 347 } 348 349 // Tags returns stringified tag list. 350 func (i BucketInfo) Tags() string { 351 keys := []string{} 352 for key := range i.Tagging { 353 keys = append(keys, key) 354 } 355 sort.Strings(keys) 356 357 strs := []string{} 358 for _, key := range keys { 359 strs = append( 360 strs, 361 fmt.Sprintf("%v:%v", console.Colorize("Key", key), console.Colorize("Value", i.Tagging[key])), 362 ) 363 } 364 365 return strings.Join(strs, ", ") 366 } 367 368 type bucketInfoMessage struct { 369 Status string `json:"status"` 370 BucketInfo 371 Usage madmin.BucketUsageInfo 372 } 373 374 func (v bucketInfoMessage) JSON() string { 375 v.Status = "success" 376 v.Key = getKey(&ClientContent{URL: v.URL, Type: v.Type}) 377 var buf bytes.Buffer 378 enc := json.NewEncoder(&buf) 379 enc.SetIndent("", " ") 380 // Disable escaping special chars to display XML tags correctly 381 enc.SetEscapeHTML(false) 382 383 fatalIf(probe.NewError(enc.Encode(v)), "Unable to marshal into JSON.") 384 return buf.String() 385 } 386 387 type histogramDef struct { 388 start, end uint64 389 text string 390 } 391 392 var histogramTagsDesc = map[string]histogramDef{ 393 "LESS_THAN_1024_B": {0, 1024, "less than 1024 bytes"}, 394 "BETWEEN_1024_B_AND_1_MB": {1024, 1024 * 1024, "between 1024 bytes and 1 MB"}, 395 "BETWEEN_1_MB_AND_10_MB": {1024 * 1024, 10 * 1024 * 1024, "between 1 MB and 10 MB"}, 396 "BETWEEN_10_MB_AND_64_MB": {10 * 1024 * 1024, 64 * 1024 * 1024, "between 10 MB and 64 MB"}, 397 "BETWEEN_64_MB_AND_128_MB": {64 * 1024 * 1024, 128 * 1024 * 1024, "between 64 MB and 128 MB"}, 398 "BETWEEN_128_MB_AND_512_MB": {128 * 1024 * 1024, 512 * 1024 * 1024, "between 128 MB and 512 MB"}, 399 "GREATER_THAN_512_MB": {512 * 1024 * 1024, 0, "greater than 512 MB"}, 400 } 401 402 // Return a sorted list of histograms 403 func sortHistogramTags() (orderedTags []string) { 404 orderedTags = make([]string, 0, len(histogramTagsDesc)) 405 for tag := range histogramTagsDesc { 406 orderedTags = append(orderedTags, tag) 407 } 408 sort.Slice(orderedTags, func(i, j int) bool { 409 return histogramTagsDesc[orderedTags[i]].start < histogramTagsDesc[orderedTags[j]].start 410 }) 411 return 412 } 413 414 func countDigits(num uint64) (count uint) { 415 for num > 0 { 416 num /= 10 417 count++ 418 } 419 return 420 } 421 422 func (v bucketInfoMessage) String() string { 423 var b strings.Builder 424 425 keyStr := getKey(&ClientContent{URL: v.URL, Type: v.Type}) 426 keyStr = strings.TrimSuffix(keyStr, slashSeperator) 427 key := fmt.Sprintf("%-10s: %s", "Name", keyStr) 428 b.WriteString(console.Colorize("Title", key) + "\n") 429 if !v.Date.IsZero() && !v.Date.Equal(timeSentinel) { 430 b.WriteString(fmt.Sprintf("%-10s: %s ", "Date", v.Date.Format(printDate)) + "\n") 431 } 432 b.WriteString(fmt.Sprintf("%-10s: %-6s \n", "Size", "N/A")) 433 434 fType := func() string { 435 if v.Prefix { 436 return "prefix" 437 } 438 if v.Type.IsDir() { 439 return "folder" 440 } 441 return "file" 442 }() 443 b.WriteString(fmt.Sprintf("%-10s: %s \n", "Type", fType)) 444 fmt.Fprintf(&b, "\n") 445 446 if !v.Prefix { 447 fmt.Fprint(&b, console.Colorize("Title", "Properties:\n")) 448 fmt.Fprint(&b, prettyPrintBucketMetadata(v.BucketInfo)) 449 fmt.Fprintf(&b, "\n") 450 } 451 452 fmt.Fprint(&b, console.Colorize("Title", "Usage:\n")) 453 454 fmt.Fprintf(&b, "%16s: %s\n", "Total size", console.Colorize("Count", humanize.IBytes(v.Usage.Size))) 455 fmt.Fprintf(&b, "%16s: %s\n", "Objects count", console.Colorize("Count", humanize.Comma(int64(v.Usage.ObjectsCount)))) 456 fmt.Fprintf(&b, "%16s: %s\n", "Versions count", console.Colorize("Count", humanize.Comma(int64(v.Usage.VersionsCount)))) 457 fmt.Fprintf(&b, "\n") 458 459 if len(v.Usage.ObjectSizesHistogram) > 0 { 460 fmt.Fprint(&b, console.Colorize("Title", "Object sizes histogram:\n")) 461 462 var maxDigits uint 463 for _, val := range v.Usage.ObjectSizesHistogram { 464 if d := countDigits(val); d > maxDigits { 465 maxDigits = d 466 } 467 } 468 469 sortedTags := sortHistogramTags() 470 for _, tagName := range sortedTags { 471 val, ok := v.Usage.ObjectSizesHistogram[tagName] 472 if ok { 473 fmt.Fprintf(&b, " %*d object(s) %s\n", maxDigits, val, histogramTagsDesc[tagName].text) 474 } 475 } 476 } 477 478 return b.String() 479 } 480 481 // Pretty print bucket configuration - used by stat and admin bucket info as well 482 func prettyPrintBucketMetadata(info BucketInfo) string { 483 var b strings.Builder 484 placeHolder := "" 485 if info.Encryption.Algorithm != "" { 486 fmt.Fprintf(&b, "%2s%s", placeHolder, "Encryption: ") 487 if info.Encryption.Algorithm == "aws:kms" { 488 fmt.Fprint(&b, console.Colorize("Key", "\n\tKey Type: ")) 489 fmt.Fprint(&b, console.Colorize("Value", "SSE-KMS")) 490 fmt.Fprint(&b, console.Colorize("Key", "\n\tKey ID: ")) 491 fmt.Fprint(&b, console.Colorize("Value", info.Encryption.KeyID)) 492 } else { 493 fmt.Fprint(&b, console.Colorize("Key", "\n\tKey Type: ")) 494 fmt.Fprint(&b, console.Colorize("Value", strings.ToUpper(info.Encryption.Algorithm))) 495 } 496 fmt.Fprintln(&b) 497 } 498 fmt.Fprintf(&b, "%2s%s", placeHolder, "Versioning: ") 499 if info.Versioning.Status == "" { 500 fmt.Fprint(&b, console.Colorize("Unset", "Un-versioned")) 501 } else { 502 fmt.Fprint(&b, console.Colorize("Set", info.Versioning.Status)) 503 } 504 fmt.Fprintln(&b) 505 506 if info.Locking.Mode != "" { 507 fmt.Fprintf(&b, "%2s%s\n", placeHolder, "LockConfiguration: ") 508 fmt.Fprintf(&b, "%4s%s", placeHolder, "RetentionMode: ") 509 fmt.Fprint(&b, console.Colorize("Value", info.Locking.Mode)) 510 fmt.Fprintln(&b) 511 fmt.Fprintf(&b, "%4s%s", placeHolder, "Retention Until Date: ") 512 fmt.Fprint(&b, console.Colorize("Value", info.Locking.Validity)) 513 fmt.Fprintln(&b) 514 } 515 if len(info.Notification.Config.TopicConfigs) > 0 { 516 fmt.Fprintf(&b, "%2s%s", placeHolder, "Notification: ") 517 fmt.Fprint(&b, console.Colorize("Set", "Set")) 518 fmt.Fprintln(&b) 519 } 520 if info.Replication.Enabled { 521 fmt.Fprintf(&b, "%2s%s", placeHolder, "Replication: ") 522 fmt.Fprint(&b, console.Colorize("Set", "Enabled")) 523 fmt.Fprintln(&b) 524 } 525 fmt.Fprintf(&b, "%2s%s", placeHolder, "Location: ") 526 fmt.Fprint(&b, console.Colorize("Generic", info.Location)) 527 fmt.Fprintln(&b) 528 fmt.Fprintf(&b, "%2s%s", placeHolder, "Anonymous: ") 529 if info.Policy.Type == "none" { 530 fmt.Fprint(&b, console.Colorize("UnSet", "Disabled")) 531 } else { 532 fmt.Fprint(&b, console.Colorize("Set", "Enabled")) 533 } 534 fmt.Fprintln(&b) 535 if info.Tags() != "" { 536 fmt.Fprintf(&b, "%2s%s", placeHolder, "Tagging: ") 537 fmt.Fprint(&b, console.Colorize("Generic", info.Tags())) 538 fmt.Fprintln(&b) 539 } 540 fmt.Fprintf(&b, "%2s%s", placeHolder, "ILM: ") 541 if info.ILM.Config != nil { 542 fmt.Fprint(&b, console.Colorize("Set", "Enabled")) 543 } else { 544 fmt.Fprint(&b, console.Colorize("UnSet", "Disabled")) 545 } 546 fmt.Fprintln(&b) 547 548 return b.String() 549 }