Browse Source
Merge master into ec-disk-type-support
Merge master into ec-disk-type-support
Merged changes from master including: - Comments about topology collection strategy for EC evacuation - Kept multi-disk-type iteration for evacuation - Function signature now includes both diskType and writer parameters
98 changed files with 5371 additions and 731 deletions
-
1.github/workflows/container_release_unified.yml
-
74.github/workflows/helm_ci.yml
-
10.github/workflows/s3tests.yml
-
93.github/workflows/sftp-tests.yml
-
4Makefile
-
65README.md
-
2docker/compose/local-s3tests-compose.yml
-
2docker/compose/test-tarantool-filer.yml
-
2docker/compose/test-ydb-filer.yml
-
2k8s/charts/seaweedfs/Chart.yaml
-
116k8s/charts/seaweedfs/templates/all-in-one/all-in-one-deployment.yaml
-
25k8s/charts/seaweedfs/templates/all-in-one/all-in-one-pvc.yaml
-
18k8s/charts/seaweedfs/templates/all-in-one/all-in-one-service.yml
-
13k8s/charts/seaweedfs/templates/filer/filer-ingress.yaml
-
7k8s/charts/seaweedfs/templates/filer/filer-statefulset.yaml
-
4k8s/charts/seaweedfs/templates/master/master-statefulset.yaml
-
7k8s/charts/seaweedfs/templates/s3/s3-deployment.yaml
-
16k8s/charts/seaweedfs/templates/s3/s3-ingress.yaml
-
71k8s/charts/seaweedfs/templates/shared/post-install-bucket-hook.yaml
-
4k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml
-
106k8s/charts/seaweedfs/values.yaml
-
2test/foundationdb/docker-compose.arm64.yml
-
2test/foundationdb/docker-compose.yml
-
1test/postgres/docker-compose.yml
-
3test/s3/cors/Makefile
-
3test/s3/retention/Makefile
-
2test/s3/retention/s3_object_lock_headers_test.go
-
13test/s3/retention/s3_retention_test.go
-
3test/s3/tagging/Makefile
-
7test/s3/versioning/Makefile
-
41test/sftp/Makefile
-
92test/sftp/README.md
-
652test/sftp/basic_test.go
-
423test/sftp/framework.go
-
17test/sftp/go.mod
-
64test/sftp/go.sum
-
37test/sftp/testdata/userstore.json
-
28weed/admin/dash/admin_server.go
-
240weed/admin/handlers/file_browser_handlers.go
-
4weed/command/filer.go
-
6weed/command/s3.go
-
6weed/command/server.go
-
10weed/command/volume.go
-
4weed/credential/filer_etc/filer_etc_store.go
-
207weed/filer/empty_folder_cleanup/cleanup_queue.go
-
371weed/filer/empty_folder_cleanup/cleanup_queue_test.go
-
436weed/filer/empty_folder_cleanup/empty_folder_cleaner.go
-
569weed/filer/empty_folder_cleanup/empty_folder_cleaner_test.go
-
8weed/filer/filer.go
-
39weed/filer/filer_notify.go
-
39weed/filer/filer_on_meta_event.go
-
13weed/filer/filer_search.go
-
138weed/filer/reader_at.go
-
86weed/filer/reader_cache.go
-
505weed/filer/reader_cache_test.go
-
12weed/operation/upload_content.go
-
2weed/pb/master.proto
-
26weed/pb/master_pb/master.pb.go
-
12weed/pb/server_address.go
-
19weed/s3api/auth_signature_v4.go
-
10weed/s3api/chunked_reader_v4.go
-
144weed/s3api/chunked_reader_v4_test.go
-
5weed/s3api/filer_multipart.go
-
16weed/s3api/s3api_auth.go
-
17weed/s3api/s3api_bucket_config.go
-
78weed/s3api/s3api_bucket_handlers.go
-
42weed/s3api/s3api_object_handlers.go
-
58weed/s3api/s3api_object_handlers_delete.go
-
10weed/s3api/s3api_object_handlers_list.go
-
30weed/s3api/s3api_object_handlers_put.go
-
18weed/s3api/s3api_object_retention.go
-
1weed/s3api/s3api_server.go
-
11weed/server/common.go
-
4weed/server/master_grpc_server.go
-
2weed/server/master_grpc_server_volume.go
-
12weed/server/volume_grpc_copy.go
-
27weed/server/volume_server.go
-
31weed/server/volume_server_handlers_admin.go
-
2weed/server/volume_server_handlers_read.go
-
5weed/sftpd/sftp_file_writer.go
-
82weed/sftpd/sftp_filer.go
-
24weed/sftpd/sftp_server.go
-
103weed/sftpd/sftp_server_test.go
-
4weed/sftpd/sftp_service.go
-
5weed/sftpd/user/filestore.go
-
55weed/shell/command_volume_check_disk.go
-
45weed/shell/command_volume_server_evacuate.go
-
6weed/storage/needle/needle_parse_upload.go
-
12weed/storage/store.go
-
17weed/storage/store_ec_delete.go
-
2weed/storage/store_load_balancing_test.go
-
1weed/topology/data_node.go
-
69weed/topology/rack.go
-
119weed/topology/topology_test.go
-
13weed/util/fullpath.go
-
108weed/util/http/http_global_client_util.go
-
119weed/util/net_timeout.go
-
11weed/util/network.go
@ -0,0 +1,93 @@ |
|||
name: "SFTP Integration Tests" |
|||
|
|||
on: |
|||
push: |
|||
branches: [ master, main ] |
|||
paths: |
|||
- 'weed/sftpd/**' |
|||
- 'weed/command/sftp.go' |
|||
- 'test/sftp/**' |
|||
- '.github/workflows/sftp-tests.yml' |
|||
pull_request: |
|||
branches: [ master, main ] |
|||
paths: |
|||
- 'weed/sftpd/**' |
|||
- 'weed/command/sftp.go' |
|||
- 'test/sftp/**' |
|||
- '.github/workflows/sftp-tests.yml' |
|||
|
|||
concurrency: |
|||
group: ${{ github.head_ref }}/sftp-tests |
|||
cancel-in-progress: true |
|||
|
|||
permissions: |
|||
contents: read |
|||
|
|||
env: |
|||
GO_VERSION: '1.24' |
|||
TEST_TIMEOUT: '15m' |
|||
|
|||
jobs: |
|||
sftp-integration: |
|||
name: SFTP Integration Testing |
|||
runs-on: ubuntu-22.04 |
|||
timeout-minutes: 20 |
|||
|
|||
steps: |
|||
- name: Checkout code |
|||
uses: actions/checkout@v4 |
|||
|
|||
- name: Set up Go ${{ env.GO_VERSION }} |
|||
uses: actions/setup-go@v5 |
|||
with: |
|||
go-version: ${{ env.GO_VERSION }} |
|||
|
|||
- name: Install dependencies |
|||
run: | |
|||
sudo apt-get update |
|||
sudo apt-get install -y openssh-client |
|||
|
|||
- name: Build SeaweedFS |
|||
run: | |
|||
cd weed |
|||
go build -o weed . |
|||
chmod +x weed |
|||
./weed version |
|||
|
|||
- name: Run SFTP Integration Tests |
|||
run: | |
|||
cd test/sftp |
|||
|
|||
echo "🧪 Running SFTP integration tests..." |
|||
echo "============================================" |
|||
|
|||
# Install test dependencies |
|||
go mod download |
|||
|
|||
# Run all SFTP tests |
|||
go test -v -timeout=${{ env.TEST_TIMEOUT }} ./... |
|||
|
|||
echo "============================================" |
|||
echo "✅ SFTP integration tests completed" |
|||
|
|||
- name: Test Summary |
|||
if: always() |
|||
run: | |
|||
echo "## 🔐 SFTP Integration Test Summary" >> $GITHUB_STEP_SUMMARY |
|||
echo "" >> $GITHUB_STEP_SUMMARY |
|||
echo "### Test Coverage" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **HomeDir Path Translation**: User home directory mapping (fixes #7470)" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **File Operations**: Upload, download, delete" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **Directory Operations**: Create, list, remove" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **Large File Handling**: 1MB+ file support" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **Path Edge Cases**: Unicode, trailing slashes, .. paths" >> $GITHUB_STEP_SUMMARY |
|||
echo "- ✅ **Admin Access**: Root user verification" >> $GITHUB_STEP_SUMMARY |
|||
echo "" >> $GITHUB_STEP_SUMMARY |
|||
echo "### Test Configuration" >> $GITHUB_STEP_SUMMARY |
|||
echo "| User | HomeDir | Permissions |" >> $GITHUB_STEP_SUMMARY |
|||
echo "|------|---------|-------------|" >> $GITHUB_STEP_SUMMARY |
|||
echo "| admin | / | Full access |" >> $GITHUB_STEP_SUMMARY |
|||
echo "| testuser | /sftp/testuser | Home directory only |" >> $GITHUB_STEP_SUMMARY |
|||
echo "| readonly | /public | Read-only |" >> $GITHUB_STEP_SUMMARY |
|||
|
|||
|
|||
@ -1,21 +1,28 @@ |
|||
{{- if and .Values.allInOne.enabled (eq .Values.allInOne.data.type "persistentVolumeClaim") }} |
|||
{{- if .Values.allInOne.enabled }} |
|||
{{- if eq .Values.allInOne.data.type "persistentVolumeClaim" }} |
|||
apiVersion: v1 |
|||
kind: PersistentVolumeClaim |
|||
metadata: |
|||
name: {{ .Values.allInOne.data.claimName }} |
|||
name: {{ template "seaweedfs.name" . }}-all-in-one-data |
|||
namespace: {{ .Release.Namespace }} |
|||
labels: |
|||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }} |
|||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} |
|||
app.kubernetes.io/managed-by: {{ .Release.Service }} |
|||
app.kubernetes.io/instance: {{ .Release.Name }} |
|||
app.kubernetes.io/component: seaweedfs-all-in-one |
|||
{{- if .Values.allInOne.annotations }} |
|||
{{- with .Values.allInOne.data.annotations }} |
|||
annotations: |
|||
{{- toYaml .Values.allInOne.annotations | nindent 4 }} |
|||
{{- toYaml . | nindent 4 }} |
|||
{{- end }} |
|||
spec: |
|||
accessModes: |
|||
- ReadWriteOnce |
|||
resources: |
|||
requests: |
|||
storage: {{ .Values.allInOne.data.size }} |
|||
{{- toYaml (.Values.allInOne.data.accessModes | default (list "ReadWriteOnce")) | nindent 4 }} |
|||
{{- if .Values.allInOne.data.storageClass }} |
|||
storageClassName: {{ .Values.allInOne.data.storageClass }} |
|||
{{- end }} |
|||
{{- end }} |
|||
resources: |
|||
requests: |
|||
storage: {{ .Values.allInOne.data.size | default "10Gi" }} |
|||
{{- end }} |
|||
{{- end }} |
|||
@ -0,0 +1,41 @@ |
|||
.PHONY: all build test test-verbose test-short test-homedir test-debug clean deps tidy |
|||
|
|||
all: build test |
|||
|
|||
# Build the weed binary first
|
|||
build: |
|||
cd ../../weed && go build -o weed . |
|||
|
|||
# Install test dependencies
|
|||
deps: |
|||
go mod download |
|||
|
|||
# Run all tests
|
|||
test: build deps |
|||
go test -timeout 5m ./... |
|||
|
|||
# Run tests with verbose output
|
|||
test-verbose: build deps |
|||
go test -v -timeout 5m ./... |
|||
|
|||
# Run quick tests only (skip integration tests)
|
|||
test-short: deps |
|||
go test -short -v ./... |
|||
|
|||
# Run specific test
|
|||
test-homedir: build deps |
|||
go test -v -timeout 5m -run TestHomeDirPathTranslation ./... |
|||
|
|||
# Run tests with debug output from SeaweedFS
|
|||
test-debug: build deps |
|||
go test -v -timeout 5m ./... 2>&1 | tee test.log |
|||
|
|||
# Clean up test artifacts
|
|||
clean: |
|||
rm -f test.log |
|||
go clean -testcache |
|||
|
|||
# Update go.sum
|
|||
tidy: |
|||
go mod tidy |
|||
|
|||
@ -0,0 +1,92 @@ |
|||
# SeaweedFS SFTP Integration Tests |
|||
|
|||
This directory contains integration tests for the SeaweedFS SFTP server. |
|||
|
|||
## Prerequisites |
|||
|
|||
1. Build the SeaweedFS binary: |
|||
```bash |
|||
cd ../../weed |
|||
go build -o weed . |
|||
``` |
|||
|
|||
2. Ensure `ssh-keygen` is available (for generating test SSH host keys) |
|||
|
|||
## Running Tests |
|||
|
|||
### Run all tests |
|||
```bash |
|||
make test |
|||
``` |
|||
|
|||
### Run tests with verbose output |
|||
```bash |
|||
make test-verbose |
|||
``` |
|||
|
|||
### Run a specific test |
|||
```bash |
|||
go test -v -run TestHomeDirPathTranslation |
|||
``` |
|||
|
|||
### Skip long-running tests |
|||
```bash |
|||
go test -short ./... |
|||
``` |
|||
|
|||
## Test Structure |
|||
|
|||
- `framework.go` - Test framework that starts SeaweedFS cluster with SFTP |
|||
- `basic_test.go` - Basic SFTP operation tests including: |
|||
- HomeDir path translation (fixes issue #7470) |
|||
- File upload/download |
|||
- Directory operations |
|||
- Large file handling |
|||
- Edge cases |
|||
|
|||
## Test Configuration |
|||
|
|||
Tests use `testdata/userstore.json` which defines test users: |
|||
|
|||
| Username | Password | HomeDir | Permissions | |
|||
|----------|----------|---------|-------------| |
|||
| admin | adminpassword | / | Full access | |
|||
| testuser | testuserpassword | /sftp/testuser | Full access to home | |
|||
| readonly | readonlypassword | /public | Read-only | |
|||
|
|||
## Key Tests |
|||
|
|||
### TestHomeDirPathTranslation |
|||
|
|||
Tests the fix for [issue #7470](https://github.com/seaweedfs/seaweedfs/issues/7470) where |
|||
users with a non-root HomeDir (e.g., `/sftp/testuser`) could not upload files to `/` |
|||
because the path wasn't being translated to their home directory. |
|||
|
|||
The test verifies: |
|||
- Uploading to `/` correctly maps to the user's HomeDir |
|||
- Creating directories at `/` works |
|||
- Listing `/` shows the user's home directory contents |
|||
- All path operations respect the HomeDir translation |
|||
|
|||
## Debugging |
|||
|
|||
To debug test failures: |
|||
|
|||
1. Enable verbose output: |
|||
```bash |
|||
go test -v -run TestName |
|||
``` |
|||
|
|||
2. Keep test artifacts (don't cleanup): |
|||
```go |
|||
config := DefaultTestConfig() |
|||
config.SkipCleanup = true |
|||
``` |
|||
|
|||
3. Enable debug logging: |
|||
```go |
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = true |
|||
``` |
|||
|
|||
|
|||
@ -0,0 +1,652 @@ |
|||
package sftp |
|||
|
|||
import ( |
|||
"bytes" |
|||
"io" |
|||
"path" |
|||
"testing" |
|||
|
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// TestHomeDirPathTranslation tests that SFTP operations correctly translate
|
|||
// paths relative to the user's HomeDir.
|
|||
// This is the fix for https://github.com/seaweedfs/seaweedfs/issues/7470
|
|||
func TestHomeDirPathTranslation(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
// Test with user "testuser" who has HomeDir="/sftp/testuser"
|
|||
// When they upload to "/", it should actually go to "/sftp/testuser"
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Test 1: Upload file to "/" (should map to /sftp/testuser/)
|
|||
t.Run("UploadToRoot", func(t *testing.T) { |
|||
testContent := []byte("Hello from SFTP test!") |
|||
filename := "test_upload.txt" |
|||
|
|||
// Create file at "/" from user's perspective
|
|||
file, err := sftpClient.Create("/" + filename) |
|||
require.NoError(t, err, "should be able to create file at /") |
|||
|
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err, "should be able to write to file") |
|||
err = file.Close() |
|||
require.NoError(t, err, "should be able to close file") |
|||
|
|||
// Verify file exists and has correct content
|
|||
readFile, err := sftpClient.Open("/" + filename) |
|||
require.NoError(t, err, "should be able to open file") |
|||
defer readFile.Close() |
|||
|
|||
content, err := io.ReadAll(readFile) |
|||
require.NoError(t, err, "should be able to read file") |
|||
require.Equal(t, testContent, content, "file content should match") |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/" + filename) |
|||
require.NoError(t, err, "should be able to remove file") |
|||
}) |
|||
|
|||
// Test 2: Create directory at "/" (should map to /sftp/testuser/)
|
|||
t.Run("CreateDirAtRoot", func(t *testing.T) { |
|||
dirname := "test_dir" |
|||
|
|||
err := sftpClient.Mkdir("/" + dirname) |
|||
require.NoError(t, err, "should be able to create directory at /") |
|||
|
|||
// Verify directory exists
|
|||
info, err := sftpClient.Stat("/" + dirname) |
|||
require.NoError(t, err, "should be able to stat directory") |
|||
require.True(t, info.IsDir(), "should be a directory") |
|||
|
|||
// Clean up
|
|||
err = sftpClient.RemoveDirectory("/" + dirname) |
|||
require.NoError(t, err, "should be able to remove directory") |
|||
}) |
|||
|
|||
// Test 3: List directory at "/" (should list /sftp/testuser/)
|
|||
t.Run("ListRoot", func(t *testing.T) { |
|||
// Create a test file first
|
|||
testContent := []byte("list test content") |
|||
filename := "list_test.txt" |
|||
|
|||
file, err := sftpClient.Create("/" + filename) |
|||
require.NoError(t, err) |
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
// List root directory
|
|||
files, err := sftpClient.ReadDir("/") |
|||
require.NoError(t, err, "should be able to list root directory") |
|||
|
|||
// Should find our test file
|
|||
found := false |
|||
for _, f := range files { |
|||
if f.Name() == filename { |
|||
found = true |
|||
break |
|||
} |
|||
} |
|||
require.True(t, found, "should find test file in listing") |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/" + filename) |
|||
require.NoError(t, err) |
|||
}) |
|||
|
|||
// Test 4: Nested directory operations
|
|||
t.Run("NestedOperations", func(t *testing.T) { |
|||
// Create nested directory structure
|
|||
err := sftpClient.MkdirAll("/nested/dir/structure") |
|||
require.NoError(t, err, "should be able to create nested directories") |
|||
|
|||
// Create file in nested directory
|
|||
testContent := []byte("nested file content") |
|||
file, err := sftpClient.Create("/nested/dir/structure/file.txt") |
|||
require.NoError(t, err) |
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
// Verify file exists
|
|||
readFile, err := sftpClient.Open("/nested/dir/structure/file.txt") |
|||
require.NoError(t, err) |
|||
content, err := io.ReadAll(readFile) |
|||
require.NoError(t, err) |
|||
readFile.Close() |
|||
require.Equal(t, testContent, content) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/nested/dir/structure/file.txt") |
|||
require.NoError(t, err) |
|||
err = sftpClient.RemoveDirectory("/nested/dir/structure") |
|||
require.NoError(t, err) |
|||
err = sftpClient.RemoveDirectory("/nested/dir") |
|||
require.NoError(t, err) |
|||
err = sftpClient.RemoveDirectory("/nested") |
|||
require.NoError(t, err) |
|||
}) |
|||
|
|||
// Test 5: Rename operation
|
|||
t.Run("RenameFile", func(t *testing.T) { |
|||
testContent := []byte("rename test content") |
|||
|
|||
file, err := sftpClient.Create("/original.txt") |
|||
require.NoError(t, err) |
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
// Rename file
|
|||
err = sftpClient.Rename("/original.txt", "/renamed.txt") |
|||
require.NoError(t, err, "should be able to rename file") |
|||
|
|||
// Verify old file doesn't exist
|
|||
_, err = sftpClient.Stat("/original.txt") |
|||
require.Error(t, err, "original file should not exist") |
|||
|
|||
// Verify new file exists with correct content
|
|||
readFile, err := sftpClient.Open("/renamed.txt") |
|||
require.NoError(t, err, "renamed file should exist") |
|||
content, err := io.ReadAll(readFile) |
|||
require.NoError(t, err) |
|||
readFile.Close() |
|||
require.Equal(t, testContent, content) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/renamed.txt") |
|||
require.NoError(t, err) |
|||
}) |
|||
} |
|||
|
|||
// TestAdminRootAccess tests that admin user with HomeDir="/" can access everything
|
|||
func TestAdminRootAccess(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
// Connect as admin with HomeDir="/"
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("admin", "adminpassword") |
|||
require.NoError(t, err, "failed to connect as admin") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Admin should be able to create directories anywhere
|
|||
t.Run("CreateAnyDirectory", func(t *testing.T) { |
|||
// Create the user's home directory structure
|
|||
err := sftpClient.MkdirAll("/sftp/testuser") |
|||
require.NoError(t, err, "admin should be able to create any directory") |
|||
|
|||
// Create file in that directory
|
|||
testContent := []byte("admin created this") |
|||
file, err := sftpClient.Create("/sftp/testuser/admin_file.txt") |
|||
require.NoError(t, err) |
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
// Verify file exists
|
|||
info, err := sftpClient.Stat("/sftp/testuser/admin_file.txt") |
|||
require.NoError(t, err) |
|||
require.False(t, info.IsDir()) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/sftp/testuser/admin_file.txt") |
|||
require.NoError(t, err) |
|||
}) |
|||
} |
|||
|
|||
// TestLargeFileUpload tests uploading larger files through SFTP
|
|||
func TestLargeFileUpload(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Create a 1MB file
|
|||
t.Run("Upload1MB", func(t *testing.T) { |
|||
size := 1024 * 1024 // 1MB
|
|||
testData := bytes.Repeat([]byte("A"), size) |
|||
|
|||
file, err := sftpClient.Create("/large_file.bin") |
|||
require.NoError(t, err) |
|||
n, err := file.Write(testData) |
|||
require.NoError(t, err) |
|||
require.Equal(t, size, n) |
|||
file.Close() |
|||
|
|||
// Verify file size
|
|||
info, err := sftpClient.Stat("/large_file.bin") |
|||
require.NoError(t, err) |
|||
require.Equal(t, int64(size), info.Size()) |
|||
|
|||
// Verify content
|
|||
readFile, err := sftpClient.Open("/large_file.bin") |
|||
require.NoError(t, err) |
|||
content, err := io.ReadAll(readFile) |
|||
require.NoError(t, err) |
|||
readFile.Close() |
|||
require.Equal(t, testData, content) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/large_file.bin") |
|||
require.NoError(t, err) |
|||
}) |
|||
} |
|||
|
|||
// TestStatOperations tests Stat and Lstat operations
|
|||
func TestStatOperations(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Create a test file
|
|||
testContent := []byte("stat test content") |
|||
file, err := sftpClient.Create("/stat_test.txt") |
|||
require.NoError(t, err) |
|||
_, err = file.Write(testContent) |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
t.Run("StatFile", func(t *testing.T) { |
|||
info, err := sftpClient.Stat("/stat_test.txt") |
|||
require.NoError(t, err) |
|||
require.Equal(t, "stat_test.txt", info.Name()) |
|||
require.Equal(t, int64(len(testContent)), info.Size()) |
|||
require.False(t, info.IsDir()) |
|||
}) |
|||
|
|||
t.Run("StatDirectory", func(t *testing.T) { |
|||
err := sftpClient.Mkdir("/stat_dir") |
|||
require.NoError(t, err) |
|||
|
|||
info, err := sftpClient.Stat("/stat_dir") |
|||
require.NoError(t, err) |
|||
require.Equal(t, "stat_dir", info.Name()) |
|||
require.True(t, info.IsDir()) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.RemoveDirectory("/stat_dir") |
|||
require.NoError(t, err) |
|||
}) |
|||
|
|||
t.Run("StatRoot", func(t *testing.T) { |
|||
// Should be able to stat "/" which maps to user's home directory
|
|||
info, err := sftpClient.Stat("/") |
|||
require.NoError(t, err, "should be able to stat root (home) directory") |
|||
require.True(t, info.IsDir(), "root should be a directory") |
|||
}) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove("/stat_test.txt") |
|||
require.NoError(t, err) |
|||
} |
|||
|
|||
// TestWalk tests walking directory trees
|
|||
func TestWalk(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Create directory structure
|
|||
err = sftpClient.MkdirAll("/walk/a/b") |
|||
require.NoError(t, err) |
|||
err = sftpClient.MkdirAll("/walk/c") |
|||
require.NoError(t, err) |
|||
|
|||
// Create files
|
|||
for _, p := range []string{"/walk/file1.txt", "/walk/a/file2.txt", "/walk/a/b/file3.txt", "/walk/c/file4.txt"} { |
|||
file, err := sftpClient.Create(p) |
|||
require.NoError(t, err) |
|||
file.Write([]byte("test")) |
|||
file.Close() |
|||
} |
|||
|
|||
t.Run("WalkEntireTree", func(t *testing.T) { |
|||
var paths []string |
|||
walker := sftpClient.Walk("/walk") |
|||
for walker.Step() { |
|||
if walker.Err() != nil { |
|||
continue |
|||
} |
|||
paths = append(paths, walker.Path()) |
|||
} |
|||
|
|||
// Should find all directories and files
|
|||
require.Contains(t, paths, "/walk") |
|||
require.Contains(t, paths, "/walk/a") |
|||
require.Contains(t, paths, "/walk/a/b") |
|||
require.Contains(t, paths, "/walk/c") |
|||
}) |
|||
|
|||
// Clean up
|
|||
for _, p := range []string{"/walk/file1.txt", "/walk/a/file2.txt", "/walk/a/b/file3.txt", "/walk/c/file4.txt"} { |
|||
require.NoError(t, sftpClient.Remove(p)) |
|||
} |
|||
for _, p := range []string{"/walk/a/b", "/walk/a", "/walk/c", "/walk"} { |
|||
require.NoError(t, sftpClient.RemoveDirectory(p)) |
|||
} |
|||
} |
|||
|
|||
// TestCurrentWorkingDirectory tests that Getwd and Chdir work correctly
|
|||
func TestCurrentWorkingDirectory(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
// Create test directory
|
|||
err = sftpClient.Mkdir("/cwd_test") |
|||
require.NoError(t, err) |
|||
|
|||
t.Run("GetCurrentDir", func(t *testing.T) { |
|||
cwd, err := sftpClient.Getwd() |
|||
require.NoError(t, err) |
|||
// The initial working directory should be the user's home directory
|
|||
// which from the user's perspective is "/"
|
|||
require.Equal(t, "/", cwd, "initial working directory should be the virtual root") |
|||
}) |
|||
|
|||
t.Run("ChangeAndCreate", func(t *testing.T) { |
|||
// Create file in subdirectory using relative path after chdir
|
|||
// Note: pkg/sftp doesn't support Chdir, so we test using absolute paths
|
|||
file, err := sftpClient.Create("/cwd_test/relative_file.txt") |
|||
require.NoError(t, err) |
|||
file.Write([]byte("test")) |
|||
file.Close() |
|||
|
|||
// Verify using absolute path
|
|||
_, err = sftpClient.Stat("/cwd_test/relative_file.txt") |
|||
require.NoError(t, err) |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/cwd_test/relative_file.txt") |
|||
}) |
|||
|
|||
// Clean up
|
|||
err = sftpClient.RemoveDirectory("/cwd_test") |
|||
require.NoError(t, err) |
|||
} |
|||
|
|||
// TestPathEdgeCases tests various edge cases in path handling
|
|||
func TestPathEdgeCases(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
t.Run("PathWithDotDot", func(t *testing.T) { |
|||
// Create directory structure
|
|||
err := sftpClient.MkdirAll("/edge/subdir") |
|||
require.NoError(t, err) |
|||
|
|||
// Create file using path with ..
|
|||
file, err := sftpClient.Create("/edge/subdir/../file.txt") |
|||
require.NoError(t, err) |
|||
file.Write([]byte("test")) |
|||
file.Close() |
|||
|
|||
// Verify file was created in /edge
|
|||
_, err = sftpClient.Stat("/edge/file.txt") |
|||
require.NoError(t, err, "file should be created in parent directory") |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/edge/file.txt") |
|||
sftpClient.RemoveDirectory("/edge/subdir") |
|||
sftpClient.RemoveDirectory("/edge") |
|||
}) |
|||
|
|||
t.Run("PathWithTrailingSlash", func(t *testing.T) { |
|||
err := sftpClient.Mkdir("/trailing") |
|||
require.NoError(t, err) |
|||
|
|||
// Stat with trailing slash
|
|||
info, err := sftpClient.Stat("/trailing/") |
|||
require.NoError(t, err) |
|||
require.True(t, info.IsDir()) |
|||
|
|||
// Clean up
|
|||
sftpClient.RemoveDirectory("/trailing") |
|||
}) |
|||
|
|||
t.Run("CreateFileAtRootPath", func(t *testing.T) { |
|||
// This is the exact scenario from issue #7470
|
|||
// User with HomeDir="/sftp/testuser" uploads to "/"
|
|||
file, err := sftpClient.Create("/issue7470.txt") |
|||
require.NoError(t, err, "should be able to create file at / (issue #7470)") |
|||
file.Write([]byte("This tests the fix for issue #7470")) |
|||
file.Close() |
|||
|
|||
// Verify
|
|||
_, err = sftpClient.Stat("/issue7470.txt") |
|||
require.NoError(t, err) |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/issue7470.txt") |
|||
}) |
|||
|
|||
// Security test: path traversal attacks should be blocked
|
|||
t.Run("PathTraversalPrevention", func(t *testing.T) { |
|||
// User's HomeDir is "/sftp/testuser"
|
|||
// Attempting to escape via "../.." should NOT create files outside home directory
|
|||
|
|||
// First, create a valid file to ensure we can write
|
|||
validFile, err := sftpClient.Create("/valid.txt") |
|||
require.NoError(t, err) |
|||
validFile.Write([]byte("valid")) |
|||
validFile.Close() |
|||
|
|||
// Try various path traversal attempts
|
|||
// These should either:
|
|||
// 1. Be blocked (error returned), OR
|
|||
// 2. Be safely resolved to stay within home directory
|
|||
|
|||
traversalPaths := []string{ |
|||
"/../escape.txt", |
|||
"/../../escape.txt", |
|||
"/../../../escape.txt", |
|||
"/subdir/../../escape.txt", |
|||
"/./../../escape.txt", |
|||
} |
|||
|
|||
for _, traversalPath := range traversalPaths { |
|||
t.Run(traversalPath, func(t *testing.T) { |
|||
// Note: The pkg/sftp client sanitizes paths locally before sending them to the server.
|
|||
// So "/../escape.txt" becomes "/escape.txt" on the wire.
|
|||
// Therefore, we cannot trigger the server-side path traversal block with this client.
|
|||
// Instead, we verify that the file is created successfully within the jail (contained).
|
|||
// The server-side protection logic is verified in unit tests (sftpd/sftp_server_test.go).
|
|||
|
|||
file, err := sftpClient.Create(traversalPath) |
|||
require.NoError(t, err, "creation should succeed because client sanitizes path") |
|||
file.Close() |
|||
|
|||
// Clean up
|
|||
err = sftpClient.Remove(traversalPath) |
|||
require.NoError(t, err) |
|||
}) |
|||
} |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/valid.txt") |
|||
}) |
|||
} |
|||
|
|||
// TestFileContent tests reading and writing file content correctly
|
|||
func TestFileContent(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("skipping integration test in short mode") |
|||
} |
|||
|
|||
config := DefaultTestConfig() |
|||
config.EnableDebug = testing.Verbose() |
|||
|
|||
fw := NewSftpTestFramework(t, config) |
|||
err := fw.Setup(config) |
|||
require.NoError(t, err, "failed to setup test framework") |
|||
defer fw.Cleanup() |
|||
|
|||
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
|||
require.NoError(t, err, "failed to connect as testuser") |
|||
defer sshConn.Close() |
|||
defer sftpClient.Close() |
|||
|
|||
t.Run("BinaryContent", func(t *testing.T) { |
|||
// Create binary data with all byte values
|
|||
data := make([]byte, 256) |
|||
for i := 0; i < 256; i++ { |
|||
data[i] = byte(i) |
|||
} |
|||
|
|||
file, err := sftpClient.Create("/binary.bin") |
|||
require.NoError(t, err) |
|||
n, err := file.Write(data) |
|||
require.NoError(t, err) |
|||
require.Equal(t, 256, n) |
|||
file.Close() |
|||
|
|||
// Read back
|
|||
readFile, err := sftpClient.Open("/binary.bin") |
|||
require.NoError(t, err) |
|||
content, err := io.ReadAll(readFile) |
|||
require.NoError(t, err) |
|||
readFile.Close() |
|||
|
|||
require.Equal(t, data, content, "binary content should match") |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/binary.bin") |
|||
}) |
|||
|
|||
t.Run("EmptyFile", func(t *testing.T) { |
|||
file, err := sftpClient.Create("/empty.txt") |
|||
require.NoError(t, err) |
|||
file.Close() |
|||
|
|||
info, err := sftpClient.Stat("/empty.txt") |
|||
require.NoError(t, err) |
|||
require.Equal(t, int64(0), info.Size()) |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove("/empty.txt") |
|||
}) |
|||
|
|||
t.Run("UnicodeFilename", func(t *testing.T) { |
|||
filename := "/文件名.txt" |
|||
content := []byte("Unicode content: 你好世界") |
|||
|
|||
file, err := sftpClient.Create(filename) |
|||
require.NoError(t, err) |
|||
file.Write(content) |
|||
file.Close() |
|||
|
|||
// Read back
|
|||
readFile, err := sftpClient.Open(filename) |
|||
require.NoError(t, err) |
|||
readContent, err := io.ReadAll(readFile) |
|||
require.NoError(t, err) |
|||
readFile.Close() |
|||
|
|||
require.Equal(t, content, readContent) |
|||
|
|||
// Verify in listing
|
|||
files, err := sftpClient.ReadDir("/") |
|||
require.NoError(t, err) |
|||
found := false |
|||
for _, f := range files { |
|||
if f.Name() == path.Base(filename) { |
|||
found = true |
|||
break |
|||
} |
|||
} |
|||
require.True(t, found, "should find unicode filename in listing") |
|||
|
|||
// Clean up
|
|||
sftpClient.Remove(filename) |
|||
}) |
|||
} |
|||
|
|||
@ -0,0 +1,423 @@ |
|||
package sftp |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net" |
|||
"os" |
|||
"os/exec" |
|||
"path/filepath" |
|||
"runtime" |
|||
"syscall" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/pkg/sftp" |
|||
"github.com/stretchr/testify/require" |
|||
"golang.org/x/crypto/ssh" |
|||
) |
|||
|
|||
// SftpTestFramework provides utilities for SFTP integration testing
|
|||
type SftpTestFramework struct { |
|||
t *testing.T |
|||
tempDir string |
|||
dataDir string |
|||
masterProcess *os.Process |
|||
volumeProcess *os.Process |
|||
filerProcess *os.Process |
|||
sftpProcess *os.Process |
|||
masterAddr string |
|||
volumeAddr string |
|||
filerAddr string |
|||
sftpAddr string |
|||
weedBinary string |
|||
userStoreFile string |
|||
hostKeyFile string |
|||
isSetup bool |
|||
skipCleanup bool |
|||
} |
|||
|
|||
// TestConfig holds configuration for SFTP tests
|
|||
type TestConfig struct { |
|||
NumVolumes int |
|||
EnableDebug bool |
|||
SkipCleanup bool // for debugging failed tests
|
|||
UserStoreFile string |
|||
} |
|||
|
|||
// DefaultTestConfig returns a default configuration for SFTP tests
|
|||
func DefaultTestConfig() *TestConfig { |
|||
return &TestConfig{ |
|||
NumVolumes: 3, |
|||
EnableDebug: false, |
|||
SkipCleanup: false, |
|||
UserStoreFile: "", |
|||
} |
|||
} |
|||
|
|||
// NewSftpTestFramework creates a new SFTP testing framework
|
|||
func NewSftpTestFramework(t *testing.T, config *TestConfig) *SftpTestFramework { |
|||
if config == nil { |
|||
config = DefaultTestConfig() |
|||
} |
|||
|
|||
tempDir, err := os.MkdirTemp("", "seaweedfs_sftp_test_") |
|||
require.NoError(t, err) |
|||
|
|||
// Generate SSH host key for SFTP server
|
|||
hostKeyFile := filepath.Join(tempDir, "ssh_host_key") |
|||
cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", hostKeyFile, "-N", "") |
|||
err = cmd.Run() |
|||
require.NoError(t, err, "failed to generate SSH host key") |
|||
|
|||
// Use provided userstore or copy the test one
|
|||
userStoreFile := config.UserStoreFile |
|||
if userStoreFile == "" { |
|||
// Copy test userstore to temp dir
|
|||
userStoreFile = filepath.Join(tempDir, "userstore.json") |
|||
testDataPath := findTestDataPath() |
|||
input, err := os.ReadFile(filepath.Join(testDataPath, "userstore.json")) |
|||
require.NoError(t, err, "failed to read test userstore.json") |
|||
err = os.WriteFile(userStoreFile, input, 0644) |
|||
require.NoError(t, err, "failed to write userstore.json") |
|||
} |
|||
|
|||
return &SftpTestFramework{ |
|||
t: t, |
|||
tempDir: tempDir, |
|||
dataDir: filepath.Join(tempDir, "data"), |
|||
masterAddr: "127.0.0.1:19333", |
|||
volumeAddr: "127.0.0.1:18080", |
|||
filerAddr: "127.0.0.1:18888", |
|||
sftpAddr: "127.0.0.1:12022", |
|||
weedBinary: findWeedBinary(), |
|||
userStoreFile: userStoreFile, |
|||
hostKeyFile: hostKeyFile, |
|||
isSetup: false, |
|||
} |
|||
} |
|||
|
|||
// Setup starts SeaweedFS cluster with SFTP server
|
|||
func (f *SftpTestFramework) Setup(config *TestConfig) error { |
|||
if f.isSetup { |
|||
return fmt.Errorf("framework already setup") |
|||
} |
|||
|
|||
// Create all data directories
|
|||
dirs := []string{ |
|||
f.dataDir, |
|||
filepath.Join(f.dataDir, "master"), |
|||
filepath.Join(f.dataDir, "volume"), |
|||
} |
|||
for _, dir := range dirs { |
|||
if err := os.MkdirAll(dir, 0755); err != nil { |
|||
return fmt.Errorf("failed to create directory %s: %v", dir, err) |
|||
} |
|||
} |
|||
|
|||
// Start master
|
|||
if err := f.startMaster(config); err != nil { |
|||
return fmt.Errorf("failed to start master: %v", err) |
|||
} |
|||
|
|||
// Wait for master to be ready
|
|||
if err := f.waitForService(f.masterAddr, 30*time.Second); err != nil { |
|||
return fmt.Errorf("master not ready: %v", err) |
|||
} |
|||
|
|||
// Start volume server
|
|||
if err := f.startVolumeServer(config); err != nil { |
|||
return fmt.Errorf("failed to start volume server: %v", err) |
|||
} |
|||
|
|||
// Wait for volume server to be ready
|
|||
if err := f.waitForService(f.volumeAddr, 30*time.Second); err != nil { |
|||
return fmt.Errorf("volume server not ready: %v", err) |
|||
} |
|||
|
|||
// Start filer
|
|||
if err := f.startFiler(config); err != nil { |
|||
return fmt.Errorf("failed to start filer: %v", err) |
|||
} |
|||
|
|||
// Wait for filer to be ready
|
|||
if err := f.waitForService(f.filerAddr, 30*time.Second); err != nil { |
|||
return fmt.Errorf("filer not ready: %v", err) |
|||
} |
|||
|
|||
// Start SFTP server
|
|||
if err := f.startSftpServer(config); err != nil { |
|||
return fmt.Errorf("failed to start SFTP server: %v", err) |
|||
} |
|||
|
|||
// Wait for SFTP server to be ready
|
|||
if err := f.waitForService(f.sftpAddr, 30*time.Second); err != nil { |
|||
return fmt.Errorf("SFTP server not ready: %v", err) |
|||
} |
|||
|
|||
// Additional wait for all services to stabilize (gRPC endpoints)
|
|||
time.Sleep(500 * time.Millisecond) |
|||
|
|||
f.skipCleanup = config.SkipCleanup |
|||
f.isSetup = true |
|||
return nil |
|||
} |
|||
|
|||
// Cleanup stops all processes and removes temporary files
|
|||
func (f *SftpTestFramework) Cleanup() { |
|||
// Stop processes in reverse order
|
|||
processes := []*os.Process{f.sftpProcess, f.filerProcess, f.volumeProcess, f.masterProcess} |
|||
for _, proc := range processes { |
|||
if proc != nil { |
|||
proc.Signal(syscall.SIGTERM) |
|||
proc.Wait() |
|||
} |
|||
} |
|||
|
|||
// Remove temp directory
|
|||
if !f.skipCleanup { |
|||
os.RemoveAll(f.tempDir) |
|||
} |
|||
} |
|||
|
|||
// GetSftpAddr returns the SFTP server address
|
|||
func (f *SftpTestFramework) GetSftpAddr() string { |
|||
return f.sftpAddr |
|||
} |
|||
|
|||
// GetFilerAddr returns the filer address
|
|||
func (f *SftpTestFramework) GetFilerAddr() string { |
|||
return f.filerAddr |
|||
} |
|||
|
|||
// ConnectSFTP creates an SFTP client connection with the given credentials
|
|||
func (f *SftpTestFramework) ConnectSFTP(username, password string) (*sftp.Client, *ssh.Client, error) { |
|||
// Load the known host public key for verification
|
|||
hostKeyCallback, err := f.getHostKeyCallback() |
|||
if err != nil { |
|||
return nil, nil, fmt.Errorf("failed to get host key callback: %v", err) |
|||
} |
|||
|
|||
config := &ssh.ClientConfig{ |
|||
User: username, |
|||
Auth: []ssh.AuthMethod{ |
|||
ssh.Password(password), |
|||
}, |
|||
HostKeyCallback: hostKeyCallback, |
|||
Timeout: 5 * time.Second, |
|||
} |
|||
|
|||
sshConn, err := ssh.Dial("tcp", f.sftpAddr, config) |
|||
if err != nil { |
|||
return nil, nil, fmt.Errorf("failed to connect SSH: %v", err) |
|||
} |
|||
|
|||
sftpClient, err := sftp.NewClient(sshConn) |
|||
if err != nil { |
|||
sshConn.Close() |
|||
return nil, nil, fmt.Errorf("failed to create SFTP client: %v", err) |
|||
} |
|||
|
|||
return sftpClient, sshConn, nil |
|||
} |
|||
|
|||
// getHostKeyCallback returns a callback that verifies the server's host key
|
|||
// matches the known test server key we generated
|
|||
func (f *SftpTestFramework) getHostKeyCallback() (ssh.HostKeyCallback, error) { |
|||
// Read the public key file generated alongside the private key
|
|||
pubKeyFile := f.hostKeyFile + ".pub" |
|||
pubKeyBytes, err := os.ReadFile(pubKeyFile) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to read host public key: %v", err) |
|||
} |
|||
|
|||
// Parse the public key
|
|||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to parse host public key: %v", err) |
|||
} |
|||
|
|||
// Return a callback that verifies the server key matches our known key
|
|||
return ssh.FixedHostKey(pubKey), nil |
|||
} |
|||
|
|||
// startMaster starts the SeaweedFS master server
|
|||
func (f *SftpTestFramework) startMaster(config *TestConfig) error { |
|||
args := []string{ |
|||
"master", |
|||
"-ip=127.0.0.1", |
|||
"-port=19333", |
|||
"-mdir=" + filepath.Join(f.dataDir, "master"), |
|||
"-raftBootstrap", |
|||
"-peers=none", |
|||
} |
|||
|
|||
cmd := exec.Command(f.weedBinary, args...) |
|||
cmd.Dir = f.tempDir |
|||
if config.EnableDebug { |
|||
cmd.Stdout = os.Stdout |
|||
cmd.Stderr = os.Stderr |
|||
} |
|||
if err := cmd.Start(); err != nil { |
|||
return err |
|||
} |
|||
f.masterProcess = cmd.Process |
|||
return nil |
|||
} |
|||
|
|||
// startVolumeServer starts SeaweedFS volume server
|
|||
func (f *SftpTestFramework) startVolumeServer(config *TestConfig) error { |
|||
args := []string{ |
|||
"volume", |
|||
"-mserver=" + f.masterAddr, |
|||
"-ip=127.0.0.1", |
|||
"-port=18080", |
|||
"-dir=" + filepath.Join(f.dataDir, "volume"), |
|||
fmt.Sprintf("-max=%d", config.NumVolumes), |
|||
} |
|||
|
|||
cmd := exec.Command(f.weedBinary, args...) |
|||
cmd.Dir = f.tempDir |
|||
if config.EnableDebug { |
|||
cmd.Stdout = os.Stdout |
|||
cmd.Stderr = os.Stderr |
|||
} |
|||
if err := cmd.Start(); err != nil { |
|||
return err |
|||
} |
|||
f.volumeProcess = cmd.Process |
|||
return nil |
|||
} |
|||
|
|||
// startFiler starts the SeaweedFS filer server
|
|||
func (f *SftpTestFramework) startFiler(config *TestConfig) error { |
|||
args := []string{ |
|||
"filer", |
|||
"-master=" + f.masterAddr, |
|||
"-ip=127.0.0.1", |
|||
"-port=18888", |
|||
} |
|||
|
|||
cmd := exec.Command(f.weedBinary, args...) |
|||
cmd.Dir = f.tempDir |
|||
if config.EnableDebug { |
|||
cmd.Stdout = os.Stdout |
|||
cmd.Stderr = os.Stderr |
|||
} |
|||
if err := cmd.Start(); err != nil { |
|||
return err |
|||
} |
|||
f.filerProcess = cmd.Process |
|||
return nil |
|||
} |
|||
|
|||
// startSftpServer starts the SeaweedFS SFTP server
|
|||
func (f *SftpTestFramework) startSftpServer(config *TestConfig) error { |
|||
args := []string{ |
|||
"sftp", |
|||
"-filer=" + f.filerAddr, |
|||
"-ip.bind=127.0.0.1", |
|||
"-port=12022", |
|||
"-sshPrivateKey=" + f.hostKeyFile, |
|||
"-userStoreFile=" + f.userStoreFile, |
|||
} |
|||
|
|||
cmd := exec.Command(f.weedBinary, args...) |
|||
cmd.Dir = f.tempDir |
|||
if config.EnableDebug { |
|||
cmd.Stdout = os.Stdout |
|||
cmd.Stderr = os.Stderr |
|||
} |
|||
if err := cmd.Start(); err != nil { |
|||
return err |
|||
} |
|||
f.sftpProcess = cmd.Process |
|||
return nil |
|||
} |
|||
|
|||
// waitForService waits for a service to be available
|
|||
func (f *SftpTestFramework) waitForService(addr string, timeout time.Duration) error { |
|||
deadline := time.Now().Add(timeout) |
|||
for time.Now().Before(deadline) { |
|||
conn, err := net.DialTimeout("tcp", addr, 1*time.Second) |
|||
if err == nil { |
|||
conn.Close() |
|||
return nil |
|||
} |
|||
time.Sleep(100 * time.Millisecond) |
|||
} |
|||
return fmt.Errorf("service at %s not ready within timeout", addr) |
|||
} |
|||
|
|||
// findWeedBinary locates the weed binary
|
|||
// Prefers local build over system-installed weed to ensure we test the latest code
|
|||
func findWeedBinary() string { |
|||
// Get the directory where this source file is located
|
|||
// This ensures we find the locally built weed binary first
|
|||
_, thisFile, _, ok := runtime.Caller(0) |
|||
if ok { |
|||
thisDir := filepath.Dir(thisFile) |
|||
// From test/sftp/, the weed binary should be at ../../weed/weed
|
|||
candidates := []string{ |
|||
filepath.Join(thisDir, "../../weed/weed"), |
|||
filepath.Join(thisDir, "../weed/weed"), |
|||
} |
|||
for _, candidate := range candidates { |
|||
if _, err := os.Stat(candidate); err == nil { |
|||
abs, _ := filepath.Abs(candidate) |
|||
return abs |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Try relative paths from current working directory
|
|||
cwd, _ := os.Getwd() |
|||
candidates := []string{ |
|||
filepath.Join(cwd, "../../weed/weed"), |
|||
filepath.Join(cwd, "../weed/weed"), |
|||
filepath.Join(cwd, "./weed"), |
|||
} |
|||
for _, candidate := range candidates { |
|||
if _, err := os.Stat(candidate); err == nil { |
|||
abs, _ := filepath.Abs(candidate) |
|||
return abs |
|||
} |
|||
} |
|||
|
|||
// Fallback to PATH only if local build not found
|
|||
if path, err := exec.LookPath("weed"); err == nil { |
|||
return path |
|||
} |
|||
|
|||
// Default fallback
|
|||
return "weed" |
|||
} |
|||
|
|||
// findTestDataPath locates the testdata directory
|
|||
func findTestDataPath() string { |
|||
// Get the directory where this source file is located
|
|||
_, thisFile, _, ok := runtime.Caller(0) |
|||
if ok { |
|||
thisDir := filepath.Dir(thisFile) |
|||
testDataPath := filepath.Join(thisDir, "testdata") |
|||
if _, err := os.Stat(testDataPath); err == nil { |
|||
return testDataPath |
|||
} |
|||
} |
|||
|
|||
// Try relative paths from current working directory
|
|||
cwd, _ := os.Getwd() |
|||
candidates := []string{ |
|||
filepath.Join(cwd, "testdata"), |
|||
filepath.Join(cwd, "../sftp/testdata"), |
|||
filepath.Join(cwd, "test/sftp/testdata"), |
|||
} |
|||
|
|||
for _, candidate := range candidates { |
|||
if _, err := os.Stat(candidate); err == nil { |
|||
return candidate |
|||
} |
|||
} |
|||
|
|||
return "./testdata" |
|||
} |
|||
|
|||
@ -0,0 +1,17 @@ |
|||
module seaweedfs-sftp-tests |
|||
|
|||
go 1.24.0 |
|||
|
|||
require ( |
|||
github.com/pkg/sftp v1.13.7 |
|||
github.com/stretchr/testify v1.10.0 |
|||
golang.org/x/crypto v0.45.0 |
|||
) |
|||
|
|||
require ( |
|||
github.com/davecgh/go-spew v1.1.1 // indirect |
|||
github.com/kr/fs v0.1.0 // indirect |
|||
github.com/pmezard/go-difflib v1.0.0 // indirect |
|||
golang.org/x/sys v0.38.0 // indirect |
|||
gopkg.in/yaml.v3 v3.0.1 // indirect |
|||
) |
|||
@ -0,0 +1,64 @@ |
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= |
|||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= |
|||
github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= |
|||
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= |
|||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= |
|||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= |
|||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= |
|||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= |
|||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= |
|||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= |
|||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= |
|||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= |
|||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
|||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
|||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= |
|||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= |
|||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= |
|||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
|||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
|||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
|||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
|||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= |
|||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= |
|||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
|||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
|||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= |
|||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= |
|||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= |
|||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= |
|||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= |
|||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
|||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
|||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= |
|||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= |
|||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= |
|||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= |
|||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
|||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
|||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= |
|||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= |
|||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|||
@ -0,0 +1,37 @@ |
|||
[ |
|||
{ |
|||
"Username": "admin", |
|||
"Password": "adminpassword", |
|||
"PublicKeys": [], |
|||
"HomeDir": "/", |
|||
"Permissions": { |
|||
"/": ["*"] |
|||
}, |
|||
"Uid": 0, |
|||
"Gid": 0 |
|||
}, |
|||
{ |
|||
"Username": "testuser", |
|||
"Password": "testuserpassword", |
|||
"PublicKeys": [], |
|||
"HomeDir": "/sftp/testuser", |
|||
"Permissions": { |
|||
"/sftp/testuser": ["*"] |
|||
}, |
|||
"Uid": 1001, |
|||
"Gid": 1001 |
|||
}, |
|||
{ |
|||
"Username": "readonly", |
|||
"Password": "readonlypassword", |
|||
"PublicKeys": [], |
|||
"HomeDir": "/public", |
|||
"Permissions": { |
|||
"/public": ["read", "list"] |
|||
}, |
|||
"Uid": 1002, |
|||
"Gid": 1002 |
|||
} |
|||
] |
|||
|
|||
|
|||
@ -0,0 +1,207 @@ |
|||
package empty_folder_cleanup |
|||
|
|||
import ( |
|||
"container/list" |
|||
"sync" |
|||
"time" |
|||
) |
|||
|
|||
// CleanupQueue manages a deduplicated queue of folders pending cleanup.
|
|||
// It uses a doubly-linked list ordered by event time (oldest at front) and a map for O(1) deduplication.
|
|||
// Processing is triggered when:
|
|||
// - Queue size reaches maxSize, OR
|
|||
// - Oldest item exceeds maxAge
|
|||
type CleanupQueue struct { |
|||
mu sync.Mutex |
|||
items *list.List // Linked list of *queueItem ordered by time (front = oldest)
|
|||
itemsMap map[string]*list.Element // folder -> list element for O(1) lookup
|
|||
maxSize int // Max queue size before triggering cleanup
|
|||
maxAge time.Duration // Max age before triggering cleanup
|
|||
} |
|||
|
|||
// queueItem represents an item in the cleanup queue
|
|||
type queueItem struct { |
|||
folder string |
|||
queueTime time.Time |
|||
} |
|||
|
|||
// NewCleanupQueue creates a new CleanupQueue with the specified limits
|
|||
func NewCleanupQueue(maxSize int, maxAge time.Duration) *CleanupQueue { |
|||
return &CleanupQueue{ |
|||
items: list.New(), |
|||
itemsMap: make(map[string]*list.Element), |
|||
maxSize: maxSize, |
|||
maxAge: maxAge, |
|||
} |
|||
} |
|||
|
|||
// Add adds a folder to the queue with the specified event time.
|
|||
// The item is inserted in time-sorted order (oldest at front) to handle out-of-order events.
|
|||
// If folder already exists with an older time, the time is updated and position adjusted.
|
|||
// Returns true if the folder was newly added, false if it was updated.
|
|||
func (q *CleanupQueue) Add(folder string, eventTime time.Time) bool { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
// Check if folder already exists
|
|||
if elem, exists := q.itemsMap[folder]; exists { |
|||
existingItem := elem.Value.(*queueItem) |
|||
// Only update if new event is later
|
|||
if eventTime.After(existingItem.queueTime) { |
|||
// Remove from current position
|
|||
q.items.Remove(elem) |
|||
// Re-insert with new time in sorted position
|
|||
newElem := q.insertSorted(folder, eventTime) |
|||
q.itemsMap[folder] = newElem |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// Insert new folder in sorted position
|
|||
elem := q.insertSorted(folder, eventTime) |
|||
q.itemsMap[folder] = elem |
|||
return true |
|||
} |
|||
|
|||
// insertSorted inserts an item in the correct position to maintain time ordering (oldest at front)
|
|||
func (q *CleanupQueue) insertSorted(folder string, eventTime time.Time) *list.Element { |
|||
item := &queueItem{ |
|||
folder: folder, |
|||
queueTime: eventTime, |
|||
} |
|||
|
|||
// Find the correct position (insert before the first item with a later time)
|
|||
for elem := q.items.Back(); elem != nil; elem = elem.Prev() { |
|||
existingItem := elem.Value.(*queueItem) |
|||
if !eventTime.Before(existingItem.queueTime) { |
|||
// Insert after this element
|
|||
return q.items.InsertAfter(item, elem) |
|||
} |
|||
} |
|||
|
|||
// This item is the oldest, insert at front
|
|||
return q.items.PushFront(item) |
|||
} |
|||
|
|||
// Remove removes a specific folder from the queue (e.g., when a file is created).
|
|||
// Returns true if the folder was found and removed.
|
|||
func (q *CleanupQueue) Remove(folder string) bool { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
elem, exists := q.itemsMap[folder] |
|||
if !exists { |
|||
return false |
|||
} |
|||
|
|||
q.items.Remove(elem) |
|||
delete(q.itemsMap, folder) |
|||
return true |
|||
} |
|||
|
|||
// ShouldProcess returns true if the queue should be processed.
|
|||
// This is true when:
|
|||
// - Queue size >= maxSize, OR
|
|||
// - Oldest item age > maxAge
|
|||
func (q *CleanupQueue) ShouldProcess() bool { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
return q.shouldProcessLocked() |
|||
} |
|||
|
|||
// shouldProcessLocked checks if processing is needed (caller must hold lock)
|
|||
func (q *CleanupQueue) shouldProcessLocked() bool { |
|||
if q.items.Len() == 0 { |
|||
return false |
|||
} |
|||
|
|||
// Check if queue is full
|
|||
if q.items.Len() >= q.maxSize { |
|||
return true |
|||
} |
|||
|
|||
// Check if oldest item exceeds max age
|
|||
front := q.items.Front() |
|||
if front != nil { |
|||
item := front.Value.(*queueItem) |
|||
if time.Since(item.queueTime) > q.maxAge { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// Pop removes and returns the oldest folder from the queue.
|
|||
// Returns the folder and true if an item was available, or empty string and false if queue is empty.
|
|||
func (q *CleanupQueue) Pop() (string, bool) { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
front := q.items.Front() |
|||
if front == nil { |
|||
return "", false |
|||
} |
|||
|
|||
item := front.Value.(*queueItem) |
|||
q.items.Remove(front) |
|||
delete(q.itemsMap, item.folder) |
|||
|
|||
return item.folder, true |
|||
} |
|||
|
|||
// Peek returns the oldest folder without removing it.
|
|||
// Returns the folder and queue time if available, or empty values if queue is empty.
|
|||
func (q *CleanupQueue) Peek() (folder string, queueTime time.Time, ok bool) { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
front := q.items.Front() |
|||
if front == nil { |
|||
return "", time.Time{}, false |
|||
} |
|||
|
|||
item := front.Value.(*queueItem) |
|||
return item.folder, item.queueTime, true |
|||
} |
|||
|
|||
// Len returns the current queue size.
|
|||
func (q *CleanupQueue) Len() int { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
return q.items.Len() |
|||
} |
|||
|
|||
// Contains checks if a folder is in the queue.
|
|||
func (q *CleanupQueue) Contains(folder string) bool { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
_, exists := q.itemsMap[folder] |
|||
return exists |
|||
} |
|||
|
|||
// Clear removes all items from the queue.
|
|||
func (q *CleanupQueue) Clear() { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
q.items.Init() |
|||
q.itemsMap = make(map[string]*list.Element) |
|||
} |
|||
|
|||
// OldestAge returns the age of the oldest item in the queue, or 0 if empty.
|
|||
func (q *CleanupQueue) OldestAge() time.Duration { |
|||
q.mu.Lock() |
|||
defer q.mu.Unlock() |
|||
|
|||
front := q.items.Front() |
|||
if front == nil { |
|||
return 0 |
|||
} |
|||
|
|||
item := front.Value.(*queueItem) |
|||
return time.Since(item.queueTime) |
|||
} |
|||
|
|||
|
|||
@ -0,0 +1,371 @@ |
|||
package empty_folder_cleanup |
|||
|
|||
import ( |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestCleanupQueue_Add(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
// Add first item
|
|||
if !q.Add("/buckets/b1/folder1", now) { |
|||
t.Error("expected Add to return true for new item") |
|||
} |
|||
if q.Len() != 1 { |
|||
t.Errorf("expected len 1, got %d", q.Len()) |
|||
} |
|||
|
|||
// Add second item with later time
|
|||
if !q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) { |
|||
t.Error("expected Add to return true for new item") |
|||
} |
|||
if q.Len() != 2 { |
|||
t.Errorf("expected len 2, got %d", q.Len()) |
|||
} |
|||
|
|||
// Add duplicate with newer time - should update and reposition
|
|||
if q.Add("/buckets/b1/folder1", now.Add(2*time.Second)) { |
|||
t.Error("expected Add to return false for existing item") |
|||
} |
|||
if q.Len() != 2 { |
|||
t.Errorf("expected len 2 after duplicate, got %d", q.Len()) |
|||
} |
|||
|
|||
// folder1 should now be at the back (newer time) - verify by popping
|
|||
folder1, _ := q.Pop() |
|||
folder2, _ := q.Pop() |
|||
if folder1 != "/buckets/b1/folder2" || folder2 != "/buckets/b1/folder1" { |
|||
t.Errorf("expected folder1 to be moved to back, got %s, %s", folder1, folder2) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Add_OutOfOrder(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
baseTime := time.Now() |
|||
|
|||
// Add items out of order
|
|||
q.Add("/buckets/b1/folder3", baseTime.Add(3*time.Second)) |
|||
q.Add("/buckets/b1/folder1", baseTime.Add(1*time.Second)) |
|||
q.Add("/buckets/b1/folder2", baseTime.Add(2*time.Second)) |
|||
|
|||
// Items should be in time order (oldest first) - verify by popping
|
|||
expected := []string{"/buckets/b1/folder1", "/buckets/b1/folder2", "/buckets/b1/folder3"} |
|||
for i, exp := range expected { |
|||
folder, ok := q.Pop() |
|||
if !ok || folder != exp { |
|||
t.Errorf("at index %d: expected %s, got %s", i, exp, folder) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Add_DuplicateWithOlderTime(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
baseTime := time.Now() |
|||
|
|||
// Add folder at t=5
|
|||
q.Add("/buckets/b1/folder1", baseTime.Add(5*time.Second)) |
|||
|
|||
// Try to add same folder with older time - should NOT update
|
|||
q.Add("/buckets/b1/folder1", baseTime.Add(2*time.Second)) |
|||
|
|||
// Time should remain at t=5
|
|||
_, queueTime, _ := q.Peek() |
|||
if queueTime != baseTime.Add(5*time.Second) { |
|||
t.Errorf("expected time to remain unchanged, got %v", queueTime) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Remove(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) |
|||
q.Add("/buckets/b1/folder3", now.Add(2*time.Second)) |
|||
|
|||
// Remove middle item
|
|||
if !q.Remove("/buckets/b1/folder2") { |
|||
t.Error("expected Remove to return true for existing item") |
|||
} |
|||
if q.Len() != 2 { |
|||
t.Errorf("expected len 2, got %d", q.Len()) |
|||
} |
|||
if q.Contains("/buckets/b1/folder2") { |
|||
t.Error("removed item should not be in queue") |
|||
} |
|||
|
|||
// Remove non-existent item
|
|||
if q.Remove("/buckets/b1/nonexistent") { |
|||
t.Error("expected Remove to return false for non-existent item") |
|||
} |
|||
|
|||
// Verify order is preserved by popping
|
|||
folder1, _ := q.Pop() |
|||
folder3, _ := q.Pop() |
|||
if folder1 != "/buckets/b1/folder1" || folder3 != "/buckets/b1/folder3" { |
|||
t.Errorf("unexpected order: %s, %s", folder1, folder3) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Pop(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
// Pop from empty queue
|
|||
folder, ok := q.Pop() |
|||
if ok { |
|||
t.Error("expected Pop to return false for empty queue") |
|||
} |
|||
if folder != "" { |
|||
t.Errorf("expected empty folder, got %s", folder) |
|||
} |
|||
|
|||
// Add items and pop in order
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) |
|||
q.Add("/buckets/b1/folder3", now.Add(2*time.Second)) |
|||
|
|||
folder, ok = q.Pop() |
|||
if !ok || folder != "/buckets/b1/folder1" { |
|||
t.Errorf("expected folder1, got %s (ok=%v)", folder, ok) |
|||
} |
|||
|
|||
folder, ok = q.Pop() |
|||
if !ok || folder != "/buckets/b1/folder2" { |
|||
t.Errorf("expected folder2, got %s (ok=%v)", folder, ok) |
|||
} |
|||
|
|||
folder, ok = q.Pop() |
|||
if !ok || folder != "/buckets/b1/folder3" { |
|||
t.Errorf("expected folder3, got %s (ok=%v)", folder, ok) |
|||
} |
|||
|
|||
// Queue should be empty now
|
|||
if q.Len() != 0 { |
|||
t.Errorf("expected empty queue, got len %d", q.Len()) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Peek(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
// Peek empty queue
|
|||
folder, _, ok := q.Peek() |
|||
if ok { |
|||
t.Error("expected Peek to return false for empty queue") |
|||
} |
|||
|
|||
// Add item and peek
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
folder, queueTime, ok := q.Peek() |
|||
if !ok || folder != "/buckets/b1/folder1" { |
|||
t.Errorf("expected folder1, got %s (ok=%v)", folder, ok) |
|||
} |
|||
if queueTime != now { |
|||
t.Errorf("expected queue time %v, got %v", now, queueTime) |
|||
} |
|||
|
|||
// Peek should not remove item
|
|||
if q.Len() != 1 { |
|||
t.Errorf("Peek should not remove item, len=%d", q.Len()) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Contains(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
|
|||
if !q.Contains("/buckets/b1/folder1") { |
|||
t.Error("expected Contains to return true") |
|||
} |
|||
if q.Contains("/buckets/b1/folder2") { |
|||
t.Error("expected Contains to return false for non-existent") |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_ShouldProcess_MaxSize(t *testing.T) { |
|||
q := NewCleanupQueue(3, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
// Empty queue
|
|||
if q.ShouldProcess() { |
|||
t.Error("empty queue should not need processing") |
|||
} |
|||
|
|||
// Add items below max
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) |
|||
if q.ShouldProcess() { |
|||
t.Error("queue below max should not need processing") |
|||
} |
|||
|
|||
// Add item to reach max
|
|||
q.Add("/buckets/b1/folder3", now.Add(2*time.Second)) |
|||
if !q.ShouldProcess() { |
|||
t.Error("queue at max should need processing") |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_ShouldProcess_MaxAge(t *testing.T) { |
|||
q := NewCleanupQueue(100, 100*time.Millisecond) // Short max age for testing
|
|||
|
|||
// Add item with old event time
|
|||
oldTime := time.Now().Add(-1 * time.Second) // 1 second ago
|
|||
q.Add("/buckets/b1/folder1", oldTime) |
|||
|
|||
// Item is older than maxAge, should need processing
|
|||
if !q.ShouldProcess() { |
|||
t.Error("old item should trigger processing") |
|||
} |
|||
|
|||
// Clear and add fresh item
|
|||
q.Clear() |
|||
q.Add("/buckets/b1/folder2", time.Now()) |
|||
|
|||
// Fresh item should not trigger processing
|
|||
if q.ShouldProcess() { |
|||
t.Error("fresh item should not trigger processing") |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Clear(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
now := time.Now() |
|||
|
|||
q.Add("/buckets/b1/folder1", now) |
|||
q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) |
|||
q.Add("/buckets/b1/folder3", now.Add(2*time.Second)) |
|||
|
|||
q.Clear() |
|||
|
|||
if q.Len() != 0 { |
|||
t.Errorf("expected empty queue after Clear, got len %d", q.Len()) |
|||
} |
|||
if q.Contains("/buckets/b1/folder1") { |
|||
t.Error("queue should not contain items after Clear") |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_OldestAge(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
|
|||
// Empty queue
|
|||
if q.OldestAge() != 0 { |
|||
t.Error("empty queue should have zero oldest age") |
|||
} |
|||
|
|||
// Add item with time in the past
|
|||
oldTime := time.Now().Add(-5 * time.Minute) |
|||
q.Add("/buckets/b1/folder1", oldTime) |
|||
|
|||
// Age should be approximately 5 minutes
|
|||
age := q.OldestAge() |
|||
if age < 4*time.Minute || age > 6*time.Minute { |
|||
t.Errorf("expected ~5m age, got %v", age) |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_TimeOrder(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
baseTime := time.Now() |
|||
|
|||
// Add items in order
|
|||
items := []string{ |
|||
"/buckets/b1/a", |
|||
"/buckets/b1/b", |
|||
"/buckets/b1/c", |
|||
"/buckets/b1/d", |
|||
"/buckets/b1/e", |
|||
} |
|||
for i, item := range items { |
|||
q.Add(item, baseTime.Add(time.Duration(i)*time.Second)) |
|||
} |
|||
|
|||
// Pop should return in time order
|
|||
for i, expected := range items { |
|||
got, ok := q.Pop() |
|||
if !ok { |
|||
t.Errorf("Pop %d: expected item, got empty", i) |
|||
} |
|||
if got != expected { |
|||
t.Errorf("Pop %d: expected %s, got %s", i, expected, got) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_DuplicateWithNewerTime(t *testing.T) { |
|||
q := NewCleanupQueue(100, 10*time.Minute) |
|||
baseTime := time.Now() |
|||
|
|||
// Add items
|
|||
q.Add("/buckets/b1/folder1", baseTime) |
|||
q.Add("/buckets/b1/folder2", baseTime.Add(1*time.Second)) |
|||
q.Add("/buckets/b1/folder3", baseTime.Add(2*time.Second)) |
|||
|
|||
// Add duplicate with newer time - should update and reposition
|
|||
q.Add("/buckets/b1/folder1", baseTime.Add(3*time.Second)) |
|||
|
|||
// folder1 should now be at the back (newest time) - verify by popping
|
|||
expected := []string{"/buckets/b1/folder2", "/buckets/b1/folder3", "/buckets/b1/folder1"} |
|||
for i, exp := range expected { |
|||
folder, ok := q.Pop() |
|||
if !ok || folder != exp { |
|||
t.Errorf("at index %d: expected %s, got %s", i, exp, folder) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestCleanupQueue_Concurrent(t *testing.T) { |
|||
q := NewCleanupQueue(1000, 10*time.Minute) |
|||
done := make(chan bool) |
|||
now := time.Now() |
|||
|
|||
// Concurrent adds
|
|||
go func() { |
|||
for i := 0; i < 100; i++ { |
|||
q.Add("/buckets/b1/folder"+string(rune('A'+i%26)), now.Add(time.Duration(i)*time.Millisecond)) |
|||
} |
|||
done <- true |
|||
}() |
|||
|
|||
// Concurrent removes
|
|||
go func() { |
|||
for i := 0; i < 50; i++ { |
|||
q.Remove("/buckets/b1/folder" + string(rune('A'+i%26))) |
|||
} |
|||
done <- true |
|||
}() |
|||
|
|||
// Concurrent pops
|
|||
go func() { |
|||
for i := 0; i < 30; i++ { |
|||
q.Pop() |
|||
} |
|||
done <- true |
|||
}() |
|||
|
|||
// Concurrent reads
|
|||
go func() { |
|||
for i := 0; i < 100; i++ { |
|||
q.Len() |
|||
q.Contains("/buckets/b1/folderA") |
|||
q.ShouldProcess() |
|||
} |
|||
done <- true |
|||
}() |
|||
|
|||
// Wait for all goroutines
|
|||
for i := 0; i < 4; i++ { |
|||
<-done |
|||
} |
|||
|
|||
// Just verify no panic occurred and queue is in consistent state
|
|||
_ = q.Len() |
|||
} |
|||
|
|||
|
|||
@ -0,0 +1,436 @@ |
|||
package empty_folder_cleanup |
|||
|
|||
import ( |
|||
"context" |
|||
"strings" |
|||
"sync" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager" |
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/util" |
|||
) |
|||
|
|||
const ( |
|||
DefaultMaxCountCheck = 1000 |
|||
DefaultCacheExpiry = 5 * time.Minute |
|||
DefaultQueueMaxSize = 1000 |
|||
DefaultQueueMaxAge = 10 * time.Minute |
|||
DefaultProcessorSleep = 10 * time.Second // How often to check queue
|
|||
) |
|||
|
|||
// FilerOperations defines the filer operations needed by EmptyFolderCleaner
|
|||
type FilerOperations interface { |
|||
CountDirectoryEntries(ctx context.Context, dirPath util.FullPath, limit int) (count int, err error) |
|||
DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, ifNotModifiedAfter int64) error |
|||
} |
|||
|
|||
// folderState tracks the state of a folder for empty folder cleanup
|
|||
type folderState struct { |
|||
roughCount int // Cached rough count (up to maxCountCheck)
|
|||
lastAddTime time.Time // Last time an item was added
|
|||
lastDelTime time.Time // Last time an item was deleted
|
|||
lastCheck time.Time // Last time we checked the actual count
|
|||
} |
|||
|
|||
// EmptyFolderCleaner handles asynchronous cleanup of empty folders
|
|||
// Each filer owns specific folders via consistent hashing based on the peer filer list
|
|||
type EmptyFolderCleaner struct { |
|||
filer FilerOperations |
|||
lockRing *lock_manager.LockRing |
|||
host pb.ServerAddress |
|||
|
|||
// Folder state tracking
|
|||
mu sync.RWMutex |
|||
folderCounts map[string]*folderState // Rough count cache
|
|||
|
|||
// Cleanup queue (thread-safe, has its own lock)
|
|||
cleanupQueue *CleanupQueue |
|||
|
|||
// Configuration
|
|||
maxCountCheck int // Max items to count (1000)
|
|||
cacheExpiry time.Duration // How long to keep cache entries
|
|||
processorSleep time.Duration // How often processor checks queue
|
|||
bucketPath string // e.g., "/buckets"
|
|||
|
|||
// Control
|
|||
enabled bool |
|||
stopCh chan struct{} |
|||
} |
|||
|
|||
// NewEmptyFolderCleaner creates a new EmptyFolderCleaner
|
|||
func NewEmptyFolderCleaner(filer FilerOperations, lockRing *lock_manager.LockRing, host pb.ServerAddress, bucketPath string) *EmptyFolderCleaner { |
|||
efc := &EmptyFolderCleaner{ |
|||
filer: filer, |
|||
lockRing: lockRing, |
|||
host: host, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(DefaultQueueMaxSize, DefaultQueueMaxAge), |
|||
maxCountCheck: DefaultMaxCountCheck, |
|||
cacheExpiry: DefaultCacheExpiry, |
|||
processorSleep: DefaultProcessorSleep, |
|||
bucketPath: bucketPath, |
|||
enabled: true, |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
go efc.cacheEvictionLoop() |
|||
go efc.cleanupProcessor() |
|||
return efc |
|||
} |
|||
|
|||
// SetEnabled enables or disables the cleaner
|
|||
func (efc *EmptyFolderCleaner) SetEnabled(enabled bool) { |
|||
efc.mu.Lock() |
|||
defer efc.mu.Unlock() |
|||
efc.enabled = enabled |
|||
} |
|||
|
|||
// IsEnabled returns whether the cleaner is enabled
|
|||
func (efc *EmptyFolderCleaner) IsEnabled() bool { |
|||
efc.mu.RLock() |
|||
defer efc.mu.RUnlock() |
|||
return efc.enabled |
|||
} |
|||
|
|||
// ownsFolder checks if this filer owns the folder via consistent hashing
|
|||
func (efc *EmptyFolderCleaner) ownsFolder(folder string) bool { |
|||
servers := efc.lockRing.GetSnapshot() |
|||
if len(servers) <= 1 { |
|||
return true // Single filer case
|
|||
} |
|||
return efc.hashKeyToServer(folder, servers) == efc.host |
|||
} |
|||
|
|||
// hashKeyToServer uses consistent hashing to map a folder to a server
|
|||
func (efc *EmptyFolderCleaner) hashKeyToServer(key string, servers []pb.ServerAddress) pb.ServerAddress { |
|||
if len(servers) == 0 { |
|||
return "" |
|||
} |
|||
x := util.HashStringToLong(key) |
|||
if x < 0 { |
|||
x = -x |
|||
} |
|||
x = x % int64(len(servers)) |
|||
return servers[x] |
|||
} |
|||
|
|||
// OnDeleteEvent is called when a file or directory is deleted
|
|||
// Both file and directory deletions count towards making the parent folder empty
|
|||
// eventTime is the time when the delete event occurred (for proper ordering)
|
|||
func (efc *EmptyFolderCleaner) OnDeleteEvent(directory string, entryName string, isDirectory bool, eventTime time.Time) { |
|||
// Skip if not under bucket path (must be at least /buckets/<bucket>/...)
|
|||
if efc.bucketPath != "" && !isUnderBucketPath(directory, efc.bucketPath) { |
|||
return |
|||
} |
|||
|
|||
// Check if we own this folder
|
|||
if !efc.ownsFolder(directory) { |
|||
glog.V(4).Infof("EmptyFolderCleaner: not owner of %s, skipping", directory) |
|||
return |
|||
} |
|||
|
|||
efc.mu.Lock() |
|||
defer efc.mu.Unlock() |
|||
|
|||
// Check enabled inside lock to avoid race with Stop()
|
|||
if !efc.enabled { |
|||
return |
|||
} |
|||
|
|||
glog.V(3).Infof("EmptyFolderCleaner: delete event in %s/%s (isDir=%v)", directory, entryName, isDirectory) |
|||
|
|||
// Update cached count (create entry if needed)
|
|||
state, exists := efc.folderCounts[directory] |
|||
if !exists { |
|||
state = &folderState{} |
|||
efc.folderCounts[directory] = state |
|||
} |
|||
if state.roughCount > 0 { |
|||
state.roughCount-- |
|||
} |
|||
state.lastDelTime = eventTime |
|||
|
|||
// Only add to cleanup queue if roughCount suggests folder might be empty
|
|||
if state.roughCount > 0 { |
|||
glog.V(3).Infof("EmptyFolderCleaner: skipping queue for %s, roughCount=%d", directory, state.roughCount) |
|||
return |
|||
} |
|||
|
|||
// Add to cleanup queue with event time (handles out-of-order events)
|
|||
if efc.cleanupQueue.Add(directory, eventTime) { |
|||
glog.V(3).Infof("EmptyFolderCleaner: queued %s for cleanup", directory) |
|||
} |
|||
} |
|||
|
|||
// OnCreateEvent is called when a file or directory is created
|
|||
// Both file and directory creations cancel pending cleanup for the parent folder
|
|||
func (efc *EmptyFolderCleaner) OnCreateEvent(directory string, entryName string, isDirectory bool) { |
|||
// Skip if not under bucket path (must be at least /buckets/<bucket>/...)
|
|||
if efc.bucketPath != "" && !isUnderBucketPath(directory, efc.bucketPath) { |
|||
return |
|||
} |
|||
|
|||
efc.mu.Lock() |
|||
defer efc.mu.Unlock() |
|||
|
|||
// Check enabled inside lock to avoid race with Stop()
|
|||
if !efc.enabled { |
|||
return |
|||
} |
|||
|
|||
// Update cached count only if already tracked (no need to track new folders)
|
|||
if state, exists := efc.folderCounts[directory]; exists { |
|||
state.roughCount++ |
|||
state.lastAddTime = time.Now() |
|||
} |
|||
|
|||
// Remove from cleanup queue (cancel pending cleanup)
|
|||
if efc.cleanupQueue.Remove(directory) { |
|||
glog.V(3).Infof("EmptyFolderCleaner: cancelled cleanup for %s due to new entry", directory) |
|||
} |
|||
} |
|||
|
|||
// cleanupProcessor runs in background and processes the cleanup queue
|
|||
func (efc *EmptyFolderCleaner) cleanupProcessor() { |
|||
ticker := time.NewTicker(efc.processorSleep) |
|||
defer ticker.Stop() |
|||
|
|||
for { |
|||
select { |
|||
case <-efc.stopCh: |
|||
return |
|||
case <-ticker.C: |
|||
efc.processCleanupQueue() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// processCleanupQueue processes items from the cleanup queue
|
|||
func (efc *EmptyFolderCleaner) processCleanupQueue() { |
|||
// Check if we should process
|
|||
if !efc.cleanupQueue.ShouldProcess() { |
|||
return |
|||
} |
|||
|
|||
glog.V(3).Infof("EmptyFolderCleaner: processing cleanup queue (len=%d, age=%v)", |
|||
efc.cleanupQueue.Len(), efc.cleanupQueue.OldestAge()) |
|||
|
|||
// Process all items that are ready
|
|||
for efc.cleanupQueue.Len() > 0 { |
|||
// Check if still enabled
|
|||
if !efc.IsEnabled() { |
|||
return |
|||
} |
|||
|
|||
// Pop the oldest item
|
|||
folder, ok := efc.cleanupQueue.Pop() |
|||
if !ok { |
|||
break |
|||
} |
|||
|
|||
// Execute cleanup for this folder
|
|||
efc.executeCleanup(folder) |
|||
|
|||
// If queue is no longer full and oldest item is not old enough, stop processing
|
|||
if !efc.cleanupQueue.ShouldProcess() { |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
// executeCleanup performs the actual cleanup of an empty folder
|
|||
func (efc *EmptyFolderCleaner) executeCleanup(folder string) { |
|||
efc.mu.Lock() |
|||
|
|||
// Quick check: if we have cached count and it's > 0, skip
|
|||
if state, exists := efc.folderCounts[folder]; exists { |
|||
if state.roughCount > 0 { |
|||
glog.V(3).Infof("EmptyFolderCleaner: skipping %s, cached count=%d", folder, state.roughCount) |
|||
efc.mu.Unlock() |
|||
return |
|||
} |
|||
// If there was an add after our delete, skip
|
|||
if !state.lastAddTime.IsZero() && state.lastAddTime.After(state.lastDelTime) { |
|||
glog.V(3).Infof("EmptyFolderCleaner: skipping %s, add happened after delete", folder) |
|||
efc.mu.Unlock() |
|||
return |
|||
} |
|||
} |
|||
efc.mu.Unlock() |
|||
|
|||
// Re-check ownership (topology might have changed)
|
|||
if !efc.ownsFolder(folder) { |
|||
glog.V(3).Infof("EmptyFolderCleaner: no longer owner of %s, skipping", folder) |
|||
return |
|||
} |
|||
|
|||
// Check if folder is actually empty (count up to maxCountCheck)
|
|||
ctx := context.Background() |
|||
count, err := efc.countItems(ctx, folder) |
|||
if err != nil { |
|||
glog.V(2).Infof("EmptyFolderCleaner: error counting items in %s: %v", folder, err) |
|||
return |
|||
} |
|||
|
|||
efc.mu.Lock() |
|||
// Update cache
|
|||
if _, exists := efc.folderCounts[folder]; !exists { |
|||
efc.folderCounts[folder] = &folderState{} |
|||
} |
|||
efc.folderCounts[folder].roughCount = count |
|||
efc.folderCounts[folder].lastCheck = time.Now() |
|||
efc.mu.Unlock() |
|||
|
|||
if count > 0 { |
|||
glog.V(3).Infof("EmptyFolderCleaner: folder %s has %d items, not empty", folder, count) |
|||
return |
|||
} |
|||
|
|||
// Delete the empty folder
|
|||
glog.V(2).Infof("EmptyFolderCleaner: deleting empty folder %s", folder) |
|||
if err := efc.deleteFolder(ctx, folder); err != nil { |
|||
glog.V(2).Infof("EmptyFolderCleaner: failed to delete empty folder %s: %v", folder, err) |
|||
return |
|||
} |
|||
|
|||
// Clean up cache entry
|
|||
efc.mu.Lock() |
|||
delete(efc.folderCounts, folder) |
|||
efc.mu.Unlock() |
|||
|
|||
// Note: No need to recursively check parent folder here.
|
|||
// The deletion of this folder will generate a metadata event,
|
|||
// which will trigger OnDeleteEvent for the parent folder.
|
|||
} |
|||
|
|||
// countItems counts items in a folder (up to maxCountCheck)
|
|||
func (efc *EmptyFolderCleaner) countItems(ctx context.Context, folder string) (int, error) { |
|||
return efc.filer.CountDirectoryEntries(ctx, util.FullPath(folder), efc.maxCountCheck) |
|||
} |
|||
|
|||
// deleteFolder deletes an empty folder
|
|||
func (efc *EmptyFolderCleaner) deleteFolder(ctx context.Context, folder string) error { |
|||
return efc.filer.DeleteEntryMetaAndData(ctx, util.FullPath(folder), false, false, false, false, nil, 0) |
|||
} |
|||
|
|||
// isUnderPath checks if child is under parent path
|
|||
func isUnderPath(child, parent string) bool { |
|||
if parent == "" || parent == "/" { |
|||
return true |
|||
} |
|||
// Ensure parent ends without slash for proper prefix matching
|
|||
if len(parent) > 0 && parent[len(parent)-1] == '/' { |
|||
parent = parent[:len(parent)-1] |
|||
} |
|||
// Child must start with parent and then have a / or be exactly parent
|
|||
if len(child) < len(parent) { |
|||
return false |
|||
} |
|||
if child[:len(parent)] != parent { |
|||
return false |
|||
} |
|||
if len(child) == len(parent) { |
|||
return true |
|||
} |
|||
return child[len(parent)] == '/' |
|||
} |
|||
|
|||
// isUnderBucketPath checks if directory is inside a bucket (under /buckets/<bucket>/...)
|
|||
// This ensures we only clean up folders inside buckets, not the buckets themselves
|
|||
func isUnderBucketPath(directory, bucketPath string) bool { |
|||
if bucketPath == "" { |
|||
return true |
|||
} |
|||
// Ensure bucketPath ends without slash
|
|||
if len(bucketPath) > 0 && bucketPath[len(bucketPath)-1] == '/' { |
|||
bucketPath = bucketPath[:len(bucketPath)-1] |
|||
} |
|||
// Directory must be under bucketPath
|
|||
if !isUnderPath(directory, bucketPath) { |
|||
return false |
|||
} |
|||
// Directory must be at least /buckets/<bucket>/<something>
|
|||
// i.e., depth must be at least bucketPath depth + 2
|
|||
// For /buckets (depth 1), we need at least /buckets/mybucket/folder (depth 3)
|
|||
bucketPathDepth := strings.Count(bucketPath, "/") |
|||
directoryDepth := strings.Count(directory, "/") |
|||
return directoryDepth >= bucketPathDepth+2 |
|||
} |
|||
|
|||
// cacheEvictionLoop periodically removes stale entries from folderCounts
|
|||
func (efc *EmptyFolderCleaner) cacheEvictionLoop() { |
|||
ticker := time.NewTicker(efc.cacheExpiry) |
|||
defer ticker.Stop() |
|||
|
|||
for { |
|||
select { |
|||
case <-efc.stopCh: |
|||
return |
|||
case <-ticker.C: |
|||
efc.evictStaleCacheEntries() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// evictStaleCacheEntries removes cache entries that haven't been accessed recently
|
|||
func (efc *EmptyFolderCleaner) evictStaleCacheEntries() { |
|||
efc.mu.Lock() |
|||
defer efc.mu.Unlock() |
|||
|
|||
now := time.Now() |
|||
expiredCount := 0 |
|||
for folder, state := range efc.folderCounts { |
|||
// Skip if folder is in cleanup queue
|
|||
if efc.cleanupQueue.Contains(folder) { |
|||
continue |
|||
} |
|||
|
|||
// Find the most recent activity time for this folder
|
|||
lastActivity := state.lastCheck |
|||
if state.lastAddTime.After(lastActivity) { |
|||
lastActivity = state.lastAddTime |
|||
} |
|||
if state.lastDelTime.After(lastActivity) { |
|||
lastActivity = state.lastDelTime |
|||
} |
|||
|
|||
// Evict if no activity within cache expiry period
|
|||
if now.Sub(lastActivity) > efc.cacheExpiry { |
|||
delete(efc.folderCounts, folder) |
|||
expiredCount++ |
|||
} |
|||
} |
|||
|
|||
if expiredCount > 0 { |
|||
glog.V(3).Infof("EmptyFolderCleaner: evicted %d stale cache entries", expiredCount) |
|||
} |
|||
} |
|||
|
|||
// Stop stops the cleaner and cancels all pending tasks
|
|||
func (efc *EmptyFolderCleaner) Stop() { |
|||
close(efc.stopCh) |
|||
|
|||
efc.mu.Lock() |
|||
defer efc.mu.Unlock() |
|||
|
|||
efc.enabled = false |
|||
efc.cleanupQueue.Clear() |
|||
efc.folderCounts = make(map[string]*folderState) // Clear cache on stop
|
|||
} |
|||
|
|||
// GetPendingCleanupCount returns the number of pending cleanup tasks (for testing)
|
|||
func (efc *EmptyFolderCleaner) GetPendingCleanupCount() int { |
|||
return efc.cleanupQueue.Len() |
|||
} |
|||
|
|||
// GetCachedFolderCount returns the cached count for a folder (for testing)
|
|||
func (efc *EmptyFolderCleaner) GetCachedFolderCount(folder string) (int, bool) { |
|||
efc.mu.RLock() |
|||
defer efc.mu.RUnlock() |
|||
if state, exists := efc.folderCounts[folder]; exists { |
|||
return state.roughCount, true |
|||
} |
|||
return 0, false |
|||
} |
|||
|
|||
@ -0,0 +1,569 @@ |
|||
package empty_folder_cleanup |
|||
|
|||
import ( |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb" |
|||
) |
|||
|
|||
func Test_isUnderPath(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
child string |
|||
parent string |
|||
expected bool |
|||
}{ |
|||
{"child under parent", "/buckets/mybucket/folder/file.txt", "/buckets", true}, |
|||
{"child is parent", "/buckets", "/buckets", true}, |
|||
{"child not under parent", "/other/path", "/buckets", false}, |
|||
{"empty parent", "/any/path", "", true}, |
|||
{"root parent", "/any/path", "/", true}, |
|||
{"parent with trailing slash", "/buckets/mybucket", "/buckets/", true}, |
|||
{"similar prefix but not under", "/buckets-other/file", "/buckets", false}, |
|||
{"deeply nested", "/buckets/a/b/c/d/e/f", "/buckets/a/b", true}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := isUnderPath(tt.child, tt.parent) |
|||
if result != tt.expected { |
|||
t.Errorf("isUnderPath(%q, %q) = %v, want %v", tt.child, tt.parent, result, tt.expected) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func Test_isUnderBucketPath(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
directory string |
|||
bucketPath string |
|||
expected bool |
|||
}{ |
|||
// Should NOT process - bucket path itself
|
|||
{"bucket path itself", "/buckets", "/buckets", false}, |
|||
// Should NOT process - bucket directory (immediate child)
|
|||
{"bucket directory", "/buckets/mybucket", "/buckets", false}, |
|||
// Should process - folder inside bucket
|
|||
{"folder in bucket", "/buckets/mybucket/folder", "/buckets", true}, |
|||
// Should process - nested folder
|
|||
{"nested folder", "/buckets/mybucket/a/b/c", "/buckets", true}, |
|||
// Should NOT process - outside buckets
|
|||
{"outside buckets", "/other/path", "/buckets", false}, |
|||
// Empty bucket path allows all
|
|||
{"empty bucket path", "/any/path", "", true}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := isUnderBucketPath(tt.directory, tt.bucketPath) |
|||
if result != tt.expected { |
|||
t.Errorf("isUnderBucketPath(%q, %q) = %v, want %v", tt.directory, tt.bucketPath, result, tt.expected) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_ownsFolder(t *testing.T) { |
|||
// Create a LockRing with multiple servers
|
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
|
|||
servers := []pb.ServerAddress{ |
|||
"filer1:8888", |
|||
"filer2:8888", |
|||
"filer3:8888", |
|||
} |
|||
lockRing.SetSnapshot(servers) |
|||
|
|||
// Create cleaner for filer1
|
|||
cleaner1 := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
} |
|||
|
|||
// Create cleaner for filer2
|
|||
cleaner2 := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer2:8888", |
|||
} |
|||
|
|||
// Create cleaner for filer3
|
|||
cleaner3 := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer3:8888", |
|||
} |
|||
|
|||
// Test that exactly one filer owns each folder
|
|||
testFolders := []string{ |
|||
"/buckets/mybucket/folder1", |
|||
"/buckets/mybucket/folder2", |
|||
"/buckets/mybucket/folder3", |
|||
"/buckets/mybucket/a/b/c", |
|||
"/buckets/otherbucket/x", |
|||
} |
|||
|
|||
for _, folder := range testFolders { |
|||
ownCount := 0 |
|||
if cleaner1.ownsFolder(folder) { |
|||
ownCount++ |
|||
} |
|||
if cleaner2.ownsFolder(folder) { |
|||
ownCount++ |
|||
} |
|||
if cleaner3.ownsFolder(folder) { |
|||
ownCount++ |
|||
} |
|||
|
|||
if ownCount != 1 { |
|||
t.Errorf("folder %q owned by %d filers, expected exactly 1", folder, ownCount) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_ownsFolder_singleServer(t *testing.T) { |
|||
// Create a LockRing with a single server
|
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
} |
|||
|
|||
// Single filer should own all folders
|
|||
testFolders := []string{ |
|||
"/buckets/mybucket/folder1", |
|||
"/buckets/mybucket/folder2", |
|||
"/buckets/otherbucket/x", |
|||
} |
|||
|
|||
for _, folder := range testFolders { |
|||
if !cleaner.ownsFolder(folder) { |
|||
t.Errorf("single filer should own folder %q", folder) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_ownsFolder_emptyRing(t *testing.T) { |
|||
// Create an empty LockRing
|
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
} |
|||
|
|||
// With empty ring, should own all folders
|
|||
if !cleaner.ownsFolder("/buckets/mybucket/folder") { |
|||
t.Error("should own folder with empty ring") |
|||
} |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnCreateEvent_cancelsCleanup(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/testfolder" |
|||
now := time.Now() |
|||
|
|||
// Simulate delete event
|
|||
cleaner.OnDeleteEvent(folder, "file.txt", false, now) |
|||
|
|||
// Check that cleanup is queued
|
|||
if cleaner.GetPendingCleanupCount() != 1 { |
|||
t.Errorf("expected 1 pending cleanup, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
// Simulate create event
|
|||
cleaner.OnCreateEvent(folder, "newfile.txt", false) |
|||
|
|||
// Check that cleanup is cancelled
|
|||
if cleaner.GetPendingCleanupCount() != 0 { |
|||
t.Errorf("expected 0 pending cleanups after create, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnDeleteEvent_deduplication(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/testfolder" |
|||
now := time.Now() |
|||
|
|||
// Simulate multiple delete events for same folder
|
|||
for i := 0; i < 5; i++ { |
|||
cleaner.OnDeleteEvent(folder, "file"+string(rune('0'+i))+".txt", false, now.Add(time.Duration(i)*time.Second)) |
|||
} |
|||
|
|||
// Check that only 1 cleanup is queued (deduplicated)
|
|||
if cleaner.GetPendingCleanupCount() != 1 { |
|||
t.Errorf("expected 1 pending cleanup after deduplication, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnDeleteEvent_multipleFolders(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
now := time.Now() |
|||
|
|||
// Delete files in different folders
|
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder1", "file.txt", false, now) |
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder2", "file.txt", false, now.Add(1*time.Second)) |
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder3", "file.txt", false, now.Add(2*time.Second)) |
|||
|
|||
// Each folder should be queued
|
|||
if cleaner.GetPendingCleanupCount() != 3 { |
|||
t.Errorf("expected 3 pending cleanups, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnDeleteEvent_notOwner(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888", "filer2:8888"}) |
|||
|
|||
// Create cleaner for filer that doesn't own the folder
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
now := time.Now() |
|||
|
|||
// Try many folders, looking for one that filer1 doesn't own
|
|||
foundNonOwned := false |
|||
for i := 0; i < 100; i++ { |
|||
folder := "/buckets/mybucket/folder" + string(rune('0'+i%10)) + string(rune('0'+i/10)) |
|||
if !cleaner.ownsFolder(folder) { |
|||
// This folder is not owned by filer1
|
|||
cleaner.OnDeleteEvent(folder, "file.txt", false, now) |
|||
if cleaner.GetPendingCleanupCount() != 0 { |
|||
t.Errorf("non-owner should not queue cleanup for folder %s", folder) |
|||
} |
|||
foundNonOwned = true |
|||
break |
|||
} |
|||
} |
|||
|
|||
if !foundNonOwned { |
|||
t.Skip("could not find a folder not owned by filer1") |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnDeleteEvent_disabled(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: false, // Disabled
|
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/testfolder" |
|||
now := time.Now() |
|||
|
|||
// Simulate delete event
|
|||
cleaner.OnDeleteEvent(folder, "file.txt", false, now) |
|||
|
|||
// Check that no cleanup is queued when disabled
|
|||
if cleaner.GetPendingCleanupCount() != 0 { |
|||
t.Errorf("disabled cleaner should not queue cleanup, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_OnDeleteEvent_directoryDeletion(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/testfolder" |
|||
now := time.Now() |
|||
|
|||
// Simulate directory delete event - should trigger cleanup
|
|||
// because subdirectory deletion also makes parent potentially empty
|
|||
cleaner.OnDeleteEvent(folder, "subdir", true, now) |
|||
|
|||
// Check that cleanup IS queued for directory deletion
|
|||
if cleaner.GetPendingCleanupCount() != 1 { |
|||
t.Errorf("directory deletion should trigger cleanup, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_cachedCounts(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/testfolder" |
|||
|
|||
// Initialize cached count
|
|||
cleaner.folderCounts[folder] = &folderState{roughCount: 5} |
|||
|
|||
// Simulate create events
|
|||
cleaner.OnCreateEvent(folder, "newfile1.txt", false) |
|||
cleaner.OnCreateEvent(folder, "newfile2.txt", false) |
|||
|
|||
// Check cached count increased
|
|||
count, exists := cleaner.GetCachedFolderCount(folder) |
|||
if !exists { |
|||
t.Error("cached folder count should exist") |
|||
} |
|||
if count != 7 { |
|||
t.Errorf("expected cached count 7, got %d", count) |
|||
} |
|||
|
|||
// Simulate delete events
|
|||
now := time.Now() |
|||
cleaner.OnDeleteEvent(folder, "file1.txt", false, now) |
|||
cleaner.OnDeleteEvent(folder, "file2.txt", false, now.Add(1*time.Second)) |
|||
|
|||
// Check cached count decreased
|
|||
count, exists = cleaner.GetCachedFolderCount(folder) |
|||
if !exists { |
|||
t.Error("cached folder count should exist") |
|||
} |
|||
if count != 5 { |
|||
t.Errorf("expected cached count 5, got %d", count) |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_Stop(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
now := time.Now() |
|||
|
|||
// Queue some cleanups
|
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder1", "file1.txt", false, now) |
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder2", "file2.txt", false, now.Add(1*time.Second)) |
|||
cleaner.OnDeleteEvent("/buckets/mybucket/folder3", "file3.txt", false, now.Add(2*time.Second)) |
|||
|
|||
// Verify cleanups are queued
|
|||
if cleaner.GetPendingCleanupCount() < 1 { |
|||
t.Error("expected at least 1 pending cleanup before stop") |
|||
} |
|||
|
|||
// Stop the cleaner
|
|||
cleaner.Stop() |
|||
|
|||
// Verify all cleanups are cancelled
|
|||
if cleaner.GetPendingCleanupCount() != 0 { |
|||
t.Errorf("expected 0 pending cleanups after stop, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_cacheEviction(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
cacheExpiry: 100 * time.Millisecond, // Short expiry for testing
|
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder1 := "/buckets/mybucket/folder1" |
|||
folder2 := "/buckets/mybucket/folder2" |
|||
folder3 := "/buckets/mybucket/folder3" |
|||
|
|||
// Add some cache entries with old timestamps
|
|||
oldTime := time.Now().Add(-1 * time.Hour) |
|||
cleaner.folderCounts[folder1] = &folderState{roughCount: 5, lastCheck: oldTime} |
|||
cleaner.folderCounts[folder2] = &folderState{roughCount: 3, lastCheck: oldTime} |
|||
// folder3 has recent activity
|
|||
cleaner.folderCounts[folder3] = &folderState{roughCount: 2, lastCheck: time.Now()} |
|||
|
|||
// Verify all entries exist
|
|||
if len(cleaner.folderCounts) != 3 { |
|||
t.Errorf("expected 3 cache entries, got %d", len(cleaner.folderCounts)) |
|||
} |
|||
|
|||
// Run eviction
|
|||
cleaner.evictStaleCacheEntries() |
|||
|
|||
// Verify stale entries are evicted
|
|||
if len(cleaner.folderCounts) != 1 { |
|||
t.Errorf("expected 1 cache entry after eviction, got %d", len(cleaner.folderCounts)) |
|||
} |
|||
|
|||
// Verify the recent entry still exists
|
|||
if _, exists := cleaner.folderCounts[folder3]; !exists { |
|||
t.Error("expected folder3 to still exist in cache") |
|||
} |
|||
|
|||
// Verify stale entries are removed
|
|||
if _, exists := cleaner.folderCounts[folder1]; exists { |
|||
t.Error("expected folder1 to be evicted") |
|||
} |
|||
if _, exists := cleaner.folderCounts[folder2]; exists { |
|||
t.Error("expected folder2 to be evicted") |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_cacheEviction_skipsEntriesInQueue(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
cacheExpiry: 100 * time.Millisecond, |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
folder := "/buckets/mybucket/folder" |
|||
oldTime := time.Now().Add(-1 * time.Hour) |
|||
|
|||
// Add a stale cache entry
|
|||
cleaner.folderCounts[folder] = &folderState{roughCount: 0, lastCheck: oldTime} |
|||
// Also add to cleanup queue
|
|||
cleaner.cleanupQueue.Add(folder, time.Now()) |
|||
|
|||
// Run eviction
|
|||
cleaner.evictStaleCacheEntries() |
|||
|
|||
// Verify entry is NOT evicted because it's in cleanup queue
|
|||
if _, exists := cleaner.folderCounts[folder]; !exists { |
|||
t.Error("expected folder to still exist in cache (is in cleanup queue)") |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
func TestEmptyFolderCleaner_queueFIFOOrder(t *testing.T) { |
|||
lockRing := lock_manager.NewLockRing(5 * time.Second) |
|||
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"}) |
|||
|
|||
cleaner := &EmptyFolderCleaner{ |
|||
lockRing: lockRing, |
|||
host: "filer1:8888", |
|||
bucketPath: "/buckets", |
|||
enabled: true, |
|||
folderCounts: make(map[string]*folderState), |
|||
cleanupQueue: NewCleanupQueue(1000, 10*time.Minute), |
|||
stopCh: make(chan struct{}), |
|||
} |
|||
|
|||
now := time.Now() |
|||
|
|||
// Add folders in order
|
|||
folders := []string{ |
|||
"/buckets/mybucket/folder1", |
|||
"/buckets/mybucket/folder2", |
|||
"/buckets/mybucket/folder3", |
|||
} |
|||
for i, folder := range folders { |
|||
cleaner.OnDeleteEvent(folder, "file.txt", false, now.Add(time.Duration(i)*time.Second)) |
|||
} |
|||
|
|||
// Verify queue length
|
|||
if cleaner.GetPendingCleanupCount() != 3 { |
|||
t.Errorf("expected 3 queued folders, got %d", cleaner.GetPendingCleanupCount()) |
|||
} |
|||
|
|||
// Verify time-sorted order by popping
|
|||
for i, expected := range folders { |
|||
folder, ok := cleaner.cleanupQueue.Pop() |
|||
if !ok || folder != expected { |
|||
t.Errorf("expected folder %s at index %d, got %s", expected, i, folder) |
|||
} |
|||
} |
|||
|
|||
cleaner.Stop() |
|||
} |
|||
|
|||
@ -0,0 +1,505 @@ |
|||
package filer |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"sync" |
|||
"sync/atomic" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
// mockChunkCacheForReaderCache implements chunk cache for testing
|
|||
type mockChunkCacheForReaderCache struct { |
|||
data map[string][]byte |
|||
hitCount int32 |
|||
mu sync.Mutex |
|||
} |
|||
|
|||
func newMockChunkCacheForReaderCache() *mockChunkCacheForReaderCache { |
|||
return &mockChunkCacheForReaderCache{ |
|||
data: make(map[string][]byte), |
|||
} |
|||
} |
|||
|
|||
func (m *mockChunkCacheForReaderCache) GetChunk(fileId string, minSize uint64) []byte { |
|||
m.mu.Lock() |
|||
defer m.mu.Unlock() |
|||
if d, ok := m.data[fileId]; ok { |
|||
atomic.AddInt32(&m.hitCount, 1) |
|||
return d |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (m *mockChunkCacheForReaderCache) ReadChunkAt(data []byte, fileId string, offset uint64) (int, error) { |
|||
m.mu.Lock() |
|||
defer m.mu.Unlock() |
|||
if d, ok := m.data[fileId]; ok && int(offset) < len(d) { |
|||
atomic.AddInt32(&m.hitCount, 1) |
|||
n := copy(data, d[offset:]) |
|||
return n, nil |
|||
} |
|||
return 0, nil |
|||
} |
|||
|
|||
func (m *mockChunkCacheForReaderCache) SetChunk(fileId string, data []byte) { |
|||
m.mu.Lock() |
|||
defer m.mu.Unlock() |
|||
m.data[fileId] = data |
|||
} |
|||
|
|||
func (m *mockChunkCacheForReaderCache) GetMaxFilePartSizeInCache() uint64 { |
|||
return 1024 * 1024 // 1MB
|
|||
} |
|||
|
|||
func (m *mockChunkCacheForReaderCache) IsInCache(fileId string, lockNeeded bool) bool { |
|||
m.mu.Lock() |
|||
defer m.mu.Unlock() |
|||
_, ok := m.data[fileId] |
|||
return ok |
|||
} |
|||
|
|||
// TestReaderCacheContextCancellation tests that a reader can cancel its wait
|
|||
// while the download continues for other readers
|
|||
func TestReaderCacheContextCancellation(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
// Create a ReaderCache - we can't easily test the full flow without mocking HTTP,
|
|||
// but we can test the context cancellation in readChunkAt
|
|||
rc := NewReaderCache(10, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
// Pre-populate cache to avoid HTTP calls
|
|||
testData := []byte("test data for context cancellation") |
|||
cache.SetChunk("test-file-1", testData) |
|||
|
|||
// Test that context cancellation works
|
|||
ctx, cancel := context.WithCancel(context.Background()) |
|||
|
|||
buffer := make([]byte, len(testData)) |
|||
n, err := rc.ReadChunkAt(ctx, buffer, "test-file-1", nil, false, 0, len(testData), true) |
|||
if err != nil { |
|||
t.Errorf("Expected no error, got: %v", err) |
|||
} |
|||
if n != len(testData) { |
|||
t.Errorf("Expected %d bytes, got %d", len(testData), n) |
|||
} |
|||
|
|||
// Cancel context and verify it doesn't affect already completed reads
|
|||
cancel() |
|||
|
|||
// Subsequent read with cancelled context should still work from cache
|
|||
buffer2 := make([]byte, len(testData)) |
|||
n2, err2 := rc.ReadChunkAt(ctx, buffer2, "test-file-1", nil, false, 0, len(testData), true) |
|||
// Note: This may or may not error depending on whether it hits cache
|
|||
_ = n2 |
|||
_ = err2 |
|||
} |
|||
|
|||
// TestReaderCacheFallbackToChunkCache tests that when a cacher returns n=0, err=nil,
|
|||
// we fall back to the chunkCache
|
|||
func TestReaderCacheFallbackToChunkCache(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
// Pre-populate the chunk cache with data
|
|||
testData := []byte("fallback test data that should be found in chunk cache") |
|||
cache.SetChunk("fallback-file", testData) |
|||
|
|||
rc := NewReaderCache(10, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
// Read should hit the chunk cache
|
|||
buffer := make([]byte, len(testData)) |
|||
n, err := rc.ReadChunkAt(context.Background(), buffer, "fallback-file", nil, false, 0, len(testData), true) |
|||
|
|||
if err != nil { |
|||
t.Errorf("Expected no error, got: %v", err) |
|||
} |
|||
if n != len(testData) { |
|||
t.Errorf("Expected %d bytes, got %d", len(testData), n) |
|||
} |
|||
|
|||
// Verify cache was hit
|
|||
if cache.hitCount == 0 { |
|||
t.Error("Expected chunk cache to be hit") |
|||
} |
|||
} |
|||
|
|||
// TestReaderCacheMultipleReadersWaitForSameChunk tests that multiple readers
|
|||
// can wait for the same chunk download to complete
|
|||
func TestReaderCacheMultipleReadersWaitForSameChunk(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
// Pre-populate cache so we don't need HTTP
|
|||
testData := make([]byte, 1024) |
|||
for i := range testData { |
|||
testData[i] = byte(i % 256) |
|||
} |
|||
cache.SetChunk("shared-chunk", testData) |
|||
|
|||
rc := NewReaderCache(10, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
// Launch multiple concurrent readers for the same chunk
|
|||
numReaders := 10 |
|||
var wg sync.WaitGroup |
|||
errors := make(chan error, numReaders) |
|||
bytesRead := make(chan int, numReaders) |
|||
|
|||
for i := 0; i < numReaders; i++ { |
|||
wg.Add(1) |
|||
go func() { |
|||
defer wg.Done() |
|||
buffer := make([]byte, len(testData)) |
|||
n, err := rc.ReadChunkAt(context.Background(), buffer, "shared-chunk", nil, false, 0, len(testData), true) |
|||
if err != nil { |
|||
errors <- err |
|||
} |
|||
bytesRead <- n |
|||
}() |
|||
} |
|||
|
|||
wg.Wait() |
|||
close(errors) |
|||
close(bytesRead) |
|||
|
|||
// Check for errors
|
|||
for err := range errors { |
|||
t.Errorf("Reader got error: %v", err) |
|||
} |
|||
|
|||
// Verify all readers got the expected data
|
|||
for n := range bytesRead { |
|||
if n != len(testData) { |
|||
t.Errorf("Expected %d bytes, got %d", len(testData), n) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TestReaderCachePartialRead tests reading at different offsets
|
|||
func TestReaderCachePartialRead(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
testData := []byte("0123456789ABCDEFGHIJ") |
|||
cache.SetChunk("partial-read-file", testData) |
|||
|
|||
rc := NewReaderCache(10, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
tests := []struct { |
|||
name string |
|||
offset int64 |
|||
size int |
|||
expected []byte |
|||
}{ |
|||
{"read from start", 0, 5, []byte("01234")}, |
|||
{"read from middle", 5, 5, []byte("56789")}, |
|||
{"read to end", 15, 5, []byte("FGHIJ")}, |
|||
{"read single byte", 10, 1, []byte("A")}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
buffer := make([]byte, tt.size) |
|||
n, err := rc.ReadChunkAt(context.Background(), buffer, "partial-read-file", nil, false, tt.offset, len(testData), true) |
|||
|
|||
if err != nil { |
|||
t.Errorf("Expected no error, got: %v", err) |
|||
} |
|||
if n != tt.size { |
|||
t.Errorf("Expected %d bytes, got %d", tt.size, n) |
|||
} |
|||
if string(buffer[:n]) != string(tt.expected) { |
|||
t.Errorf("Expected %q, got %q", tt.expected, buffer[:n]) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestReaderCacheCleanup tests that old downloaders are cleaned up
|
|||
func TestReaderCacheCleanup(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
// Create cache with limit of 3
|
|||
rc := NewReaderCache(3, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
// Add data for multiple files
|
|||
for i := 0; i < 5; i++ { |
|||
fileId := string(rune('A' + i)) |
|||
data := []byte("data for file " + fileId) |
|||
cache.SetChunk(fileId, data) |
|||
} |
|||
|
|||
// Read from multiple files - should trigger cleanup when exceeding limit
|
|||
for i := 0; i < 5; i++ { |
|||
fileId := string(rune('A' + i)) |
|||
buffer := make([]byte, 20) |
|||
_, err := rc.ReadChunkAt(context.Background(), buffer, fileId, nil, false, 0, 20, true) |
|||
if err != nil { |
|||
t.Errorf("Read error for file %s: %v", fileId, err) |
|||
} |
|||
} |
|||
|
|||
// Cache should still work - reads should succeed
|
|||
for i := 0; i < 5; i++ { |
|||
fileId := string(rune('A' + i)) |
|||
buffer := make([]byte, 20) |
|||
n, err := rc.ReadChunkAt(context.Background(), buffer, fileId, nil, false, 0, 20, true) |
|||
if err != nil { |
|||
t.Errorf("Second read error for file %s: %v", fileId, err) |
|||
} |
|||
if n == 0 { |
|||
t.Errorf("Expected data for file %s, got 0 bytes", fileId) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TestSingleChunkCacherDoneSignal tests that done channel is always closed
|
|||
func TestSingleChunkCacherDoneSignal(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
rc := NewReaderCache(10, cache, nil) |
|||
defer rc.destroy() |
|||
|
|||
// Test that we can read even when data is in cache (done channel should work)
|
|||
testData := []byte("done signal test") |
|||
cache.SetChunk("done-signal-test", testData) |
|||
|
|||
// Multiple goroutines reading same chunk
|
|||
var wg sync.WaitGroup |
|||
for i := 0; i < 5; i++ { |
|||
wg.Add(1) |
|||
go func() { |
|||
defer wg.Done() |
|||
buffer := make([]byte, len(testData)) |
|||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
|||
defer cancel() |
|||
|
|||
n, err := rc.ReadChunkAt(ctx, buffer, "done-signal-test", nil, false, 0, len(testData), true) |
|||
if err != nil && err != context.DeadlineExceeded { |
|||
t.Errorf("Unexpected error: %v", err) |
|||
} |
|||
if n == 0 && err == nil { |
|||
t.Error("Got 0 bytes with no error") |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// Should complete without hanging
|
|||
done := make(chan struct{}) |
|||
go func() { |
|||
wg.Wait() |
|||
close(done) |
|||
}() |
|||
|
|||
select { |
|||
case <-done: |
|||
// Success
|
|||
case <-time.After(10 * time.Second): |
|||
t.Fatal("Test timed out - done channel may not be signaled correctly") |
|||
} |
|||
} |
|||
|
|||
// ============================================================================
|
|||
// Tests that exercise SingleChunkCacher concurrency logic
|
|||
// ============================================================================
|
|||
//
|
|||
// These tests use blocking lookupFileIdFn to exercise the wait/cancellation
|
|||
// logic in SingleChunkCacher without requiring HTTP calls.
|
|||
|
|||
// TestSingleChunkCacherLookupError tests handling of lookup errors
|
|||
func TestSingleChunkCacherLookupError(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
|
|||
// Lookup function that returns an error
|
|||
lookupFn := func(ctx context.Context, fileId string) ([]string, error) { |
|||
return nil, fmt.Errorf("lookup failed for %s", fileId) |
|||
} |
|||
|
|||
rc := NewReaderCache(10, cache, lookupFn) |
|||
defer rc.destroy() |
|||
|
|||
buffer := make([]byte, 100) |
|||
_, err := rc.ReadChunkAt(context.Background(), buffer, "error-test", nil, false, 0, 100, true) |
|||
|
|||
if err == nil { |
|||
t.Error("Expected an error, got nil") |
|||
} |
|||
} |
|||
|
|||
// TestSingleChunkCacherContextCancellationDuringLookup tests that a reader can
|
|||
// cancel its wait while the lookup is in progress. This exercises the actual
|
|||
// SingleChunkCacher wait/cancel logic.
|
|||
func TestSingleChunkCacherContextCancellationDuringLookup(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
lookupStarted := make(chan struct{}) |
|||
lookupCanFinish := make(chan struct{}) |
|||
|
|||
// Lookup function that blocks to simulate slow operation
|
|||
lookupFn := func(ctx context.Context, fileId string) ([]string, error) { |
|||
close(lookupStarted) |
|||
<-lookupCanFinish // Block until test allows completion
|
|||
return nil, fmt.Errorf("lookup completed but reader should have cancelled") |
|||
} |
|||
|
|||
rc := NewReaderCache(10, cache, lookupFn) |
|||
defer rc.destroy() |
|||
defer close(lookupCanFinish) // Ensure cleanup
|
|||
|
|||
ctx, cancel := context.WithCancel(context.Background()) |
|||
readResult := make(chan error, 1) |
|||
|
|||
go func() { |
|||
buffer := make([]byte, 100) |
|||
_, err := rc.ReadChunkAt(ctx, buffer, "cancel-during-lookup", nil, false, 0, 100, true) |
|||
readResult <- err |
|||
}() |
|||
|
|||
// Wait for lookup to start, then cancel the reader's context
|
|||
select { |
|||
case <-lookupStarted: |
|||
cancel() // Cancel the reader while lookup is blocked
|
|||
case <-time.After(5 * time.Second): |
|||
t.Fatal("Lookup never started") |
|||
} |
|||
|
|||
// Read should return with context.Canceled
|
|||
select { |
|||
case err := <-readResult: |
|||
if err != context.Canceled { |
|||
t.Errorf("Expected context.Canceled, got: %v", err) |
|||
} |
|||
case <-time.After(5 * time.Second): |
|||
t.Fatal("Read did not complete after context cancellation") |
|||
} |
|||
} |
|||
|
|||
// TestSingleChunkCacherMultipleReadersWaitForDownload tests that multiple readers
|
|||
// can wait for the same SingleChunkCacher download to complete. When lookup fails,
|
|||
// all readers should receive the same error.
|
|||
func TestSingleChunkCacherMultipleReadersWaitForDownload(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
lookupStarted := make(chan struct{}) |
|||
lookupCanFinish := make(chan struct{}) |
|||
var lookupStartedOnce sync.Once |
|||
|
|||
// Lookup function that blocks to simulate slow operation
|
|||
lookupFn := func(ctx context.Context, fileId string) ([]string, error) { |
|||
lookupStartedOnce.Do(func() { close(lookupStarted) }) |
|||
<-lookupCanFinish |
|||
return nil, fmt.Errorf("simulated lookup error") |
|||
} |
|||
|
|||
rc := NewReaderCache(10, cache, lookupFn) |
|||
defer rc.destroy() |
|||
|
|||
numReaders := 5 |
|||
var wg sync.WaitGroup |
|||
errors := make(chan error, numReaders) |
|||
|
|||
// Start multiple readers for the same chunk
|
|||
for i := 0; i < numReaders; i++ { |
|||
wg.Add(1) |
|||
go func() { |
|||
defer wg.Done() |
|||
buffer := make([]byte, 100) |
|||
_, err := rc.ReadChunkAt(context.Background(), buffer, "shared-chunk", nil, false, 0, 100, true) |
|||
errors <- err |
|||
}() |
|||
} |
|||
|
|||
// Wait for lookup to start, then allow completion
|
|||
select { |
|||
case <-lookupStarted: |
|||
close(lookupCanFinish) |
|||
case <-time.After(5 * time.Second): |
|||
close(lookupCanFinish) |
|||
t.Fatal("Lookup never started") |
|||
} |
|||
|
|||
wg.Wait() |
|||
close(errors) |
|||
|
|||
// All readers should receive an error
|
|||
errorCount := 0 |
|||
for err := range errors { |
|||
if err != nil { |
|||
errorCount++ |
|||
} |
|||
} |
|||
if errorCount != numReaders { |
|||
t.Errorf("Expected %d errors, got %d", numReaders, errorCount) |
|||
} |
|||
} |
|||
|
|||
// TestSingleChunkCacherOneReaderCancelsOthersContinue tests that when one reader
|
|||
// cancels, other readers waiting on the same chunk continue to wait.
|
|||
func TestSingleChunkCacherOneReaderCancelsOthersContinue(t *testing.T) { |
|||
cache := newMockChunkCacheForReaderCache() |
|||
lookupStarted := make(chan struct{}) |
|||
lookupCanFinish := make(chan struct{}) |
|||
var lookupStartedOnce sync.Once |
|||
|
|||
lookupFn := func(ctx context.Context, fileId string) ([]string, error) { |
|||
lookupStartedOnce.Do(func() { close(lookupStarted) }) |
|||
<-lookupCanFinish |
|||
return nil, fmt.Errorf("simulated error after delay") |
|||
} |
|||
|
|||
rc := NewReaderCache(10, cache, lookupFn) |
|||
defer rc.destroy() |
|||
|
|||
cancelledReaderDone := make(chan error, 1) |
|||
otherReaderDone := make(chan error, 1) |
|||
|
|||
ctx, cancel := context.WithCancel(context.Background()) |
|||
|
|||
// Start reader that will be cancelled
|
|||
go func() { |
|||
buffer := make([]byte, 100) |
|||
_, err := rc.ReadChunkAt(ctx, buffer, "shared-chunk-2", nil, false, 0, 100, true) |
|||
cancelledReaderDone <- err |
|||
}() |
|||
|
|||
// Start reader that will NOT be cancelled
|
|||
go func() { |
|||
buffer := make([]byte, 100) |
|||
_, err := rc.ReadChunkAt(context.Background(), buffer, "shared-chunk-2", nil, false, 0, 100, true) |
|||
otherReaderDone <- err |
|||
}() |
|||
|
|||
// Wait for lookup to start
|
|||
select { |
|||
case <-lookupStarted: |
|||
case <-time.After(5 * time.Second): |
|||
t.Fatal("Lookup never started") |
|||
} |
|||
|
|||
// Cancel the first reader
|
|||
cancel() |
|||
|
|||
// First reader should complete with context.Canceled quickly
|
|||
select { |
|||
case err := <-cancelledReaderDone: |
|||
if err != context.Canceled { |
|||
t.Errorf("Cancelled reader: expected context.Canceled, got: %v", err) |
|||
} |
|||
case <-time.After(2 * time.Second): |
|||
t.Error("Cancelled reader did not complete quickly") |
|||
} |
|||
|
|||
// Allow the download to complete
|
|||
close(lookupCanFinish) |
|||
|
|||
// Other reader should eventually complete (with error since lookup returns error)
|
|||
select { |
|||
case err := <-otherReaderDone: |
|||
if err == nil || err == context.Canceled { |
|||
t.Errorf("Other reader: expected non-nil non-cancelled error, got: %v", err) |
|||
} |
|||
// Expected: "simulated error after delay"
|
|||
case <-time.After(5 * time.Second): |
|||
t.Error("Other reader did not complete") |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
package sftpd |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/sftpd/user" |
|||
"github.com/stretchr/testify/assert" |
|||
) |
|||
|
|||
func stringPtr(s string) *string { |
|||
return &s |
|||
} |
|||
|
|||
func TestToAbsolutePath(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
homeDir *string // Use pointer to distinguish between unset and empty
|
|||
userPath string |
|||
expected string |
|||
expectError bool |
|||
}{ |
|||
{ |
|||
name: "normal path", |
|||
userPath: "/foo.txt", |
|||
expected: "/sftp/testuser/foo.txt", |
|||
}, |
|||
{ |
|||
name: "root path", |
|||
userPath: "/", |
|||
expected: "/sftp/testuser", |
|||
}, |
|||
{ |
|||
name: "path with dot", |
|||
userPath: "/./foo.txt", |
|||
expected: "/sftp/testuser/foo.txt", |
|||
}, |
|||
{ |
|||
name: "path traversal attempts", |
|||
userPath: "/../foo.txt", |
|||
expectError: true, |
|||
}, |
|||
{ |
|||
name: "path traversal attempts 2", |
|||
userPath: "../../foo.txt", |
|||
expectError: true, |
|||
}, |
|||
{ |
|||
name: "path traversal attempts 3", |
|||
userPath: "/subdir/../../foo.txt", |
|||
expectError: true, |
|||
}, |
|||
{ |
|||
name: "empty path", |
|||
userPath: "", |
|||
expected: "/sftp/testuser", |
|||
}, |
|||
{ |
|||
name: "multiple slashes", |
|||
userPath: "//foo.txt", |
|||
expected: "/sftp/testuser/foo.txt", |
|||
}, |
|||
{ |
|||
name: "trailing slash", |
|||
userPath: "/foo/", |
|||
expected: "/sftp/testuser/foo", |
|||
}, |
|||
{ |
|||
name: "empty HomeDir passthrough", |
|||
homeDir: stringPtr(""), |
|||
userPath: "/foo.txt", |
|||
expected: "/foo.txt", |
|||
}, |
|||
{ |
|||
name: "root HomeDir passthrough", |
|||
homeDir: stringPtr("/"), |
|||
userPath: "/foo.txt", |
|||
expected: "/foo.txt", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
homeDir := "/sftp/testuser" // default
|
|||
if tt.homeDir != nil { |
|||
homeDir = *tt.homeDir |
|||
} |
|||
|
|||
fs := &SftpServer{ |
|||
user: &user.User{ |
|||
HomeDir: homeDir, |
|||
}, |
|||
} |
|||
|
|||
got, err := fs.toAbsolutePath(tt.userPath) |
|||
if tt.expectError { |
|||
assert.Error(t, err) |
|||
} else { |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, tt.expected, got) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue