Files
s3-cli/cmd/mirror-main.go
T

1185 lines
36 KiB
Go

// 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 <http://www.gnu.org/licenses/>.
package cmd
import (
"context"
"fmt"
"math/rand"
"net/http"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/fatih/color"
"github.com/minio/cli"
json "github.com/minio/colorjson"
"github.com/minio/mc/pkg/probe"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/encrypt"
"github.com/minio/minio-go/v7/pkg/notification"
"github.com/minio/pkg/v3/console"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// mirror specific flags.
var (
mirrorFlags = []cli.Flag{
cli.BoolFlag{
Name: "force",
Usage: "force allows forced overwrite or removal of object(s) on target",
Hidden: true, // Hidden since this option is deprecated.
},
cli.BoolFlag{
Name: "overwrite",
Usage: "overwrite object(s) on target if it differs from source",
},
cli.BoolFlag{
Name: "fake",
Usage: "perform a fake mirror operation",
Hidden: true, // deprecated 2022
},
cli.BoolFlag{
Name: "dry-run",
Usage: "perform a fake mirror operation",
},
cli.BoolFlag{
Name: "watch, w",
Usage: "watch and synchronize changes",
},
cli.BoolFlag{
Name: "remove",
Usage: "remove extraneous object(s) on target",
},
cli.StringFlag{
Name: "region",
Usage: "specify region when creating new bucket(s) on target",
Value: "us-east-1",
},
cli.BoolFlag{
Name: "preserve, a",
Usage: "preserve file(s)/object(s) attributes and bucket(s) policy/locking configuration(s) on target bucket(s)",
},
cli.BoolFlag{
Name: "md5",
Usage: "force all upload(s) to calculate md5sum checksum",
Hidden: true,
},
cli.BoolFlag{
Name: "multi-master",
Usage: "enable multi-master multi-site setup",
Hidden: true,
},
cli.BoolFlag{
Name: "active-active",
Usage: "enable active-active multi-site setup",
},
cli.BoolFlag{
Name: "disable-multipart",
Usage: "disable multipart upload feature",
},
cli.StringSliceFlag{
Name: "exclude",
Usage: "exclude object(s) that match specified object name pattern",
},
cli.StringSliceFlag{
Name: "exclude-bucket",
Usage: "exclude bucket(s) that match specified bucket name pattern",
},
cli.StringSliceFlag{
Name: "exclude-storageclass",
Usage: "exclude object(s) that match the specified storage class",
},
cli.StringFlag{
Name: "older-than",
Usage: "filter object(s) older than value in duration string (e.g. 7d10h31s)",
},
cli.StringFlag{
Name: "newer-than",
Usage: "filter object(s) newer than value in duration string (e.g. 7d10h31s)",
},
cli.StringFlag{
Name: "storage-class, sc",
Usage: "specify storage class for new object(s) on target",
},
cli.StringFlag{
Name: "attr",
Usage: "add custom metadata for all objects",
},
cli.StringFlag{
Name: "monitoring-address",
Usage: "if specified, a new prometheus endpoint will be created to report mirroring activity. (eg: localhost:8081)",
},
cli.BoolFlag{
Name: "retry",
Usage: "if specified, will enable retrying on a per object basis if errors occur",
},
cli.BoolFlag{
Name: "summary",
Usage: "print a summary of the mirror session",
},
cli.BoolFlag{
Name: "skip-errors",
Usage: "skip any errors when mirroring",
},
checksumFlag,
}
)
// Mirror folders recursively from a single source to many destinations
var mirrorCmd = cli.Command{
Name: "mirror",
Usage: "synchronize object(s) to a remote site",
Action: mainMirror,
OnUsageError: onUsageError,
Before: setGlobalsFromContext,
Flags: append(append(mirrorFlags, encFlags...), globalFlags...),
CustomHelpTemplate: `NAME:
{{.HelpName}} - {{.Usage}}
USAGE:
{{.HelpName}} [FLAGS] SOURCE TARGET
FLAGS:
{{range .VisibleFlags}}{{.}}
{{end}}
ENVIRONMENT VARIABLES:
MC_ENC_KMS: KMS encryption key in the form of (alias/prefix=key).
MC_ENC_S3: S3 encryption key in the form of (alias/prefix=key).
EXAMPLES:
01. Mirror a bucket recursively from MinIO cloud storage to a bucket on Amazon S3 cloud storage.
{{.Prompt}} {{.HelpName}} play/photos/2014 s3/backup-photos
02. Mirror a local folder recursively to Amazon S3 cloud storage.
{{.Prompt}} {{.HelpName}} backup/ s3/archive
03. Only mirror files that are newer than 7 days, 10 hours and 30 minutes to Amazon S3 cloud storage.
{{.Prompt}} {{.HelpName}} --newer-than "7d10h30m" backup/ s3/archive
04. Mirror a bucket from aliased Amazon S3 cloud storage to a folder on Windows.
{{.Prompt}} {{.HelpName}} s3\documents\2014\ C:\backup\2014
05. Mirror a bucket from aliased Amazon S3 cloud storage to a local folder use '--overwrite' to overwrite destination.
{{.Prompt}} {{.HelpName}} --overwrite s3/miniocloud miniocloud-backup
06. Mirror a bucket from MinIO cloud storage to a bucket on Amazon S3 cloud storage and remove any extraneous
files on Amazon S3 cloud storage.
{{.Prompt}} {{.HelpName}} --remove play/photos/2014 s3/backup-photos/2014
07. Continuously mirror a local folder recursively to MinIO cloud storage. '--watch' continuously watches for
new objects, uploads and removes extraneous files on Amazon S3 cloud storage.
{{.Prompt}} {{.HelpName}} --remove --watch /var/lib/backups play/backups
08. Continuously mirror all buckets and objects from site 1 to site 2, removed buckets and objects will be reflected as well.
{{.Prompt}} {{.HelpName}} --remove --watch site1-alias/ site2-alias/
09. Mirror a bucket from aliased Amazon S3 cloud storage to a local folder.
Exclude all .* files and *.temp files when mirroring.
{{.Prompt}} {{.HelpName}} --exclude ".*" --exclude "*.temp" s3/test ~/test
10. Mirror all buckets from aliased Amazon S3 cloud storage to a local folder.
Exclude test* buckets and backup* buckets when mirroring.
{{.Prompt}} {{.HelpName}} --exclude-bucket 'test*' --exclude 'backup*' s3 ~/test
11. Mirror objects newer than 10 days from bucket test to a local folder.
{{.Prompt}} {{.HelpName}} --newer-than 10d s3/test ~/localfolder
12. Mirror objects older than 30 days from Amazon S3 bucket test to a local folder.
{{.Prompt}} {{.HelpName}} --older-than 30d s3/test ~/test
13. Mirror server encrypted objects from Amazon S3 cloud storage to a bucket on Amazon S3 cloud storage
{{.Prompt}} {{.HelpName}} --enc-c "minio/archive=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDA" --enc-c "s3/archive=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5BBB" s3/archive/ minio/archive/
14. Update 'Cache-Control' header on all existing objects recursively.
{{.Prompt}} {{.HelpName}} --attr "Cache-Control=max-age=90000,min-fresh=9000" myminio/video-files myminio/video-files
15. Mirror a local folder recursively to Amazon S3 cloud storage and preserve all local file attributes.
{{.Prompt}} {{.HelpName}} -a backup/ s3/archive
16. Cross mirror between sites in a active-active deployment.
Site-A: {{.Prompt}} {{.HelpName}} --active-active siteA siteB
Site-B: {{.Prompt}} {{.HelpName}} --active-active siteB siteA
`,
}
var (
mirrorTotalOps = promauto.NewCounter(prometheus.CounterOpts{
Name: "mc_mirror_total_s3ops",
Help: "The total number of mirror operations",
})
mirrorTotalUploadedBytes = promauto.NewCounter(prometheus.CounterOpts{
Name: "mc_mirror_total_s3uploaded_bytes",
Help: "The total number of bytes uploaded",
})
mirrorFailedOps = promauto.NewCounter(prometheus.CounterOpts{
Name: "mc_mirror_failed_s3ops",
Help: "The total number of failed mirror operations",
})
mirrorRestarts = promauto.NewCounter(prometheus.CounterOpts{
Name: "mc_mirror_total_restarts",
Help: "The number of mirror restarts",
})
mirrorReplicationDurations = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mc_mirror_replication_duration",
Help: "Histogram of replication time in ms per object sizes",
Buckets: prometheus.ExponentialBuckets(1, 20, 5),
},
[]string{"object_size"},
)
)
const uaMirrorAppName = "mc-mirror"
type mirrorJob struct {
stopCh chan struct{}
// the global watcher object, which receives notifications of created
// and deleted files
watcher *Watcher
// Hold operation status information
status Status
parallel *ParallelManager
// channel for status messages
statusCh chan URLs
TotalObjects int64
TotalBytes int64
sourceURL string
targetURL string
opts mirrorOptions
}
// mirrorMessage container for file mirror messages
type mirrorMessage struct {
Status string `json:"status"`
Source string `json:"source"`
Target string `json:"target"`
Size int64 `json:"size"`
TotalCount int64 `json:"totalCount"`
TotalSize int64 `json:"totalSize"`
EventTime string `json:"eventTime"`
EventType notification.EventType `json:"eventType"`
}
// String colorized mirror message
func (m mirrorMessage) String() string {
var msg string
if m.EventTime != "" {
msg = console.Colorize("Time", fmt.Sprintf("[%s] ", m.EventTime))
}
switch m.EventType {
case notification.ObjectRemovedDelete:
return msg + "Removed " + console.Colorize("Removed", fmt.Sprintf("`%s`", m.Target))
case notification.ObjectRemovedDeleteMarkerCreated:
return msg + "Removed (Delete Marker)" + console.Colorize("Removed", fmt.Sprintf("`%s`", m.Target))
case notification.ILMDelMarkerExpirationDelete:
return msg + "Removed (ILM)" + console.Colorize("Removed", fmt.Sprintf("`%s`", m.Target))
}
if m.EventTime == "" {
return console.Colorize("Mirror", fmt.Sprintf("`%s` -> `%s`", m.Source, m.Target))
}
msg += console.Colorize("Size", fmt.Sprintf("%6s ", humanize.IBytes(uint64(m.Size))))
msg += console.Colorize("Mirror", fmt.Sprintf("`%s` -> `%s`", m.Source, m.Target))
return msg
}
// JSON jsonified mirror message
func (m mirrorMessage) JSON() string {
m.Status = "success"
mirrorMessageBytes, e := json.MarshalIndent(m, "", " ")
fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
return string(mirrorMessageBytes)
}
func (mj *mirrorJob) doCreateBucket(ctx context.Context, sURLs URLs) URLs {
if mj.opts.isFake {
return sURLs.WithError(nil)
}
// Construct proper path with alias.
aliasedURL := filepath.Join(sURLs.TargetAlias, sURLs.TargetContent.URL.Path)
clnt, pErr := newClient(aliasedURL)
if pErr != nil {
return sURLs.WithError(pErr)
}
err := clnt.MakeBucket(ctx, "", mj.opts.isOverwrite, false)
if err != nil {
return sURLs.WithError(err)
}
return sURLs.WithError(nil)
}
func (mj *mirrorJob) doDeleteBucket(ctx context.Context, sURLs URLs) URLs {
if mj.opts.isFake {
return sURLs.WithError(nil)
}
// Construct proper path with alias.
aliasedURL := filepath.Join(sURLs.TargetAlias, sURLs.TargetContent.URL.Path)
clnt, pErr := newClient(aliasedURL)
if pErr != nil {
return sURLs.WithError(pErr)
}
contentCh := make(chan *ClientContent, 1)
contentCh <- &ClientContent{URL: clnt.GetURL()}
close(contentCh)
for result := range clnt.Remove(ctx, false, true, false, false, contentCh) {
if result.Err != nil {
return sURLs.WithError(result.Err)
}
}
return sURLs.WithError(nil)
}
// doRemove - removes files on target.
func (mj *mirrorJob) doRemove(ctx context.Context, sURLs URLs, event EventInfo) URLs {
if mj.opts.isFake {
return sURLs.WithError(nil)
}
// Construct proper path with alias.
targetWithAlias := filepath.Join(sURLs.TargetAlias, sURLs.TargetContent.URL.Path)
clnt, pErr := newClient(targetWithAlias)
if pErr != nil {
return sURLs.WithError(pErr)
}
if sURLs.SourceAlias != "" {
clnt.AddUserAgent(uaMirrorAppName+":"+sURLs.SourceAlias, ReleaseTag)
} else {
clnt.AddUserAgent(uaMirrorAppName, ReleaseTag)
}
contentCh := make(chan *ClientContent, 1)
contentCh <- &ClientContent{URL: *newClientURL(sURLs.TargetContent.URL.Path)}
close(contentCh)
isRemoveBucket := false
resultCh := clnt.Remove(ctx, false, isRemoveBucket, false, false, contentCh)
for result := range resultCh {
if result.Err != nil {
switch result.Err.ToGoError().(type) {
case PathInsufficientPermission:
// Ignore Permission error.
continue
}
return sURLs.WithError(result.Err)
}
targetPath := filepath.ToSlash(filepath.Join(sURLs.TargetAlias, sURLs.TargetContent.URL.Path))
mj.status.PrintMsg(mirrorMessage{
Target: targetPath,
TotalCount: sURLs.TotalCount,
TotalSize: sURLs.TotalSize,
EventTime: event.Time,
EventType: event.Type,
})
}
return sURLs.WithError(nil)
}
// doMirror - Mirror an object to multiple destination. URLs status contains a copy of sURLs and error if any.
func (mj *mirrorJob) doMirrorWatch(ctx context.Context, targetPath string, tgtSSE encrypt.ServerSide, sURLs URLs, event EventInfo) URLs {
shouldQueue := false
if !mj.opts.isOverwrite && !mj.opts.activeActive {
targetClient, err := newClient(targetPath)
if err != nil {
// cannot create targetclient
return sURLs.WithError(err)
}
_, err = targetClient.Stat(ctx, StatOptions{sse: tgtSSE})
if err == nil {
if !sURLs.SourceContent.RetentionEnabled && !sURLs.SourceContent.LegalHoldEnabled {
return sURLs.WithError(probe.NewError(ObjectAlreadyExists{}))
}
} // doesn't exist
shouldQueue = true
}
if shouldQueue || mj.opts.isOverwrite || mj.opts.activeActive {
// adjust total, because we want to show progress of
// the item still queued to be copied.
mj.status.Add(sURLs.SourceContent.Size)
mj.status.SetTotal(mj.status.Get()).Update()
mj.status.AddCounts(1)
sURLs.TotalSize = mj.status.Get()
sURLs.TotalCount = mj.status.GetCounts()
return mj.doMirror(ctx, sURLs, event)
}
return sURLs.WithError(probe.NewError(ObjectAlreadyExists{}))
}
func convertSizeToTag(size int64) string {
switch {
case size < 1024:
return "LESS_THAN_1_KiB"
case size < 1024*1024:
return "LESS_THAN_1_MiB"
case size < 10*1024*1024:
return "LESS_THAN_10_MiB"
case size < 100*1024*1024:
return "LESS_THAN_100_MiB"
case size < 1024*1024*1024:
return "LESS_THAN_1_GiB"
default:
return "GREATER_THAN_1_GiB"
}
}
// doMirror - Mirror an object to multiple destination. URLs status contains a copy of sURLs and error if any.
func (mj *mirrorJob) doMirror(ctx context.Context, sURLs URLs, event EventInfo) URLs {
if sURLs.Error != nil { // Erroneous sURLs passed.
return sURLs.WithError(sURLs.Error.Trace())
}
// For a fake mirror make sure we update respective progress bars
// and accounting readers under relevant conditions.
if mj.opts.isFake {
if sURLs.SourceContent != nil {
mj.status.Add(sURLs.SourceContent.Size)
}
mj.status.Update()
return sURLs.WithError(nil)
}
sourceAlias := sURLs.SourceAlias
sourceURL := sURLs.SourceContent.URL
targetAlias := sURLs.TargetAlias
targetURL := sURLs.TargetContent.URL
length := sURLs.SourceContent.Size
mj.status.SetCaption(sourceURL.String() + ":")
// Initialize target metadata.
sURLs.TargetContent.Metadata = make(map[string]string)
if mj.opts.storageClass != "" {
sURLs.TargetContent.StorageClass = mj.opts.storageClass
}
if mj.opts.activeActive {
srcModTime := getSourceModTimeKey(sURLs.SourceContent.Metadata)
// If the source object already has source modtime attribute set, then
// use it in target. Otherwise use the S3 modtime instead.
if srcModTime != "" {
sURLs.TargetContent.Metadata[activeActiveSourceModTimeKey] = srcModTime
} else {
sURLs.TargetContent.Metadata[activeActiveSourceModTimeKey] = sURLs.SourceContent.Time.Format(time.RFC3339Nano)
}
}
// Initialize additional target user metadata.
sURLs.TargetContent.UserMetadata = mj.opts.userMetadata
sourcePath := filepath.ToSlash(filepath.Join(sourceAlias, sourceURL.Path))
targetPath := filepath.ToSlash(filepath.Join(targetAlias, targetURL.Path))
if !mj.opts.isSummary {
mj.status.PrintMsg(mirrorMessage{
Source: sourcePath,
Target: targetPath,
Size: length,
TotalCount: sURLs.TotalCount,
TotalSize: sURLs.TotalSize,
EventTime: event.Time,
EventType: event.Type,
})
}
sURLs.MD5 = mj.opts.md5
sURLs.checksum = mj.opts.checksum
sURLs.DisableMultipart = mj.opts.disableMultipart
var ret URLs
if !mj.opts.isRetriable {
now := time.Now()
ret = uploadSourceToTargetURL(ctx, uploadSourceToTargetURLOpts{urls: sURLs, progress: mj.status, encKeyDB: mj.opts.encKeyDB, preserve: mj.opts.isMetadata, isZip: false})
if ret.Error == nil {
durationMs := time.Since(now).Milliseconds()
mirrorReplicationDurations.With(prometheus.Labels{"object_size": convertSizeToTag(sURLs.SourceContent.Size)}).Observe(float64(durationMs))
}
return ret
}
newRetryManager(ctx, time.Second, 3).retry(func(rm *retryManager) *probe.Error {
if rm.retries > 0 {
printMsg(retryMessage{
SourceURL: sURLs.SourceContent.URL.String(),
TargetURL: sURLs.TargetContent.URL.String(),
Retries: rm.retries,
})
}
now := time.Now()
ret = uploadSourceToTargetURL(ctx, uploadSourceToTargetURLOpts{urls: sURLs, progress: mj.status, encKeyDB: mj.opts.encKeyDB, preserve: mj.opts.isMetadata, isZip: false})
if ret.Error == nil {
durationMs := time.Since(now).Milliseconds()
mirrorReplicationDurations.With(prometheus.Labels{"object_size": convertSizeToTag(sURLs.SourceContent.Size)}).Observe(float64(durationMs))
}
return ret.Error
})
return ret
}
// Update progress status
func (mj *mirrorJob) monitorMirrorStatus(cancel context.CancelFunc) (errDuringMirror bool) {
// now we want to start the progress bar
mj.status.Start()
defer mj.status.Finish()
var cancelInProgress bool
defer func() {
// make sure we always cancel the context
if !cancelInProgress {
cancel()
}
}()
for sURLs := range mj.statusCh {
if cancelInProgress {
// Do not need to print any error after
// canceling the context, just draining
// the status channel here.
continue
}
// Update prometheus fields
mirrorTotalOps.Inc()
if sURLs.Error != nil {
var ignoreErr bool
switch {
case sURLs.SourceContent != nil:
if isErrIgnored(sURLs.Error) {
ignoreErr = true
} else {
switch sURLs.Error.ToGoError().(type) {
case PathInsufficientPermission:
// Ignore Permission error.
ignoreErr = true
}
if !ignoreErr {
errorIf(sURLs.Error.Trace(sURLs.SourceContent.URL.String()),
"Failed to copy `%s`.", sURLs.SourceContent.URL)
}
}
case sURLs.TargetContent != nil:
// When sURLs.SourceContent is nil, we know that we have an error related to removing
errorIf(sURLs.Error.Trace(sURLs.TargetContent.URL.String()),
"Failed to remove `%s`.", sURLs.TargetContent.URL.String())
default:
if strings.Contains(sURLs.Error.ToGoError().Error(), "Overwrite not allowed") {
ignoreErr = true
}
if sURLs.ErrorCond == differInUnknown {
errorIf(sURLs.Error.Trace(), "Failed to perform mirroring")
} else {
errorIf(sURLs.Error.Trace(),
"Failed to perform mirroring, with error condition (%s)", sURLs.ErrorCond)
}
}
if !ignoreErr {
mirrorFailedOps.Inc()
errDuringMirror = true
// Quit mirroring if --skip-errors is not passed
if !mj.opts.skipErrors {
cancel()
cancelInProgress = true
}
}
continue
}
if sURLs.SourceContent != nil {
mirrorTotalUploadedBytes.Add(float64(sURLs.SourceContent.Size))
}
}
return
}
func (mj *mirrorJob) watchMirrorEvents(ctx context.Context, events []EventInfo) {
for _, event := range events {
// It will change the expanded alias back to the alias
// again, by replacing the sourceUrlFull with the sourceAlias.
// This url will be used to mirror.
sourceAlias, sourceURLFull, _ := mustExpandAlias(mj.sourceURL)
// If the passed source URL points to fs, fetch the absolute src path
// to correctly calculate targetPath
if sourceAlias == "" {
tmpSrcURL, e := filepath.Abs(sourceURLFull)
if e == nil {
sourceURLFull = tmpSrcURL
}
}
eventPath := event.Path
switch runtime.GOOS {
case "darwin":
// Strip the prefixes in the event path. Happens in darwin OS only
eventPath = eventPath[strings.Index(eventPath, sourceURLFull):]
case "windows":
// Shared folder as source URL and if event path is an absolute path.
eventPath = getEventPathURLWin(mj.sourceURL, eventPath)
}
sourceURL := newClientURL(eventPath)
// build target path, it is the relative of the eventPath with the sourceUrl
// joined to the targetURL.
sourceSuffix := strings.TrimPrefix(eventPath, sourceURLFull)
// Skip the object, if it matches the Exclude options provided
if matchExcludeOptions(mj.opts.excludeOptions, sourceSuffix, sourceURL.Type) {
continue
}
// Skip the bucket, if it matches the Exclude options provided
if matchExcludeBucketOptions(mj.opts.excludeBuckets, sourceSuffix) {
continue
}
sc, ok := event.UserMetadata["x-amz-storage-class"]
if ok {
var found bool
for _, esc := range mj.opts.excludeStorageClasses {
if esc == sc {
found = true
break
}
}
if found {
continue
}
}
targetPath := urlJoinPath(mj.targetURL, sourceSuffix)
// newClient needs the unexpanded path, newCLientURL needs the expanded path
targetAlias, expandedTargetPath, _ := mustExpandAlias(targetPath)
targetURL := newClientURL(expandedTargetPath)
tgtSSE := getSSE(targetPath, mj.opts.encKeyDB[targetAlias])
if strings.HasPrefix(string(event.Type), "s3:ObjectCreated:") {
sourceModTime, _ := time.Parse(time.RFC3339Nano, event.Time)
mirrorURL := URLs{
SourceAlias: sourceAlias,
SourceContent: &ClientContent{
URL: *sourceURL,
RetentionEnabled: event.Type == notification.EventType("s3:ObjectCreated:PutRetention"),
LegalHoldEnabled: event.Type == notification.EventType("s3:ObjectCreated:PutLegalHold"),
Size: event.Size,
Time: sourceModTime,
Metadata: event.UserMetadata,
},
TargetAlias: targetAlias,
TargetContent: &ClientContent{URL: *targetURL},
MD5: mj.opts.md5,
checksum: mj.opts.checksum,
DisableMultipart: mj.opts.disableMultipart,
encKeyDB: mj.opts.encKeyDB,
}
if mj.opts.activeActive &&
event.Type != notification.ObjectCreatedCopy &&
event.Type != notification.ObjectCreatedCompleteMultipartUpload &&
(getSourceModTimeKey(mirrorURL.SourceContent.Metadata) != "" ||
getSourceModTimeKey(mirrorURL.SourceContent.UserMetadata) != "") {
// If source has active-active attributes, it means that the
// object was uploaded by "mc mirror", hence ignore the event
// to avoid copying it.
continue
}
mj.parallel.queueTask(func() URLs {
return mj.doMirrorWatch(ctx, targetPath, tgtSSE, mirrorURL, event)
}, mirrorURL.SourceContent.Size)
} else if event.Type == notification.ObjectRemovedDelete ||
event.Type == notification.ObjectRemovedDeleteMarkerCreated ||
event.Type == notification.ILMDelMarkerExpirationDelete {
if targetAlias != "" && strings.Contains(event.UserAgent, uaMirrorAppName+":"+targetAlias) {
// Ignore delete cascading delete events if cyclical.
continue
}
mirrorURL := URLs{
SourceAlias: sourceAlias,
SourceContent: nil,
TargetAlias: targetAlias,
TargetContent: &ClientContent{URL: *targetURL},
MD5: mj.opts.md5,
checksum: mj.opts.checksum,
DisableMultipart: mj.opts.disableMultipart,
encKeyDB: mj.opts.encKeyDB,
}
mirrorURL.TotalCount = mj.status.GetCounts()
mirrorURL.TotalSize = mj.status.Get()
if mirrorURL.TargetContent != nil && (mj.opts.isRemove || mj.opts.activeActive) {
mj.parallel.queueTask(func() URLs {
return mj.doRemove(ctx, mirrorURL, event)
}, 0)
}
} else if event.Type == notification.BucketCreatedAll {
mirrorURL := URLs{
SourceAlias: sourceAlias,
SourceContent: &ClientContent{URL: *sourceURL},
TargetAlias: targetAlias,
TargetContent: &ClientContent{URL: *targetURL},
}
mj.parallel.queueTaskWithBarrier(func() URLs {
return mj.doCreateBucket(ctx, mirrorURL)
}, 0)
} else if event.Type == notification.BucketRemovedAll && mj.opts.isRemove {
mirrorURL := URLs{
TargetAlias: targetAlias,
TargetContent: &ClientContent{URL: *targetURL},
}
mj.parallel.queueTaskWithBarrier(func() URLs {
return mj.doDeleteBucket(ctx, mirrorURL)
}, 0)
}
}
}
// this goroutine will watch for notifications, and add modified objects to the queue
func (mj *mirrorJob) watchMirror(ctx context.Context) {
defer mj.watcher.Stop()
for {
select {
case events, ok := <-mj.watcher.Events():
if !ok {
return
}
mj.watchMirrorEvents(ctx, events)
case err, ok := <-mj.watcher.Errors():
if !ok {
return
}
switch err.ToGoError().(type) {
case APINotImplemented:
errorIf(err.Trace(),
"Unable to Watch on source, perhaps source doesn't support Watching for events")
return
}
if err != nil {
mj.parallel.queueTask(func() URLs {
return URLs{Error: err}
}, 0)
}
case <-ctx.Done():
return
}
}
}
func (mj *mirrorJob) watchURL(ctx context.Context, sourceClient Client) *probe.Error {
return mj.watcher.Join(ctx, sourceClient, true)
}
// Fetch urls that need to be mirrored
func (mj *mirrorJob) startMirror(ctx context.Context) {
URLsCh := prepareMirrorURLs(ctx, mj.sourceURL, mj.targetURL, mj.opts)
for {
select {
case sURLs, ok := <-URLsCh:
if !ok {
return
}
if sURLs.Error != nil {
mj.statusCh <- sURLs
continue
}
if sURLs.SourceContent != nil {
if isOlder(sURLs.SourceContent.Time, mj.opts.olderThan) {
continue
}
if isNewer(sURLs.SourceContent.Time, mj.opts.newerThan) {
continue
}
}
if sURLs.SourceContent != nil {
mj.status.Add(sURLs.SourceContent.Size)
}
mj.status.SetTotal(mj.status.Get()).Update()
mj.status.AddCounts(1)
// Save total count.
sURLs.TotalCount = mj.status.GetCounts()
// Save totalSize.
sURLs.TotalSize = mj.status.Get()
if sURLs.SourceContent != nil {
mj.parallel.queueTask(func() URLs {
return mj.doMirror(ctx, sURLs, EventInfo{})
}, sURLs.SourceContent.Size)
} else if sURLs.TargetContent != nil && mj.opts.isRemove {
mj.parallel.queueTask(func() URLs {
return mj.doRemove(ctx, sURLs, EventInfo{})
}, 0)
}
case <-ctx.Done():
return
case <-mj.stopCh:
return
}
}
}
// when using a struct for copying, we could save a lot of passing of variables
func (mj *mirrorJob) mirror(ctx context.Context) bool {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(ctx)
// Starts watcher loop for watching for new events.
if mj.opts.isWatch {
wg.Add(1)
go func() {
defer wg.Done()
mj.watchMirror(ctx)
}()
}
// Start mirroring.
wg.Add(1)
go func() {
defer wg.Done()
// startMirror locks and blocks itself.
mj.startMirror(ctx)
}()
// Close statusCh when both watch & mirror quits
go func() {
wg.Wait()
mj.parallel.stopAndWait()
close(mj.statusCh)
}()
return mj.monitorMirrorStatus(cancel)
}
func newMirrorJob(srcURL, dstURL string, opts mirrorOptions) *mirrorJob {
mj := mirrorJob{
stopCh: make(chan struct{}),
sourceURL: srcURL,
targetURL: dstURL,
opts: opts,
statusCh: make(chan URLs),
watcher: NewWatcher(UTCNow()),
}
mj.parallel = newParallelManager(mj.statusCh)
// we'll define the status to use here,
// do we want the quiet status? or the progressbar
if globalQuiet || opts.isSummary {
mj.status = NewQuietStatus(mj.parallel)
} else if globalJSON {
mj.status = NewQuietStatus(mj.parallel)
} else {
mj.status = NewProgressStatus(mj.parallel)
}
return &mj
}
// copyBucketPolicies - copy policies from source to dest
func copyBucketPolicies(ctx context.Context, srcClt, dstClt Client, isOverwrite bool) *probe.Error {
rules, err := srcClt.GetAccessRules(ctx)
if err != nil {
switch err.ToGoError().(type) {
case APINotImplemented:
return nil
}
return err
}
// Set found rules to target bucket if permitted
for _, r := range rules {
originalRule, _, err := dstClt.GetAccess(ctx)
if err != nil {
return err
}
// Set rule only if it doesn't exist in the target bucket
// or force flag is activated
if originalRule == "none" || isOverwrite {
err = dstClt.SetAccess(ctx, r, false)
if err != nil {
return err
}
}
}
return nil
}
func getEventPathURLWin(srcURL, eventPath string) string {
// A rename or move or sometimes even write event sets eventPath as an absolute filepath.
// If the watch folder is a shared folder the write events show the entire event path,
// from which we need to deduce the correct path relative to the source URL
var eventRelPath, lastPathPrefix string
var lastPathPrefixPos int
sourceURLpathList := strings.Split(srcURL, slashSeperator)
lenSrcURLSlice := len(sourceURLpathList)
shdModifyEventPath := filepath.IsAbs(eventPath) && !filepath.IsAbs(srcURL) && lenSrcURLSlice > 1
if shdModifyEventPath {
lastPathPrefix = sourceURLpathList[lenSrcURLSlice-1]
lastPathPrefixPos = strings.Index(eventPath, lastPathPrefix)
}
canModifyEventPath := shdModifyEventPath && lastPathPrefix != "" && lastPathPrefixPos > 0
canModifyEventPath = canModifyEventPath && lastPathPrefixPos+len(lastPathPrefix) < len(eventPath)
if canModifyEventPath {
eventRelPath = filepath.ToSlash(eventPath[lastPathPrefixPos+len(lastPathPrefix):])
eventPath = srcURL + eventRelPath
}
return eventPath
}
// runMirror - mirrors all buckets to another S3 server
func runMirror(ctx context.Context, srcURL, dstURL string, cli *cli.Context, encKeyDB map[string][]prefixSSEPair) bool {
// Parse metadata.
userMetadata := make(map[string]string)
if cli.String("attr") != "" {
var err *probe.Error
userMetadata, err = getMetaDataEntry(cli.String("attr"))
fatalIf(err, "Unable to parse attribute %v", cli.String("attr"))
}
srcClt, err := newClient(srcURL)
fatalIf(err, "Unable to initialize `"+srcURL+"`.")
dstClt, err := newClient(dstURL)
fatalIf(err, "Unable to initialize `"+dstURL+"`.")
// This is kept for backward compatibility, `--force` means --overwrite.
isOverwrite := cli.Bool("force")
if !isOverwrite {
isOverwrite = cli.Bool("overwrite")
}
isWatch := cli.Bool("watch") || cli.Bool("multi-master") || cli.Bool("active-active")
isRemove := cli.Bool("remove")
md5, checksum := parseChecksum(cli)
// preserve is also expected to be overwritten if necessary
isMetadata := cli.Bool("a") || isWatch || len(userMetadata) > 0
isFake := cli.Bool("fake") || cli.Bool("dry-run")
mopts := mirrorOptions{
isFake: isFake,
isRemove: isRemove,
isOverwrite: isOverwrite,
isWatch: isWatch,
isMetadata: isMetadata,
isSummary: cli.Bool("summary"),
isRetriable: cli.Bool("retry"),
md5: md5,
checksum: checksum,
disableMultipart: cli.Bool("disable-multipart"),
skipErrors: cli.Bool("skip-errors"),
excludeOptions: cli.StringSlice("exclude"),
excludeBuckets: cli.StringSlice("exclude-bucket"),
excludeStorageClasses: cli.StringSlice("exclude-storageclass"),
olderThan: cli.String("older-than"),
newerThan: cli.String("newer-than"),
storageClass: cli.String("storage-class"),
userMetadata: userMetadata,
encKeyDB: encKeyDB,
activeActive: isWatch,
}
// If we are not using active/active and we are not removing
// files from the remote, then we can exit the listing once
// local files have been checked for diff.
if !mopts.activeActive && !mopts.isRemove {
mopts.sourceListingOnly = true
}
// Create a new mirror job and execute it
mj := newMirrorJob(srcURL, dstURL, mopts)
preserve := cli.Bool("preserve")
createDstBuckets := dstClt.GetURL().Type == objectStorage && dstClt.GetURL().Path == string(dstClt.GetURL().Separator)
mirrorSrcBuckets := srcClt.GetURL().Type == objectStorage && srcClt.GetURL().Path == string(srcClt.GetURL().Separator)
mirrorBucketsToBuckets := mirrorSrcBuckets && createDstBuckets
if mirrorSrcBuckets || createDstBuckets {
// Synchronize buckets using dirDifference function
for d := range bucketDifference(ctx, srcClt, dstClt, mj.opts) {
if d.Error != nil {
if mj.opts.activeActive {
errorIf(d.Error, "Failed to start mirroring.. retrying")
return true
}
mj.status.fatalIf(d.Error, "Failed to start mirroring.")
}
if d.Diff == differInSecond {
diffBucket := strings.TrimPrefix(d.SecondURL, dstClt.GetURL().String())
if !isFake && isRemove {
aliasedDstBucket := path.Join(dstURL, diffBucket)
err := deleteBucket(ctx, aliasedDstBucket, false)
mj.status.fatalIf(err, "Failed to start mirroring.")
}
continue
}
sourceSuffix := strings.TrimPrefix(d.FirstURL, srcClt.GetURL().String())
newSrcURL := path.Join(srcURL, sourceSuffix)
newTgtURL := path.Join(dstURL, sourceSuffix)
newSrcClt, _ := newClient(newSrcURL)
newDstClt, _ := newClient(newTgtURL)
if d.Diff == differInFirst {
var (
withLock bool
mode minio.RetentionMode
validity uint64
unit minio.ValidityUnit
err *probe.Error
)
if preserve && mirrorBucketsToBuckets {
_, mode, validity, unit, err = newSrcClt.GetObjectLockConfig(ctx)
if err == nil {
withLock = true
}
}
mj.status.PrintMsg(mirrorMessage{
Source: newSrcURL,
Target: newTgtURL,
})
if mj.opts.isFake {
continue
}
// Skip create bucket, if it matches the Exclude options provided
if matchExcludeBucketOptions(mopts.excludeBuckets, sourceSuffix) {
continue
}
// Bucket only exists in the source, create the same bucket in the destination
if err := newDstClt.MakeBucket(ctx, cli.String("region"), false, withLock); err != nil {
errorIf(err, "Unable to create bucket at `%s`.", newTgtURL)
continue
}
if preserve && mirrorBucketsToBuckets {
// object lock configuration set on bucket
if mode != "" {
err = newDstClt.SetObjectLockConfig(ctx, mode, validity, unit)
errorIf(err, "Unable to set object lock config in `%s`.", newTgtURL)
if err != nil && mj.opts.activeActive {
return true
}
if err == nil {
mj.opts.md5 = true
mj.opts.checksum = minio.ChecksumNone
}
}
errorIf(copyBucketPolicies(ctx, newSrcClt, newDstClt, isOverwrite),
"Unable to copy bucket policies to `%s`.", newDstClt.GetURL())
}
}
}
}
if mj.opts.isWatch {
// monitor mode will watch the source folders for changes,
// and queue them for copying.
if err := mj.watchURL(ctx, srcClt); err != nil {
if mj.opts.activeActive {
errorIf(err, "Failed to start monitoring.. retrying")
return true
}
mj.status.fatalIf(err, "Failed to start monitoring.")
}
}
return mj.mirror(ctx)
}
// Main entry point for mirror command.
func mainMirror(cliCtx *cli.Context) error {
// Additional command specific theme customization.
console.SetColor("Mirror", color.New(color.FgGreen, color.Bold))
ctx, cancelMirror := context.WithCancel(globalContext)
defer cancelMirror()
encKeyDB, err := validateAndCreateEncryptionKeys(cliCtx)
fatalIf(err, "Unable to parse encryption keys.")
// check 'mirror' cli arguments.
srcURL, tgtURL := checkMirrorSyntax(ctx, cliCtx, encKeyDB)
if prometheusAddress := cliCtx.String("monitoring-address"); prometheusAddress != "" {
http.Handle("/metrics", promhttp.Handler())
go func() {
if e := http.ListenAndServe(prometheusAddress, nil); e != nil {
fatalIf(probe.NewError(e), "Unable to setup monitoring endpoint.")
}
}()
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
select {
case <-ctx.Done():
return exitStatus(globalErrorExitStatus)
default:
errorDetected := runMirror(ctx, srcURL, tgtURL, cliCtx, encKeyDB)
if cliCtx.Bool("watch") || cliCtx.Bool("multi-master") || cliCtx.Bool("active-active") {
mirrorRestarts.Inc()
time.Sleep(time.Duration(r.Float64() * float64(2*time.Second)))
continue
}
if errorDetected {
return exitStatus(globalErrorExitStatus)
}
return nil
}
}
}