Files
seaweedfs/weed/shell/command_remote_copy_local_test.go
T
e64d01825f feat(shell): add -delete option to remote.copy.local (#10228)
* shell: add -delete option to remote.copy.local

Add a -delete flag to remote.copy.local that removes files and
directories from remote storage when they no longer exist locally,
similar to rsync --delete. This makes the command usable for
scheduled one-shot backups that also propagate local deletions.

- -include/-exclude patterns also limit which remote files are deleted
- size/age filters only apply to copying, since remote entries have no
  local attributes to filter on
- orphaned remote directories are removed after their contents,
  deepest first, and only when no name filter is set (a recursive
  RemoveDirectory could otherwise remove intentionally kept files)
- deletion is skipped entirely if any copy failed
- -dryRun shows DELETE lines for review before committing to anything

Fixes seaweedfs/seaweedfs#8609

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* shell: fix remote.copy.local -delete deleting files outside -dir

Traverse lists remote objects by key prefix with no delimiter, so a -dir
pointing at a subdirectory also matches siblings that merely share its
name prefix (foo -> foobar). Those are not under the local traversal
root, so -delete treated them as extraneous and removed them. Scope
deletion candidates to paths under dirToCopy.

Also drop the directory-removal path: RemoveDirectory is a no-op on every
backend and Traverse never emits directory entries, so it only ever
printed success for work it never did.

---------

Co-authored-by: Jason Lin <jason@jtx.com.tw>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-07-04 10:50:32 -07:00

268 lines
7.6 KiB
Go

package shell
import (
"reflect"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func newTestFileFilter(include, exclude string) *FileFilter {
defaultInt64 := int64(-1)
return &FileFilter{
include: &include,
exclude: &exclude,
minSize: &defaultInt64,
maxSize: &defaultInt64,
minAge: &defaultInt64,
maxAge: &defaultInt64,
minCacheAge: &defaultInt64,
now: time.Now().Unix(),
}
}
func testFileEntry(name string) *filer_pb.Entry {
return &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{},
}
}
func testDirEntry(name string) *filer_pb.Entry {
return &filer_pb.Entry{
Name: name,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
}
}
func TestPlanLocalToRemoteSync(t *testing.T) {
tests := []struct {
name string
dirToCopy string
localFiles map[string]*filer_pb.Entry
remoteFiles map[string]bool // path -> isDirectory
forceUpdate bool
deleteExtraneous bool
fileFilter *FileFilter
wantFilesToCopy []string
wantFilesToDelete []string
}{
{
name: "copy local-only files, no delete flag",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
"/mnt/b.txt": testFileEntry("b.txt"),
},
remoteFiles: map[string]bool{
"/mnt/b.txt": false,
"/mnt/orphan.txt": false,
},
fileFilter: newTestFileFilter("", ""),
wantFilesToCopy: []string{"/mnt/a.txt"},
},
{
name: "delete flag removes remote-only files",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
"/mnt/orphan.txt": false,
"/mnt/orphan2.txt": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", ""),
wantFilesToDelete: []string{"/mnt/orphan.txt", "/mnt/orphan2.txt"},
},
{
name: "delete with empty local storage removes all remote files",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{},
remoteFiles: map[string]bool{
"/mnt/orphan.txt": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", ""),
wantFilesToDelete: []string{"/mnt/orphan.txt"},
},
{
name: "delete respects include pattern",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.pdf": testFileEntry("a.pdf"),
},
remoteFiles: map[string]bool{
"/mnt/a.pdf": false,
"/mnt/orphan.pdf": false,
"/mnt/keep.txt": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("*.pdf", ""),
wantFilesToDelete: []string{"/mnt/orphan.pdf"},
},
{
name: "delete respects exclude pattern",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
"/mnt/orphan.txt": false,
"/mnt/keep.bak": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", "*.bak"),
wantFilesToDelete: []string{"/mnt/orphan.txt"},
},
{
name: "delete ignores size/age filters",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
"/mnt/orphan.txt": false,
},
deleteExtraneous: true,
fileFilter: func() *FileFilter {
ff := newTestFileFilter("", "")
minSize := int64(1000000) // would exclude orphan.txt from copying if it applied to deletion
ff.minSize = &minSize
return ff
}(),
wantFilesToDelete: []string{"/mnt/orphan.txt"},
},
{
name: "delete removes orphaned files, directory entries left alone",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
"/mnt/old": true,
"/mnt/old/sub": true,
"/mnt/old/sub/orphan.txt": false,
"/mnt/old/sub2": true,
"/mnt/old/sub2/orphan.txt": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", ""),
wantFilesToDelete: []string{
"/mnt/old/sub/orphan.txt",
"/mnt/old/sub2/orphan.txt",
},
},
{
name: "exclude pattern protects remote files under nested directory",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
"/mnt/old": true,
"/mnt/old/orphan.txt": false,
"/mnt/old/keep.bak": false,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", "*.bak"),
wantFilesToDelete: []string{"/mnt/old/orphan.txt"},
},
{
name: "remote files outside -dir are never deleted",
dirToCopy: "/mnt/foo",
localFiles: map[string]*filer_pb.Entry{
"/mnt/foo/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/foo/a.txt": false,
"/mnt/foo/orphan.txt": false,
"/mnt/foobar/b.txt": false, // sibling sharing the "foo" key prefix, must be left alone
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", ""),
wantFilesToDelete: []string{"/mnt/foo/orphan.txt"},
},
{
name: "directory present locally is not deleted",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/dir": testDirEntry("dir"),
},
remoteFiles: map[string]bool{
"/mnt/dir": true,
},
deleteExtraneous: true,
fileFilter: newTestFileFilter("", ""),
},
{
name: "forceUpdate copies files that exist on remote",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{
"/mnt/a.txt": false,
},
forceUpdate: true,
fileFilter: newTestFileFilter("", ""),
wantFilesToCopy: []string{"/mnt/a.txt"},
},
{
name: "local directories are not copied",
dirToCopy: "/mnt",
localFiles: map[string]*filer_pb.Entry{
"/mnt/dir": testDirEntry("dir"),
"/mnt/a.txt": testFileEntry("a.txt"),
},
remoteFiles: map[string]bool{},
fileFilter: newTestFileFilter("", ""),
wantFilesToCopy: []string{"/mnt/a.txt"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan := planLocalToRemoteSync(tt.localFiles, tt.remoteFiles, util.FullPath(tt.dirToCopy), tt.forceUpdate, tt.deleteExtraneous, tt.fileFilter)
if !reflect.DeepEqual(plan.filesToCopy, tt.wantFilesToCopy) {
t.Errorf("filesToCopy = %v, want %v", plan.filesToCopy, tt.wantFilesToCopy)
}
if !reflect.DeepEqual(plan.filesToDelete, tt.wantFilesToDelete) {
t.Errorf("filesToDelete = %v, want %v", plan.filesToDelete, tt.wantFilesToDelete)
}
})
}
}
func TestFileFilter_matchesName(t *testing.T) {
tests := []struct {
name string
include string
exclude string
fileName string
want bool
}{
{"no filters", "", "", "a.txt", true},
{"include match", "*.pdf", "", "a.pdf", true},
{"include mismatch", "*.pdf", "", "a.txt", false},
{"exclude match", "", "*.tmp", "a.tmp", false},
{"exclude mismatch", "", "*.tmp", "a.txt", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ff := newTestFileFilter(tt.include, tt.exclude)
if got := ff.matchesName(tt.fileName); got != tt.want {
t.Errorf("matchesName(%q) = %v, want %v", tt.fileName, got, tt.want)
}
})
}
}