mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 14:45:51 +00:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805950b401 | ||
|
|
e28629bb5f | ||
|
|
cadb2eeb05 | ||
|
|
982aae6d53 | ||
|
|
f5c0bcafa3 | ||
|
|
a9b3be416b | ||
|
|
5d53edb93b | ||
|
|
dcc200fec0 | ||
|
|
5167bbd2a9 | ||
|
|
55f0fbf364 | ||
|
|
62a83ed469 | ||
|
|
9c266fac29 | ||
|
|
5dd2d44858 | ||
|
|
28ac536280 | ||
|
|
89b6deaefa | ||
|
|
f1384108e8 | ||
|
|
c0dad091f1 | ||
|
|
4cc6a2a4e5 | ||
|
|
5c1de633cb | ||
|
|
3183a49698 | ||
|
|
f9b4a4c396 | ||
|
|
fdb888729b | ||
|
|
716f21fbd3 | ||
|
|
a5ab05ec03 | ||
|
|
8d110b29dd | ||
|
|
39ba19eea6 | ||
|
|
268cc84e8c | ||
|
|
e361daa754 | ||
|
|
d59cc1b09f | ||
|
|
3bcadc9f90 | ||
|
|
e9da64f62a | ||
|
|
5ed0b00fb9 | ||
|
|
51841a2e04 | ||
|
|
4f038820dc | ||
|
|
ebb06a3908 | ||
|
|
ee775825bc | ||
|
|
733ca8e6df | ||
|
|
099a351f3b | ||
|
|
1a67e6118e | ||
|
|
208d008fe3 | ||
|
|
778b92e436 | ||
|
|
ec41795594 | ||
|
|
5d50baad5a | ||
|
|
60487e75f3 | ||
|
|
2e1be41e75 | ||
|
|
1eafaecd70 | ||
|
|
310be2aece | ||
|
|
32d3a4c467 | ||
|
|
a33e5a9e6a | ||
|
|
bfd45e8260 | ||
|
|
8c585a9682 | ||
|
|
61c0514a1c | ||
|
|
5602f98c47 | ||
|
|
8a6fcc1df9 | ||
|
|
f6f3859826 | ||
|
|
caca3bf427 | ||
|
|
ab222709e3 | ||
|
|
36dd59560b | ||
|
|
36acd6e3b6 | ||
|
|
5a85c4c2ac | ||
|
|
b878e9ae46 | ||
|
|
39d4a0b495 | ||
|
|
1f9967e84f | ||
|
|
9483f38abc | ||
|
|
e8f0b57e51 |
@@ -26,7 +26,7 @@ jobs:
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Free Disk Space
|
||||
run: |
|
||||
echo "Available disk space before cleanup:"
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
- name: Build ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Free Disk Space
|
||||
run: |
|
||||
echo "Available disk space before cleanup:"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Free Disk Space
|
||||
run: |
|
||||
echo "Available disk space before cleanup:"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build ${{ matrix.platform }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Free Disk Space
|
||||
run: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Free Disk Space
|
||||
run: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Free Disk Space
|
||||
run: |
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Free Disk Space
|
||||
run: |
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build and push ${{ matrix.variant }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
@@ -223,3 +223,4 @@ jobs:
|
||||
|
||||
echo "✓ Successfully copied ${{ matrix.variant }} to Docker Hub"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: "EC Integration Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ec-integration-tests:
|
||||
name: EC Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ^1.24
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build weed binary
|
||||
run: |
|
||||
cd weed && go build -o weed .
|
||||
|
||||
- name: Run EC Integration Tests
|
||||
working-directory: test/erasure_coding
|
||||
run: |
|
||||
go test -v
|
||||
|
||||
- name: Archive logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ec-integration-test-logs
|
||||
path: test/erasure_coding
|
||||
@@ -44,6 +44,80 @@ jobs:
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --all --validate-maintainers=false --chart-dirs k8s/charts
|
||||
|
||||
- name: Verify template rendering
|
||||
run: |
|
||||
set -e
|
||||
CHART_DIR="k8s/charts/seaweedfs"
|
||||
|
||||
echo "=== Testing default configuration ==="
|
||||
helm template test $CHART_DIR > /tmp/default.yaml
|
||||
echo "✓ Default configuration renders successfully"
|
||||
|
||||
echo "=== Testing with S3 enabled ==="
|
||||
helm template test $CHART_DIR --set s3.enabled=true > /tmp/s3.yaml
|
||||
grep -q "kind: Deployment" /tmp/s3.yaml && grep -q "seaweedfs-s3" /tmp/s3.yaml
|
||||
echo "✓ S3 deployment renders correctly"
|
||||
|
||||
echo "=== Testing with all-in-one mode ==="
|
||||
helm template test $CHART_DIR --set allInOne.enabled=true > /tmp/allinone.yaml
|
||||
grep -q "seaweedfs-all-in-one" /tmp/allinone.yaml
|
||||
echo "✓ All-in-one deployment renders correctly"
|
||||
|
||||
echo "=== Testing with security enabled ==="
|
||||
helm template test $CHART_DIR --set global.enableSecurity=true > /tmp/security.yaml
|
||||
grep -q "security-config" /tmp/security.yaml
|
||||
echo "✓ Security configuration renders correctly"
|
||||
|
||||
echo "=== Testing with monitoring enabled ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set global.monitoring.enabled=true \
|
||||
--set global.monitoring.gatewayHost=prometheus \
|
||||
--set global.monitoring.gatewayPort=9091 > /tmp/monitoring.yaml
|
||||
echo "✓ Monitoring configuration renders correctly"
|
||||
|
||||
echo "=== Testing with PVC storage ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set master.data.type=persistentVolumeClaim \
|
||||
--set master.data.size=10Gi \
|
||||
--set master.data.storageClass=standard > /tmp/pvc.yaml
|
||||
grep -q "PersistentVolumeClaim" /tmp/pvc.yaml
|
||||
echo "✓ PVC configuration renders correctly"
|
||||
|
||||
echo "=== Testing with custom replicas ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set master.replicas=3 \
|
||||
--set filer.replicas=2 \
|
||||
--set volume.replicas=3 > /tmp/replicas.yaml
|
||||
echo "✓ Custom replicas configuration renders correctly"
|
||||
|
||||
echo "=== Testing filer with S3 gateway ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set filer.s3.enabled=true \
|
||||
--set filer.s3.enableAuth=true > /tmp/filer-s3.yaml
|
||||
echo "✓ Filer S3 gateway renders correctly"
|
||||
|
||||
echo "=== Testing SFTP enabled ==="
|
||||
helm template test $CHART_DIR --set sftp.enabled=true > /tmp/sftp.yaml
|
||||
grep -q "seaweedfs-sftp" /tmp/sftp.yaml
|
||||
echo "✓ SFTP deployment renders correctly"
|
||||
|
||||
echo "=== Testing ingress configurations ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set master.ingress.enabled=true \
|
||||
--set filer.ingress.enabled=true \
|
||||
--set s3.enabled=true \
|
||||
--set s3.ingress.enabled=true > /tmp/ingress.yaml
|
||||
grep -q "kind: Ingress" /tmp/ingress.yaml
|
||||
echo "✓ Ingress configurations render correctly"
|
||||
|
||||
echo "=== Testing COSI driver ==="
|
||||
helm template test $CHART_DIR --set cosi.enabled=true > /tmp/cosi.yaml
|
||||
grep -q "seaweedfs-cosi" /tmp/cosi.yaml
|
||||
echo "✓ COSI driver renders correctly"
|
||||
|
||||
echo ""
|
||||
echo "✅ All template rendering tests passed!"
|
||||
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@v1.13.0
|
||||
|
||||
|
||||
@@ -411,4 +411,58 @@ jobs:
|
||||
path: test/s3/versioning/weed-test*.log
|
||||
retention-days: 7
|
||||
|
||||
s3-tagging-tests:
|
||||
# CI job for S3 object tagging tests
|
||||
name: S3 Tagging Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run S3 Tagging Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/s3/tagging
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
|
||||
# Set environment variables for the test
|
||||
export S3_ENDPOINT="http://localhost:8006"
|
||||
export S3_ACCESS_KEY="0555b35654ad1656d804"
|
||||
export S3_SECRET_KEY="h7GhxuBLTrlhVUyxSPUKUV8r/2EI4ngqJxD7iBdBYLhwluN30JaT3Q=="
|
||||
|
||||
# Run the specific test that is equivalent to AWS S3 tagging behavior
|
||||
make test-with-server || {
|
||||
echo "❌ Test failed, checking logs..."
|
||||
if [ -f weed-test.log ]; then
|
||||
echo "=== Server logs ==="
|
||||
tail -100 weed-test.log
|
||||
fi
|
||||
echo "=== Process information ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: s3-tagging-test-logs
|
||||
path: test/s3/tagging/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
# Removed SSE-C integration tests and compatibility job
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
-master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 -volume.preStopSeconds=1 \
|
||||
-master.port=9333 -volume.port=8080 -filer.port=8888 -s3.port=8000 -metricsPort=9324 \
|
||||
-s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
-s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
pid=$!
|
||||
|
||||
# Wait for all SeaweedFS components to be ready
|
||||
@@ -368,7 +368,7 @@ jobs:
|
||||
-master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 -volume.preStopSeconds=1 \
|
||||
-master.port=9334 -volume.port=8081 -filer.port=8889 -s3.port=8001 -metricsPort=9325 \
|
||||
-s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
-s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
pid=$!
|
||||
|
||||
# Wait for all SeaweedFS components to be ready
|
||||
@@ -526,7 +526,7 @@ jobs:
|
||||
-master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 -volume.preStopSeconds=1 \
|
||||
-master.port=9335 -volume.port=8082 -filer.port=8890 -s3.port=8002 -metricsPort=9326 \
|
||||
-s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
-s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
pid=$!
|
||||
|
||||
# Wait for all SeaweedFS components to be ready
|
||||
@@ -636,7 +636,7 @@ jobs:
|
||||
-master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 -volume.preStopSeconds=1 \
|
||||
-master.port=9336 -volume.port=8083 -filer.port=8891 -s3.port=8003 -metricsPort=9327 \
|
||||
-s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
-s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" -master.peers=none &
|
||||
pid=$!
|
||||
|
||||
# Wait for all SeaweedFS components to be ready
|
||||
@@ -817,7 +817,7 @@ jobs:
|
||||
-master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 -volume.preStopSeconds=1 \
|
||||
-master.port=9337 -volume.port=8085 -filer.port=8892 -s3.port=8004 -metricsPort=9328 \
|
||||
-s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" \
|
||||
-s3.allowDeleteBucketNotEmpty=true -s3.config="$GITHUB_WORKSPACE/docker/compose/s3.json" \
|
||||
-master.peers=none \
|
||||
> /tmp/seaweedfs-sql-server.log 2>&1 &
|
||||
pid=$!
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: spark-test-results
|
||||
path: test/java/spark/target/surefire-reports/
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
|
||||
- name: Publish test report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@v1
|
||||
uses: dorny/test-reporter@v2
|
||||
with:
|
||||
name: Spark Test Results
|
||||
path: test/java/spark/target/surefire-reports/*.xml
|
||||
|
||||
@@ -18,12 +18,12 @@ full_install: admin-generate
|
||||
cd weed; go install -tags "elastic gocdk sqlite ydb tarantool tikv rclone"
|
||||
|
||||
server: install
|
||||
weed -v 0 server -s3 -filer -filer.maxMB=64 -volume.max=0 -master.volumeSizeLimitMB=100 -volume.preStopSeconds=1 -s3.port=8000 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=./docker/compose/s3.json -metricsPort=9324
|
||||
weed -v 0 server -s3 -filer -filer.maxMB=64 -volume.max=0 -master.volumeSizeLimitMB=100 -volume.preStopSeconds=1 -s3.port=8000 -s3.allowDeleteBucketNotEmpty=true -s3.config=./docker/compose/s3.json -metricsPort=9324
|
||||
|
||||
benchmark: install warp_install
|
||||
pkill weed || true
|
||||
pkill warp || true
|
||||
weed server -debug=$(debug) -s3 -filer -volume.max=0 -master.volumeSizeLimitMB=100 -volume.preStopSeconds=1 -s3.port=8000 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false -s3.config=./docker/compose/s3.json &
|
||||
weed server -debug=$(debug) -s3 -filer -volume.max=0 -master.volumeSizeLimitMB=100 -volume.preStopSeconds=1 -s3.port=8000 -s3.allowDeleteBucketNotEmpty=false -s3.config=./docker/compose/s3.json &
|
||||
warp client &
|
||||
while ! nc -z localhost 8000 ; do sleep 1 ; done
|
||||
warp mixed --host=127.0.0.1:8000 --access-key=some_access_key1 --secret-key=some_secret_key1 --autoterm
|
||||
|
||||
@@ -592,65 +592,22 @@ Percentage of the requests served within a certain time (ms)
|
||||
|
||||
```
|
||||
make benchmark
|
||||
warp: Benchmark data written to "warp-mixed-2023-10-16[102354]-l70a.csv.zst"
|
||||
warp: Benchmark data written to "warp-mixed-2025-12-05[194844]-kBpU.csv.zst"
|
||||
|
||||
Mixed operations.
|
||||
Operation: DELETE, 10%, Concurrency: 20, Ran 4m59s.
|
||||
* Throughput: 6.19 obj/s
|
||||
Operation: DELETE, 10%, Concurrency: 20, Ran 42s.
|
||||
* Throughput: 55.13 obj/s
|
||||
|
||||
Operation: GET, 45%, Concurrency: 20, Ran 5m0s.
|
||||
* Throughput: 279.85 MiB/s, 27.99 obj/s
|
||||
Operation: GET, 45%, Concurrency: 20, Ran 42s.
|
||||
* Throughput: 2477.45 MiB/s, 247.75 obj/s
|
||||
|
||||
Operation: PUT, 15%, Concurrency: 20, Ran 5m0s.
|
||||
* Throughput: 89.86 MiB/s, 8.99 obj/s
|
||||
Operation: PUT, 15%, Concurrency: 20, Ran 42s.
|
||||
* Throughput: 825.85 MiB/s, 82.59 obj/s
|
||||
|
||||
Operation: STAT, 30%, Concurrency: 20, Ran 5m0s.
|
||||
* Throughput: 18.63 obj/s
|
||||
Operation: STAT, 30%, Concurrency: 20, Ran 42s.
|
||||
* Throughput: 165.27 obj/s
|
||||
|
||||
Cluster Total: 369.74 MiB/s, 61.79 obj/s, 0 errors over 5m0s.
|
||||
```
|
||||
|
||||
To see segmented request statistics, use the --analyze.v parameter.
|
||||
```
|
||||
warp analyze --analyze.v warp-mixed-2023-10-16[102354]-l70a.csv.zst
|
||||
18642 operations loaded... Done!
|
||||
Mixed operations.
|
||||
----------------------------------------
|
||||
Operation: DELETE - total: 1854, 10.0%, Concurrency: 20, Ran 5m0s, starting 2023-10-16 10:23:57.115 +0500 +05
|
||||
* Throughput: 6.19 obj/s
|
||||
|
||||
Requests considered: 1855:
|
||||
* Avg: 104ms, 50%: 30ms, 90%: 207ms, 99%: 1.355s, Fastest: 1ms, Slowest: 4.613s, StdDev: 320ms
|
||||
|
||||
----------------------------------------
|
||||
Operation: GET - total: 8388, 45.3%, Size: 10485760 bytes. Concurrency: 20, Ran 5m0s, starting 2023-10-16 10:23:57.12 +0500 +05
|
||||
* Throughput: 279.77 MiB/s, 27.98 obj/s
|
||||
|
||||
Requests considered: 8389:
|
||||
* Avg: 221ms, 50%: 106ms, 90%: 492ms, 99%: 1.739s, Fastest: 8ms, Slowest: 8.633s, StdDev: 383ms
|
||||
* TTFB: Avg: 81ms, Best: 2ms, 25th: 24ms, Median: 39ms, 75th: 65ms, 90th: 171ms, 99th: 669ms, Worst: 4.783s StdDev: 163ms
|
||||
* First Access: Avg: 240ms, 50%: 105ms, 90%: 511ms, 99%: 2.08s, Fastest: 12ms, Slowest: 8.633s, StdDev: 480ms
|
||||
* First Access TTFB: Avg: 88ms, Best: 2ms, 25th: 24ms, Median: 38ms, 75th: 64ms, 90th: 179ms, 99th: 919ms, Worst: 4.783s StdDev: 199ms
|
||||
* Last Access: Avg: 219ms, 50%: 106ms, 90%: 463ms, 99%: 1.782s, Fastest: 9ms, Slowest: 8.633s, StdDev: 416ms
|
||||
* Last Access TTFB: Avg: 81ms, Best: 2ms, 25th: 24ms, Median: 39ms, 75th: 65ms, 90th: 161ms, 99th: 657ms, Worst: 4.783s StdDev: 176ms
|
||||
|
||||
----------------------------------------
|
||||
Operation: PUT - total: 2688, 14.5%, Size: 10485760 bytes. Concurrency: 20, Ran 5m0s, starting 2023-10-16 10:23:57.115 +0500 +05
|
||||
* Throughput: 89.83 MiB/s, 8.98 obj/s
|
||||
|
||||
Requests considered: 2689:
|
||||
* Avg: 1.165s, 50%: 878ms, 90%: 2.015s, 99%: 5.74s, Fastest: 99ms, Slowest: 8.264s, StdDev: 968ms
|
||||
|
||||
----------------------------------------
|
||||
Operation: STAT - total: 5586, 30.2%, Concurrency: 20, Ran 5m0s, starting 2023-10-16 10:23:57.113 +0500 +05
|
||||
* Throughput: 18.63 obj/s
|
||||
|
||||
Requests considered: 5587:
|
||||
* Avg: 15ms, 50%: 11ms, 90%: 34ms, 99%: 80ms, Fastest: 0s, Slowest: 245ms, StdDev: 17ms
|
||||
* First Access: Avg: 14ms, 50%: 10ms, 90%: 33ms, 99%: 69ms, Fastest: 0s, Slowest: 203ms, StdDev: 16ms
|
||||
* Last Access: Avg: 15ms, 50%: 11ms, 90%: 34ms, 99%: 74ms, Fastest: 0s, Slowest: 203ms, StdDev: 17ms
|
||||
|
||||
Cluster Total: 369.64 MiB/s, 61.77 obj/s, 0 errors over 5m0s.
|
||||
Total Errors:0.
|
||||
Cluster Total: 3302.88 MiB/s, 550.51 obj/s over 43s.
|
||||
```
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
@@ -23,6 +23,9 @@ RUN mkdir -p /etc/seaweedfs
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/filer.toml /etc/seaweedfs/filer.toml
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# FIPS 140-3 mode is ON by default (Go 1.24+)
|
||||
# To disable: docker run -e GODEBUG=fips140=off ...
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk add --no-cache fuse su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
- 8888:8888
|
||||
- 18888:18888
|
||||
- 8000:8000
|
||||
command: 'filer -master="master:9333" -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false'
|
||||
command: 'filer -master="master:9333" -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowDeleteBucketNotEmpty=false'
|
||||
volumes:
|
||||
- ./s3.json:/etc/seaweedfs/s3.json
|
||||
depends_on:
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
|
||||
s3:
|
||||
image: chrislusf/seaweedfs:local
|
||||
command: "server -ip=127.0.0.1 -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false"
|
||||
command: "server -ip=127.0.0.1 -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowDeleteBucketNotEmpty=false"
|
||||
volumes:
|
||||
- ./s3.json:/etc/seaweedfs/s3.json
|
||||
environment:
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- 8888:8888
|
||||
- 8000:8000
|
||||
- 18888:18888
|
||||
command: "server -ip=s3 -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false"
|
||||
command: "server -ip=s3 -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8000 -s3.allowDeleteBucketNotEmpty=false"
|
||||
volumes:
|
||||
- ./s3.json:/etc/seaweedfs/s3.json
|
||||
environment:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Enable FIPS 140-3 mode by default (Go 1.24+)
|
||||
# To disable: docker run -e GODEBUG=fips140=off ...
|
||||
export GODEBUG="${GODEBUG:+$GODEBUG,}fips140=on"
|
||||
|
||||
# Fix permissions for mounted volumes
|
||||
# If /data is mounted from host, it might have different ownership
|
||||
# Fix this by ensuring seaweed user owns the directory
|
||||
|
||||
@@ -50,7 +50,7 @@ require (
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/karlseguin/ccache/v2 v2.0.8
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/klauspost/compress v1.18.2
|
||||
github.com/klauspost/reedsolomon v1.12.5
|
||||
github.com/kurin/blazer v0.5.3
|
||||
github.com/linxGnu/grocksdb v1.10.3
|
||||
@@ -67,7 +67,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.19.1
|
||||
github.com/prometheus/procfs v0.19.2
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/seaweedfs/goexif v1.0.3
|
||||
@@ -124,7 +124,7 @@ require (
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1
|
||||
github.com/a-h/templ v0.3.943
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20240515141816-262c6fe778ad
|
||||
github.com/arangodb/go-driver v1.6.7
|
||||
github.com/arangodb/go-driver v1.6.9
|
||||
github.com/armon/go-metrics v0.4.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.3
|
||||
@@ -154,12 +154,12 @@ require (
|
||||
github.com/rdleal/intervalst v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.14.1
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.10
|
||||
github.com/shirou/gopsutil/v4 v4.25.11
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.1
|
||||
github.com/tikv/client-go/v2 v2.0.7
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.118.2
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.121.0
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.6
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/sync v0.18.0
|
||||
@@ -298,7 +298,7 @@ require (
|
||||
github.com/d4l3k/messagediff v1.2.1 // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/elastic/gosigar v0.14.3 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
|
||||
@@ -415,8 +415,8 @@ require (
|
||||
github.com/tiancaiamao/gp v0.0.0-20221230034425-4025bc8a4d4a // indirect
|
||||
github.com/tikv/pd/client v0.0.0-20230329114254-1948c247c2b1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/twmb/murmur3 v1.1.3 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
@@ -425,7 +425,7 @@ require (
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20250911135631-b3beddd517d9 // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb // indirect
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
|
||||
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
|
||||
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
|
||||
|
||||
@@ -158,8 +158,6 @@ cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZ
|
||||
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
|
||||
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY=
|
||||
@@ -578,8 +576,6 @@ github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3
|
||||
github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.218 h1:tIvcbHXNY/bq+Sno6vajOJOxhe5XbU59Fa1ohOybK+s=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.218/go.mod h1:E0BaGQbcMUcql+AfubCR/iasWKBxX5UZPivnQGC2z0M=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
@@ -659,8 +655,8 @@ github.com/apple/foundationdb/bindings/go v0.0.0-20240515141816-262c6fe778ad h1:
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20240515141816-262c6fe778ad/go.mod h1:OMVSB21p9+xQUIqlGizHPZfjK+SHws1ht+ZytVDoz9U=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
|
||||
github.com/arangodb/go-driver v1.6.7 h1:9FBUsH60cKu7DjFGozTsaqWMy+3UeEplplqUn4yEcg4=
|
||||
github.com/arangodb/go-driver v1.6.7/go.mod h1:H6uhiKUD/ki7fS9dNDK6xzMX/D5ibj5kGN1bGKd37Ho=
|
||||
github.com/arangodb/go-driver v1.6.9 h1:zckB+xuA16NmHUuYOX7INCJTIyIkoBQjAGqNpiyf2HQ=
|
||||
github.com/arangodb/go-driver v1.6.9/go.mod h1:eAM/drVZw39hTGFdkxvbVv0uJsDGFaUpqQHVZMSoALc=
|
||||
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e h1:Xg+hGrY2LcQBbxd0ZFdbGSyRKTYMZCfBbw/pMJFOk1g=
|
||||
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e/go.mod h1:mq7Shfa/CaixoDxiyAAc5jZ6CVBAyPaNQCGS7mkj4Ho=
|
||||
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||
@@ -800,8 +796,6 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/cockroachdb/apd/v3 v3.1.0 h1:MK3Ow7LH0W8zkd5GMKA1PvS9qG3bWFI95WaVNfyZJ/w=
|
||||
@@ -865,8 +859,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
|
||||
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
||||
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
@@ -884,11 +878,8 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34=
|
||||
github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
@@ -957,8 +948,6 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -1335,8 +1324,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -1570,8 +1559,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.19.1 h1:QVtROpTkphuXuNlnCv3m1ut3JytkXHtQ3xvck/YmzMM=
|
||||
github.com/prometheus/procfs v0.19.1/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
@@ -1635,8 +1624,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
@@ -1673,8 +1662,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -1731,10 +1718,10 @@ github.com/tikv/pd/client v0.0.0-20230329114254-1948c247c2b1/go.mod h1:3cTcfo8GR
|
||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tsuna/gohbase v0.0.0-20201125011725-348991136365 h1:6iRwZdrFUzbcVYZwa8dXTIILGIxmmhjyUPJEcwzPGaU=
|
||||
github.com/tsuna/gohbase v0.0.0-20201125011725-348991136365/go.mod h1:zj0GJHGvyf1ed3Jm/Tb4830c/ZKDq+YoLsCt2rGQuT0=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
@@ -1788,14 +1775,14 @@ github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e h1:9LPdmD
|
||||
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20230528143953-42c825ace222/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20250911135631-b3beddd517d9 h1:SKqSRP6/ocY2Z4twOqKEKxpmawVTHTvQiom7hrU6jt0=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20250911135631-b3beddd517d9/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb h1:LZ6dhVfWzhicf/P5Xh7fA0Jd7rfGduxmB2QZpD+Lz9Q=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0 h1:/NyPd9KnCJgzrEXCArqk1ThqCH2Dh31uUwl88o/VkuM=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0/go.mod h1:9YzkhlIymWaJGX6KMU3vh5sOf3UKbCXkG/ZdjaI3zNM=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.44.0/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.3/go.mod h1:bWnOIcUHd7+Sl7DN+yhyY1H/I61z53GczvwJgXMgvj0=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.118.2 h1:+R4RDTDZ+k/tFRIflg2ynQ7t6bZBg9t5vKQU3PvahQg=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.118.2/go.mod h1:UEMMk+JMunUveo2j+zlJEJ5I7ntf2+MbimciVNJYnNs=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.121.0 h1:Ml4WMsaRY+/+Wh3DCp1A+/zjM1aMdQg3irjuYFwWVHQ=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.121.0/go.mod h1:/LjMxb/rXmoGAAnImoqAFIlhO5ampHacbvDetQitCk4=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1 h1:qw3Fa+T81+Kpu5Io2vYHJOwcrYrVjgJlT6t/0dOXJrA=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1/go.mod h1:t/ZA4ECdgPWjAb4jyDe8AzQZB5dhpGbi3iCahFaNwBY=
|
||||
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 h1:9E5q8Nsy2RiJMZDNVy0A3KUrIMBPakJ2VgloeWbcI84=
|
||||
@@ -1845,20 +1832,14 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
|
||||
@@ -1869,20 +1850,12 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iL
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 h1:s0n95ya5tOG03exJ5JySOdJFtwGo4ZQ+KeY7Zro4CLI=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.36.0/go.mod h1:m9wRxtKA2MZ1HcnNC4BKI+9aYe434qRZTCvI7QGUN7Y=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
@@ -2113,8 +2086,6 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri
|
||||
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -2586,12 +2557,8 @@ google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q
|
||||
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
@@ -2634,14 +2601,11 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
|
||||
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
|
||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
||||
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI=
|
||||
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
|
||||
google.golang.org/grpc/security/advancedtls v1.0.0 h1:/KQ7VP/1bs53/aopk9QhuPyFAp9Dm9Ejix3lzYkCrDA=
|
||||
google.golang.org/grpc/security/advancedtls v1.0.0/go.mod h1:o+s4go+e1PJ2AjuQMY5hU82W7lDlefjJA6FqEHRVHWk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -2660,8 +2624,6 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
description: SeaweedFS
|
||||
name: seaweedfs
|
||||
appVersion: "4.01"
|
||||
appVersion: "4.02"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.0.401
|
||||
version: 4.0.402
|
||||
|
||||
@@ -15,9 +15,9 @@ metadata:
|
||||
{{- toYaml .Values.allInOne.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: {{ .Values.allInOne.replicas | default 1 }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
type: {{ .Values.allInOne.updateStrategy.type | default "Recreate" }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
@@ -130,12 +130,23 @@ spec:
|
||||
value: {{ include "seaweedfs.cluster.masterAddress" . | quote }}
|
||||
- name: {{ $clusterFilerKey }}
|
||||
value: {{ include "seaweedfs.cluster.filerAddress" . | quote }}
|
||||
{{- if .Values.allInOne.secretExtraEnvironmentVars }}
|
||||
{{- range $key, $value := .Values.allInOne.secretExtraEnvironmentVars }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
{{ toYaml $value | nindent 16 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-ec"
|
||||
- |
|
||||
/usr/bin/weed \
|
||||
{{- if .Values.allInOne.loggingOverrideLevel }}
|
||||
-v={{ .Values.allInOne.loggingOverrideLevel }} \
|
||||
{{- else }}
|
||||
-v={{ .Values.global.loggingLevel }} \
|
||||
{{- end }}
|
||||
server \
|
||||
-dir=/data \
|
||||
-master \
|
||||
@@ -191,6 +202,9 @@ spec:
|
||||
{{- else if .Values.master.metricsPort }}
|
||||
-metricsPort={{ .Values.master.metricsPort }} \
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.metricsIp }}
|
||||
-metricsIp={{ .Values.allInOne.metricsIp }} \
|
||||
{{- end }}
|
||||
-filer \
|
||||
-filer.port={{ .Values.filer.port }} \
|
||||
{{- if .Values.filer.disableDirListing }}
|
||||
@@ -219,61 +233,75 @@ spec:
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.s3.enabled }}
|
||||
-s3 \
|
||||
-s3.port={{ .Values.s3.port }} \
|
||||
{{- if .Values.s3.domainName }}
|
||||
-s3.domainName={{ .Values.s3.domainName }} \
|
||||
-s3.port={{ .Values.allInOne.s3.port | default .Values.s3.port }} \
|
||||
{{- $domainName := .Values.allInOne.s3.domainName | default .Values.s3.domainName }}
|
||||
{{- if $domainName }}
|
||||
-s3.domainName={{ $domainName }} \
|
||||
{{- end }}
|
||||
{{- if .Values.global.enableSecurity }}
|
||||
{{- if .Values.s3.httpsPort }}
|
||||
-s3.port.https={{ .Values.s3.httpsPort }} \
|
||||
{{- $httpsPort := .Values.allInOne.s3.httpsPort | default .Values.s3.httpsPort }}
|
||||
{{- if $httpsPort }}
|
||||
-s3.port.https={{ $httpsPort }} \
|
||||
{{- end }}
|
||||
-s3.cert.file=/usr/local/share/ca-certificates/client/tls.crt \
|
||||
-s3.key.file=/usr/local/share/ca-certificates/client/tls.key \
|
||||
{{- end }}
|
||||
{{- if eq (typeOf .Values.s3.allowEmptyFolder) "bool" }}
|
||||
-s3.allowEmptyFolder={{ .Values.s3.allowEmptyFolder }} \
|
||||
{{- end }}
|
||||
{{- if .Values.s3.enableAuth }}
|
||||
{{- if or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/s3/seaweedfs_s3_config \
|
||||
{{- end }}
|
||||
{{- if .Values.s3.auditLogConfig }}
|
||||
{{- $auditLogConfig := .Values.allInOne.s3.auditLogConfig | default .Values.s3.auditLogConfig }}
|
||||
{{- if $auditLogConfig }}
|
||||
-s3.auditLogConfig=/etc/sw/s3/s3_auditLogConfig.json \
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.sftp.enabled }}
|
||||
-sftp \
|
||||
-sftp.port={{ .Values.sftp.port }} \
|
||||
{{- if .Values.sftp.sshPrivateKey }}
|
||||
-sftp.sshPrivateKey={{ .Values.sftp.sshPrivateKey }} \
|
||||
-sftp.port={{ .Values.allInOne.sftp.port | default .Values.sftp.port }} \
|
||||
{{- $sshPrivateKey := .Values.allInOne.sftp.sshPrivateKey | default .Values.sftp.sshPrivateKey }}
|
||||
{{- if $sshPrivateKey }}
|
||||
-sftp.sshPrivateKey={{ $sshPrivateKey }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.hostKeysFolder }}
|
||||
-sftp.hostKeysFolder={{ .Values.sftp.hostKeysFolder }} \
|
||||
{{- $hostKeysFolder := .Values.allInOne.sftp.hostKeysFolder | default .Values.sftp.hostKeysFolder }}
|
||||
{{- if $hostKeysFolder }}
|
||||
-sftp.hostKeysFolder={{ $hostKeysFolder }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.authMethods }}
|
||||
-sftp.authMethods={{ .Values.sftp.authMethods }} \
|
||||
{{- $authMethods := .Values.allInOne.sftp.authMethods | default .Values.sftp.authMethods }}
|
||||
{{- if $authMethods }}
|
||||
-sftp.authMethods={{ $authMethods }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.maxAuthTries }}
|
||||
-sftp.maxAuthTries={{ .Values.sftp.maxAuthTries }} \
|
||||
{{- $maxAuthTries := .Values.allInOne.sftp.maxAuthTries | default .Values.sftp.maxAuthTries }}
|
||||
{{- if $maxAuthTries }}
|
||||
-sftp.maxAuthTries={{ $maxAuthTries }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.bannerMessage }}
|
||||
-sftp.bannerMessage="{{ .Values.sftp.bannerMessage }}" \
|
||||
{{- $bannerMessage := .Values.allInOne.sftp.bannerMessage | default .Values.sftp.bannerMessage }}
|
||||
{{- if $bannerMessage }}
|
||||
-sftp.bannerMessage="{{ $bannerMessage }}" \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.loginGraceTime }}
|
||||
-sftp.loginGraceTime={{ .Values.sftp.loginGraceTime }} \
|
||||
{{- $loginGraceTime := .Values.allInOne.sftp.loginGraceTime | default .Values.sftp.loginGraceTime }}
|
||||
{{- if $loginGraceTime }}
|
||||
-sftp.loginGraceTime={{ $loginGraceTime }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.clientAliveInterval }}
|
||||
-sftp.clientAliveInterval={{ .Values.sftp.clientAliveInterval }} \
|
||||
{{- $clientAliveInterval := .Values.allInOne.sftp.clientAliveInterval | default .Values.sftp.clientAliveInterval }}
|
||||
{{- if $clientAliveInterval }}
|
||||
-sftp.clientAliveInterval={{ $clientAliveInterval }} \
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.clientAliveCountMax }}
|
||||
-sftp.clientAliveCountMax={{ .Values.sftp.clientAliveCountMax }} \
|
||||
{{- $clientAliveCountMax := .Values.allInOne.sftp.clientAliveCountMax | default .Values.sftp.clientAliveCountMax }}
|
||||
{{- if $clientAliveCountMax }}
|
||||
-sftp.clientAliveCountMax={{ $clientAliveCountMax }} \
|
||||
{{- end }}
|
||||
{{- if or .Values.allInOne.sftp.enableAuth .Values.sftp.enableAuth }}
|
||||
-sftp.userStoreFile=/etc/sw/sftp/seaweedfs_sftp_config \
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- $extraArgsCount := len .Values.allInOne.extraArgs }}
|
||||
{{- range $i, $arg := .Values.allInOne.extraArgs }}
|
||||
{{ $arg | quote }}{{ if ne (add1 $i) $extraArgsCount }} \{{ end }}
|
||||
{{- end }}
|
||||
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- if and .Values.allInOne.s3.enabled (or .Values.s3.enableAuth .Values.filer.s3.enableAuth) }}
|
||||
{{- if and .Values.allInOne.s3.enabled (or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth) }}
|
||||
- name: config-s3-users
|
||||
mountPath: /etc/sw/s3
|
||||
readOnly: true
|
||||
@@ -282,10 +310,12 @@ spec:
|
||||
- name: config-ssh
|
||||
mountPath: /etc/sw/ssh
|
||||
readOnly: true
|
||||
{{- if or .Values.allInOne.sftp.enableAuth .Values.sftp.enableAuth }}
|
||||
- mountPath: /etc/sw/sftp
|
||||
name: config-users
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.notificationConfig }}
|
||||
- name: notification-config
|
||||
mountPath: /etc/seaweedfs/notification.toml
|
||||
@@ -332,15 +362,16 @@ spec:
|
||||
- containerPort: {{ .Values.filer.grpcPort }}
|
||||
name: swfs-fil-grpc
|
||||
{{- if .Values.allInOne.s3.enabled }}
|
||||
- containerPort: {{ .Values.s3.port }}
|
||||
- containerPort: {{ .Values.allInOne.s3.port | default .Values.s3.port }}
|
||||
name: swfs-s3
|
||||
{{- if .Values.s3.httpsPort }}
|
||||
- containerPort: {{ .Values.s3.httpsPort }}
|
||||
{{- $httpsPort := .Values.allInOne.s3.httpsPort | default .Values.s3.httpsPort }}
|
||||
{{- if $httpsPort }}
|
||||
- containerPort: {{ $httpsPort }}
|
||||
name: swfs-s3-tls
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.sftp.enabled }}
|
||||
- containerPort: {{ .Values.sftp.port }}
|
||||
- containerPort: {{ .Values.allInOne.sftp.port | default .Values.sftp.port }}
|
||||
name: swfs-sftp
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.metricsPort }}
|
||||
@@ -352,7 +383,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.allInOne.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.master.port }}
|
||||
scheme: {{ .Values.allInOne.readinessProbe.scheme }}
|
||||
scheme: {{ .Values.allInOne.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.allInOne.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.allInOne.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.allInOne.readinessProbe.successThreshold }}
|
||||
@@ -364,7 +395,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.allInOne.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.master.port }}
|
||||
scheme: {{ .Values.allInOne.livenessProbe.scheme }}
|
||||
scheme: {{ .Values.allInOne.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.allInOne.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.allInOne.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.allInOne.livenessProbe.successThreshold }}
|
||||
@@ -389,26 +420,31 @@ spec:
|
||||
path: {{ .Values.allInOne.data.hostPathPrefix }}/seaweedfs-all-in-one-data/
|
||||
type: DirectoryOrCreate
|
||||
{{- else if eq .Values.allInOne.data.type "persistentVolumeClaim" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "seaweedfs.name" . }}-all-in-one-data
|
||||
{{- else if eq .Values.allInOne.data.type "existingClaim" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.allInOne.data.claimName }}
|
||||
{{- else if eq .Values.allInOne.data.type "emptyDir" }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if and .Values.allInOne.s3.enabled (or .Values.s3.enableAuth .Values.filer.s3.enableAuth) }}
|
||||
{{- if and .Values.allInOne.s3.enabled (or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth) }}
|
||||
- name: config-s3-users
|
||||
secret:
|
||||
defaultMode: 420
|
||||
secretName: {{ default (printf "%s-s3-secret" (include "seaweedfs.name" .)) (or .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret) }}
|
||||
secretName: {{ default (printf "%s-s3-secret" (include "seaweedfs.name" .)) (or .Values.allInOne.s3.existingConfigSecret .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret) }}
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.sftp.enabled }}
|
||||
- name: config-ssh
|
||||
secret:
|
||||
defaultMode: 420
|
||||
secretName: {{ default (printf "%s-sftp-ssh-secret" (include "seaweedfs.name" .)) .Values.sftp.existingSshConfigSecret }}
|
||||
secretName: {{ default (printf "%s-sftp-ssh-secret" (include "seaweedfs.name" .)) (or .Values.allInOne.sftp.existingSshConfigSecret .Values.sftp.existingSshConfigSecret) }}
|
||||
{{- if or .Values.allInOne.sftp.enableAuth .Values.sftp.enableAuth }}
|
||||
- name: config-users
|
||||
secret:
|
||||
defaultMode: 420
|
||||
secretName: {{ default (printf "%s-sftp-secret" (include "seaweedfs.name" .)) .Values.sftp.existingConfigSecret }}
|
||||
secretName: {{ default (printf "%s-sftp-secret" (include "seaweedfs.name" .)) (or .Values.allInOne.sftp.existingConfigSecret .Values.sftp.existingConfigSecret) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.notificationConfig }}
|
||||
- name: notification-config
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
{{- toYaml .Values.allInOne.service.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.allInOne.service.type | default "ClusterIP" }}
|
||||
internalTrafficPolicy: {{ .Values.allInOne.service.internalTrafficPolicy | default "Cluster" }}
|
||||
ports:
|
||||
# Master ports
|
||||
@@ -50,13 +51,14 @@ spec:
|
||||
# S3 ports (if enabled)
|
||||
{{- if .Values.allInOne.s3.enabled }}
|
||||
- name: "swfs-s3"
|
||||
port: {{ if .Values.allInOne.s3.enabled }}{{ .Values.s3.port }}{{ else }}{{ .Values.filer.s3.port }}{{ end }}
|
||||
targetPort: {{ if .Values.allInOne.s3.enabled }}{{ .Values.s3.port }}{{ else }}{{ .Values.filer.s3.port }}{{ end }}
|
||||
port: {{ .Values.allInOne.s3.port | default .Values.s3.port }}
|
||||
targetPort: {{ .Values.allInOne.s3.port | default .Values.s3.port }}
|
||||
protocol: TCP
|
||||
{{- if and .Values.allInOne.s3.enabled .Values.s3.httpsPort }}
|
||||
{{- $httpsPort := .Values.allInOne.s3.httpsPort | default .Values.s3.httpsPort }}
|
||||
{{- if $httpsPort }}
|
||||
- name: "swfs-s3-tls"
|
||||
port: {{ .Values.s3.httpsPort }}
|
||||
targetPort: {{ .Values.s3.httpsPort }}
|
||||
port: {{ $httpsPort }}
|
||||
targetPort: {{ $httpsPort }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -64,8 +66,8 @@ spec:
|
||||
# SFTP ports (if enabled)
|
||||
{{- if .Values.allInOne.sftp.enabled }}
|
||||
- name: "swfs-sftp"
|
||||
port: {{ .Values.sftp.port }}
|
||||
targetPort: {{ .Values.sftp.port }}
|
||||
port: {{ .Values.allInOne.sftp.port | default .Values.sftp.port }}
|
||||
targetPort: {{ .Values.allInOne.sftp.port | default .Values.sftp.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
|
||||
@@ -80,4 +82,4 @@ spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/component: seaweedfs-all-in-one
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{{- if .Values.filer.enabled }}
|
||||
{{- if .Values.filer.ingress.enabled }}
|
||||
{{- /* Filer ingress works for both normal mode (filer.enabled) and all-in-one mode (allInOne.enabled) */}}
|
||||
{{- $filerEnabled := or .Values.filer.enabled .Values.allInOne.enabled }}
|
||||
{{- if and $filerEnabled .Values.filer.ingress.enabled }}
|
||||
{{- /* Determine service name based on deployment mode */}}
|
||||
{{- $serviceName := ternary (printf "%s-all-in-one" (include "seaweedfs.name" .)) (printf "%s-filer" (include "seaweedfs.name" .)) .Values.allInOne.enabled }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
@@ -33,16 +36,14 @@ spec:
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ template "seaweedfs.name" . }}-filer
|
||||
name: {{ $serviceName }}
|
||||
port:
|
||||
number: {{ .Values.filer.port }}
|
||||
#name:
|
||||
{{- else }}
|
||||
serviceName: {{ template "seaweedfs.name" . }}-filer
|
||||
serviceName: {{ $serviceName }}
|
||||
servicePort: {{ .Values.filer.port }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.ingress.host }}
|
||||
host: {{ .Values.filer.ingress.host }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -213,9 +213,6 @@ spec:
|
||||
-s3.cert.file=/usr/local/share/ca-certificates/client/tls.crt \
|
||||
-s3.key.file=/usr/local/share/ca-certificates/client/tls.key \
|
||||
{{- end }}
|
||||
{{- if eq (typeOf .Values.filer.s3.allowEmptyFolder) "bool" }}
|
||||
-s3.allowEmptyFolder={{ .Values.filer.s3.allowEmptyFolder }} \
|
||||
{{- end }}
|
||||
{{- if .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/seaweedfs_s3_config \
|
||||
{{- end }}
|
||||
@@ -289,7 +286,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.filer.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.filer.port }}
|
||||
scheme: {{ .Values.filer.readinessProbe.scheme }}
|
||||
scheme: {{ .Values.filer.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.filer.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.filer.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.filer.readinessProbe.successThreshold }}
|
||||
@@ -301,7 +298,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.filer.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.filer.port }}
|
||||
scheme: {{ .Values.filer.livenessProbe.scheme }}
|
||||
scheme: {{ .Values.filer.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.filer.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.filer.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.filer.livenessProbe.successThreshold }}
|
||||
|
||||
@@ -235,7 +235,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.master.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.master.port }}
|
||||
scheme: {{ .Values.master.readinessProbe.scheme }}
|
||||
scheme: {{ .Values.master.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.master.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.master.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.master.readinessProbe.successThreshold }}
|
||||
@@ -247,7 +247,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.master.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.master.port }}
|
||||
scheme: {{ .Values.master.livenessProbe.scheme }}
|
||||
scheme: {{ .Values.master.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.master.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.master.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.master.livenessProbe.successThreshold }}
|
||||
|
||||
@@ -143,9 +143,6 @@ spec:
|
||||
{{- if .Values.s3.domainName }}
|
||||
-domainName={{ .Values.s3.domainName }} \
|
||||
{{- end }}
|
||||
{{- if eq (typeOf .Values.s3.allowEmptyFolder) "bool" }}
|
||||
-allowEmptyFolder={{ .Values.s3.allowEmptyFolder }} \
|
||||
{{- end }}
|
||||
{{- if .Values.s3.enableAuth }}
|
||||
-config=/etc/sw/seaweedfs_s3_config \
|
||||
{{- end }}
|
||||
@@ -204,7 +201,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.s3.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.s3.port }}
|
||||
scheme: {{ .Values.s3.readinessProbe.scheme }}
|
||||
scheme: {{ .Values.s3.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.s3.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.s3.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.s3.readinessProbe.successThreshold }}
|
||||
@@ -216,7 +213,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ .Values.s3.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.s3.port }}
|
||||
scheme: {{ .Values.s3.livenessProbe.scheme }}
|
||||
scheme: {{ .Values.s3.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.s3.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.s3.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.s3.livenessProbe.successThreshold }}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{{- if .Values.s3.ingress.enabled }}
|
||||
{{- /* S3 ingress works for standalone S3 gateway (s3.enabled), S3 on Filer (filer.s3.enabled), and all-in-one mode (allInOne.s3.enabled) */}}
|
||||
{{- $s3Enabled := or .Values.s3.enabled (and .Values.filer.s3.enabled (not .Values.allInOne.enabled)) (and .Values.allInOne.enabled .Values.allInOne.s3.enabled) }}
|
||||
{{- if and $s3Enabled .Values.s3.ingress.enabled }}
|
||||
{{- /* Determine service name based on deployment mode */}}
|
||||
{{- $serviceName := ternary (printf "%s-all-in-one" (include "seaweedfs.name" .)) (printf "%s-s3" (include "seaweedfs.name" .)) .Values.allInOne.enabled }}
|
||||
{{- $s3Port := .Values.allInOne.s3.port | default .Values.s3.port }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
@@ -32,13 +37,12 @@ spec:
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ template "seaweedfs.name" . }}-s3
|
||||
name: {{ $serviceName }}
|
||||
port:
|
||||
number: {{ .Values.s3.port }}
|
||||
#name:
|
||||
number: {{ $s3Port }}
|
||||
{{- else }}
|
||||
serviceName: {{ template "seaweedfs.name" . }}-s3
|
||||
servicePort: {{ .Values.s3.port }}
|
||||
serviceName: {{ $serviceName }}
|
||||
servicePort: {{ $s3Port }}
|
||||
{{- end }}
|
||||
{{- if .Values.s3.ingress.host }}
|
||||
host: {{ .Values.s3.ingress.host | quote }}
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
{{- if .Values.master.enabled }}
|
||||
{{- if .Values.filer.s3.enabled }}
|
||||
{{- if .Values.filer.s3.createBuckets }}
|
||||
{{- /* Support bucket creation for both standalone filer.s3 and allInOne modes */}}
|
||||
{{- $createBuckets := list }}
|
||||
{{- $s3Enabled := false }}
|
||||
{{- $enableAuth := false }}
|
||||
{{- $existingConfigSecret := "" }}
|
||||
|
||||
{{- /* Check allInOne mode first */}}
|
||||
{{- if .Values.allInOne.enabled }}
|
||||
{{- if .Values.allInOne.s3.enabled }}
|
||||
{{- $s3Enabled = true }}
|
||||
{{- if .Values.allInOne.s3.createBuckets }}
|
||||
{{- $createBuckets = .Values.allInOne.s3.createBuckets }}
|
||||
{{- end }}
|
||||
{{- $enableAuth = or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth }}
|
||||
{{- $existingConfigSecret = or .Values.allInOne.s3.existingConfigSecret .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret }}
|
||||
{{- end }}
|
||||
{{- else if .Values.master.enabled }}
|
||||
{{- /* Check standalone filer.s3 mode */}}
|
||||
{{- if .Values.filer.s3.enabled }}
|
||||
{{- $s3Enabled = true }}
|
||||
{{- if .Values.filer.s3.createBuckets }}
|
||||
{{- $createBuckets = .Values.filer.s3.createBuckets }}
|
||||
{{- end }}
|
||||
{{- $enableAuth = .Values.filer.s3.enableAuth }}
|
||||
{{- $existingConfigSecret = .Values.filer.s3.existingConfigSecret }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and $s3Enabled $createBuckets }}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
@@ -32,9 +58,9 @@ spec:
|
||||
- name: WEED_CLUSTER_DEFAULT
|
||||
value: "sw"
|
||||
- name: WEED_CLUSTER_SW_MASTER
|
||||
value: "{{ template "seaweedfs.name" . }}-master.{{ .Release.Namespace }}:{{ .Values.master.port }}"
|
||||
value: {{ include "seaweedfs.cluster.masterAddress" . | quote }}
|
||||
- name: WEED_CLUSTER_SW_FILER
|
||||
value: "{{ template "seaweedfs.name" . }}-filer-client.{{ .Release.Namespace }}:{{ .Values.filer.port }}"
|
||||
value: {{ include "seaweedfs.cluster.filerAddress" . | quote }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
@@ -71,24 +97,29 @@ spec:
|
||||
echo "Service at $url failed to become ready within 5 minutes"
|
||||
exit 1
|
||||
}
|
||||
{{- if .Values.allInOne.enabled }}
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_MASTER{{ .Values.allInOne.readinessProbe.httpGet.path }}"
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_FILER{{ .Values.filer.readinessProbe.httpGet.path }}"
|
||||
{{- else }}
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_MASTER{{ .Values.master.readinessProbe.httpGet.path }}"
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_FILER{{ .Values.filer.readinessProbe.httpGet.path }}"
|
||||
{{- range $reg, $props := $.Values.filer.s3.createBuckets }}
|
||||
exec /bin/echo \
|
||||
"s3.bucket.create --name {{ $props.name }}" |\
|
||||
{{- end }}
|
||||
{{- range $createBuckets }}
|
||||
/bin/echo \
|
||||
"s3.bucket.create --name {{ .name }}" |\
|
||||
/usr/bin/weed shell
|
||||
{{- end }}
|
||||
{{- range $reg, $props := $.Values.filer.s3.createBuckets }}
|
||||
{{- if $props.anonymousRead }}
|
||||
exec /bin/echo \
|
||||
{{- range $createBuckets }}
|
||||
{{- if .anonymousRead }}
|
||||
/bin/echo \
|
||||
"s3.configure --user anonymous \
|
||||
--buckets {{ $props.name }} \
|
||||
--buckets {{ .name }} \
|
||||
--actions Read \
|
||||
--apply true" |\
|
||||
/usr/bin/weed shell
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.s3.enableAuth }}
|
||||
{{- if $enableAuth }}
|
||||
volumeMounts:
|
||||
- name: config-users
|
||||
mountPath: /etc/sw
|
||||
@@ -106,17 +137,15 @@ spec:
|
||||
{{- if .Values.filer.containerSecurityContext.enabled }}
|
||||
securityContext: {{- omit .Values.filer.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.s3.enableAuth }}
|
||||
{{- if $enableAuth }}
|
||||
volumes:
|
||||
- name: config-users
|
||||
secret:
|
||||
defaultMode: 420
|
||||
{{- if not (empty .Values.filer.s3.existingConfigSecret) }}
|
||||
secretName: {{ .Values.filer.s3.existingConfigSecret }}
|
||||
{{- if $existingConfigSecret }}
|
||||
secretName: {{ $existingConfigSecret }}
|
||||
{{- else }}
|
||||
secretName: seaweedfs-s3-secret
|
||||
secretName: {{ template "seaweedfs.name" . }}-s3-secret
|
||||
{{- end }}
|
||||
{{- end }}{{/** if .Values.filer.s3.enableAuth **/}}
|
||||
{{- end }}{{/** if .Values.master.enabled **/}}
|
||||
{{- end }}{{/** if .Values.filer.s3.enabled **/}}
|
||||
{{- end }}{{/** if .Values.filer.s3.createBuckets **/}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -251,7 +251,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ $volume.readinessProbe.httpGet.path }}
|
||||
port: {{ $volume.port }}
|
||||
scheme: {{ $volume.readinessProbe.scheme }}
|
||||
scheme: {{ $volume.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ $volume.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ $volume.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ $volume.readinessProbe.successThreshold }}
|
||||
@@ -263,7 +263,7 @@ spec:
|
||||
httpGet:
|
||||
path: {{ $volume.livenessProbe.httpGet.path }}
|
||||
port: {{ $volume.port }}
|
||||
scheme: {{ $volume.livenessProbe.scheme }}
|
||||
scheme: {{ $volume.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ $volume.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ $volume.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ $volume.livenessProbe.successThreshold }}
|
||||
|
||||
@@ -310,7 +310,7 @@ volume:
|
||||
# limit file size to avoid out of memory, default 256mb
|
||||
fileSizeLimitMB: null
|
||||
# minimum free disk space(in percents). If free disk space lower this value - all volumes marks as ReadOnly
|
||||
minFreeSpacePercent: 7
|
||||
minFreeSpacePercent: 1
|
||||
|
||||
# Custom command line arguments to add to the volume command
|
||||
# Example to fix IPv6 metrics connectivity issues:
|
||||
@@ -856,8 +856,6 @@ filer:
|
||||
port: 8333
|
||||
# add additional https port
|
||||
httpsPort: 0
|
||||
# allow empty folders
|
||||
allowEmptyFolder: false
|
||||
# Suffix of the host name, {bucket}.{domainName}
|
||||
domainName: ""
|
||||
# enable user & permission to s3 (need to inject to all services)
|
||||
@@ -885,8 +883,6 @@ s3:
|
||||
httpsPort: 0
|
||||
metricsPort: 9327
|
||||
loggingOverrideLevel: null
|
||||
# allow empty folders
|
||||
allowEmptyFolder: true
|
||||
# enable user & permission to s3 (need to inject to all services)
|
||||
enableAuth: false
|
||||
# set to the name of an existing kubernetes Secret with the s3 json config file
|
||||
@@ -979,9 +975,9 @@ s3:
|
||||
extraEnvironmentVars:
|
||||
|
||||
# Custom command line arguments to add to the s3 command
|
||||
# Example to fix connection idle seconds:
|
||||
extraArgs: ["-idleTimeout=30"]
|
||||
# extraArgs: []
|
||||
# Default idleTimeout is 120 seconds. Example to customize:
|
||||
# extraArgs: ["-idleTimeout=300"]
|
||||
extraArgs: []
|
||||
|
||||
# used to configure livenessProbe on s3 containers
|
||||
#
|
||||
@@ -1097,6 +1093,7 @@ allInOne:
|
||||
enabled: false
|
||||
imageOverride: null
|
||||
restartPolicy: Always
|
||||
replicas: 1 # Number of replicas (note: multiple replicas may require shared storage)
|
||||
|
||||
# Core configuration
|
||||
idleTimeout: 30 # Connection idle seconds
|
||||
@@ -1108,24 +1105,85 @@ allInOne:
|
||||
metricsIp: "" # Metrics listen IP. If empty, defaults to bindAddress
|
||||
loggingOverrideLevel: null # Override logging level
|
||||
|
||||
# Service configuration
|
||||
# Custom command line arguments to add to the server command
|
||||
# Example to fix IPv6 metrics connectivity issues:
|
||||
# extraArgs: ["-metricsIp", "0.0.0.0"]
|
||||
# Example with multiple args:
|
||||
# extraArgs: ["-customFlag", "value", "-anotherFlag"]
|
||||
extraArgs: []
|
||||
|
||||
# Update strategy configuration
|
||||
# type: Recreate or RollingUpdate
|
||||
# For single replica, Recreate is recommended to avoid data conflicts.
|
||||
# For multiple replicas with RollingUpdate, you MUST use shared storage
|
||||
# (e.g., data.type: persistentVolumeClaim with ReadWriteMany access mode)
|
||||
# to avoid data loss or inconsistency between pods.
|
||||
updateStrategy:
|
||||
type: Recreate
|
||||
|
||||
# S3 gateway configuration
|
||||
# Note: Most parameters below default to null, which means they inherit from
|
||||
# the global s3.* settings. Set explicit values here to override for allInOne only.
|
||||
s3:
|
||||
enabled: false # Whether to enable S3 gateway
|
||||
port: null # S3 gateway port (null inherits from s3.port)
|
||||
httpsPort: null # S3 gateway HTTPS port (null inherits from s3.httpsPort)
|
||||
domainName: null # Suffix of the host name (null inherits from s3.domainName)
|
||||
enableAuth: false # Enable user & permission to S3
|
||||
# Set to the name of an existing kubernetes Secret with the s3 json config file
|
||||
# should have a secret key called seaweedfs_s3_config with an inline json config
|
||||
existingConfigSecret: null
|
||||
auditLogConfig: null # S3 audit log configuration (null inherits from s3.auditLogConfig)
|
||||
# You may specify buckets to be created during the install process.
|
||||
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
|
||||
# createBuckets:
|
||||
# - name: bucket-a
|
||||
# anonymousRead: true
|
||||
# - name: bucket-b
|
||||
# anonymousRead: false
|
||||
|
||||
# SFTP server configuration
|
||||
# Note: Most parameters below default to null, which means they inherit from
|
||||
# the global sftp.* settings. Set explicit values here to override for allInOne only.
|
||||
sftp:
|
||||
enabled: false # Whether to enable SFTP server
|
||||
port: null # SFTP port (null inherits from sftp.port)
|
||||
sshPrivateKey: null # Path to SSH private key (null inherits from sftp.sshPrivateKey)
|
||||
hostKeysFolder: null # Path to SSH host keys folder (null inherits from sftp.hostKeysFolder)
|
||||
authMethods: null # Comma-separated auth methods (null inherits from sftp.authMethods)
|
||||
maxAuthTries: null # Maximum authentication attempts (null inherits from sftp.maxAuthTries)
|
||||
bannerMessage: null # Banner message (null inherits from sftp.bannerMessage)
|
||||
loginGraceTime: null # Login grace time (null inherits from sftp.loginGraceTime)
|
||||
clientAliveInterval: null # Client keep-alive interval (null inherits from sftp.clientAliveInterval)
|
||||
clientAliveCountMax: null # Maximum missed keep-alive messages (null inherits from sftp.clientAliveCountMax)
|
||||
enableAuth: false # Enable SFTP authentication
|
||||
# Set to the name of an existing kubernetes Secret with the sftp json config file
|
||||
existingConfigSecret: null
|
||||
# Set to the name of an existing kubernetes Secret with the SSH keys
|
||||
existingSshConfigSecret: null
|
||||
|
||||
# Service settings
|
||||
service:
|
||||
annotations: {} # Annotations for the service
|
||||
type: ClusterIP # Service type (ClusterIP, NodePort, LoadBalancer)
|
||||
internalTrafficPolicy: Cluster # Internal traffic policy
|
||||
|
||||
# Note: For ingress in all-in-one mode, use the standard s3.ingress and
|
||||
# filer.ingress settings. The templates automatically detect all-in-one mode
|
||||
# and point to the correct service (seaweedfs-all-in-one instead of
|
||||
# seaweedfs-s3 or seaweedfs-filer).
|
||||
|
||||
# Storage configuration
|
||||
data:
|
||||
type: "emptyDir" # Options: "hostPath", "persistentVolumeClaim", "emptyDir"
|
||||
type: "emptyDir" # Options: "hostPath", "persistentVolumeClaim", "emptyDir", "existingClaim"
|
||||
hostPathPrefix: /mnt/data # Path prefix for hostPath volumes
|
||||
claimName: seaweedfs-data-pvc # Name of the PVC to use
|
||||
size: "" # Size of the PVC
|
||||
storageClass: "" # Storage class for the PVC
|
||||
claimName: seaweedfs-data-pvc # Name of the PVC to use (for existingClaim type)
|
||||
size: null # Size of the PVC (null defaults to 10Gi for persistentVolumeClaim type)
|
||||
storageClass: null # Storage class for the PVC (null uses cluster default)
|
||||
# accessModes for the PVC. Default is ["ReadWriteOnce"].
|
||||
# For multi-replica deployments, use ["ReadWriteMany"] with a compatible storage class.
|
||||
accessModes: []
|
||||
annotations: {} # Annotations for the PVC
|
||||
|
||||
# Health checks
|
||||
readinessProbe:
|
||||
@@ -1133,7 +1191,7 @@ allInOne:
|
||||
httpGet:
|
||||
path: /cluster/status
|
||||
port: 9333
|
||||
scheme: HTTP
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
successThreshold: 1
|
||||
@@ -1145,7 +1203,7 @@ allInOne:
|
||||
httpGet:
|
||||
path: /cluster/status
|
||||
port: 9333
|
||||
scheme: HTTP
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
successThreshold: 1
|
||||
@@ -1154,6 +1212,18 @@ allInOne:
|
||||
|
||||
# Additional resources
|
||||
extraEnvironmentVars: {} # Additional environment variables
|
||||
# Secret environment variables (for database credentials, etc.)
|
||||
# Example:
|
||||
# secretExtraEnvironmentVars:
|
||||
# WEED_POSTGRES_USERNAME:
|
||||
# secretKeyRef:
|
||||
# name: postgres-credentials
|
||||
# key: username
|
||||
# WEED_POSTGRES_PASSWORD:
|
||||
# secretKeyRef:
|
||||
# name: postgres-credentials
|
||||
# key: password
|
||||
secretExtraEnvironmentVars: {}
|
||||
extraVolumeMounts: "" # Additional volume mounts
|
||||
extraVolumes: "" # Additional volumes
|
||||
initContainers: "" # Init containers
|
||||
@@ -1173,7 +1243,7 @@ allInOne:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: master
|
||||
app.kubernetes.io/component: seaweedfs-all-in-one
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
# Topology Spread Constraints Settings
|
||||
@@ -1181,16 +1251,16 @@ allInOne:
|
||||
# for a PodSpec. By Default no constraints are set.
|
||||
topologySpreadConstraints: ""
|
||||
|
||||
# Toleration Settings for master pods
|
||||
# Toleration Settings for pods
|
||||
# This should be a multi-line string matching the Toleration array
|
||||
# in a PodSpec.
|
||||
tolerations: ""
|
||||
|
||||
# nodeSelector labels for master pod assignment, formatted as a muli-line string.
|
||||
# nodeSelector labels for pod assignment, formatted as a muli-line string.
|
||||
# ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector
|
||||
nodeSelector: ""
|
||||
|
||||
# Used to assign priority to master pods
|
||||
# Used to assign priority to pods
|
||||
# ref: https://kubernetes.io/docs/concepts/configuration/pod-priority-preemption/
|
||||
priorityClassName: ""
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -139,6 +141,15 @@ func TestECEncodingVolumeLocationTimingBug(t *testing.T) {
|
||||
t.Logf("EC encoding completed successfully")
|
||||
}
|
||||
|
||||
// Add detailed logging for EC encoding command
|
||||
t.Logf("Debug: Executing EC encoding command for volume %d", volumeId)
|
||||
t.Logf("Debug: Command arguments: %v", args)
|
||||
if err != nil {
|
||||
t.Logf("Debug: EC encoding command failed with error: %v", err)
|
||||
} else {
|
||||
t.Logf("Debug: EC encoding command completed successfully")
|
||||
}
|
||||
|
||||
// The key test: check if the fix prevents the timing issue
|
||||
if contains(outputStr, "Collecting volume locations") && contains(outputStr, "before EC encoding") {
|
||||
t.Logf("FIX DETECTED: Volume locations collected BEFORE EC encoding (timing bug prevented)")
|
||||
@@ -526,7 +537,8 @@ func uploadTestData(data []byte, masterAddress string) (needle.VolumeId, error)
|
||||
|
||||
func getVolumeLocations(commandEnv *shell.CommandEnv, volumeId needle.VolumeId) ([]string, error) {
|
||||
// Retry mechanism to handle timing issues with volume registration
|
||||
for i := 0; i < 10; i++ {
|
||||
// Increase retry attempts for volume location retrieval
|
||||
for i := 0; i < 20; i++ { // Increased from 10 to 20 retries
|
||||
locations, ok := commandEnv.MasterClient.GetLocationsClone(uint32(volumeId))
|
||||
if ok {
|
||||
var result []string
|
||||
@@ -646,3 +658,427 @@ func TestECEncodingRegressionPrevention(t *testing.T) {
|
||||
t.Log("Timing pattern regression test passed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDiskAwareECRebalancing tests EC shard placement across multiple disks per server
|
||||
// This verifies the disk-aware EC rebalancing feature works correctly
|
||||
func TestDiskAwareECRebalancing(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping disk-aware integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_disk_aware_ec_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start cluster with MULTIPLE DISKS per volume server
|
||||
cluster, err := startMultiDiskCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Wait for servers to be ready
|
||||
require.NoError(t, waitForServer("127.0.0.1:9334", 30*time.Second))
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, waitForServer(fmt.Sprintf("127.0.0.1:809%d", i), 30*time.Second))
|
||||
}
|
||||
|
||||
// Wait longer for volume servers to register with master and create volumes
|
||||
t.Log("Waiting for volume servers to register with master...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
// Create command environment
|
||||
options := &shell.ShellOptions{
|
||||
Masters: stringPtr("127.0.0.1:9334"),
|
||||
GrpcDialOption: grpc.WithInsecure(),
|
||||
FilerGroup: stringPtr("default"),
|
||||
}
|
||||
commandEnv := shell.NewCommandEnv(options)
|
||||
|
||||
// Connect to master with longer timeout
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel2()
|
||||
go commandEnv.MasterClient.KeepConnectedToMaster(ctx2)
|
||||
commandEnv.MasterClient.WaitUntilConnected(ctx2)
|
||||
|
||||
// Wait for master client to fully sync
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Upload test data to create a volume - retry if volumes not ready
|
||||
var volumeId needle.VolumeId
|
||||
testData := []byte("Disk-aware EC rebalancing test data - this needs to be large enough to create a volume")
|
||||
for retry := 0; retry < 5; retry++ {
|
||||
volumeId, err = uploadTestDataToMaster(testData, "127.0.0.1:9334")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
t.Logf("Upload attempt %d failed: %v, retrying...", retry+1, err)
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
require.NoError(t, err, "Failed to upload test data after retries")
|
||||
t.Logf("Created volume %d for disk-aware EC test", volumeId)
|
||||
|
||||
// Wait for volume to be registered
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
t.Run("verify_multi_disk_setup", func(t *testing.T) {
|
||||
// Verify that each server has multiple disk directories
|
||||
for server := 0; server < 3; server++ {
|
||||
diskCount := 0
|
||||
for disk := 0; disk < 4; disk++ {
|
||||
diskDir := filepath.Join(testDir, fmt.Sprintf("server%d_disk%d", server, disk))
|
||||
if _, err := os.Stat(diskDir); err == nil {
|
||||
diskCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 4, diskCount, "Server %d should have 4 disk directories", server)
|
||||
t.Logf("Server %d has %d disk directories", server, diskCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ec_encode_with_disk_awareness", func(t *testing.T) {
|
||||
// Get lock first
|
||||
lockCmd := shell.Commands[findCommandIndex("lock")]
|
||||
var lockOutput bytes.Buffer
|
||||
err := lockCmd.Do([]string{}, commandEnv, &lockOutput)
|
||||
if err != nil {
|
||||
t.Logf("Lock command failed: %v", err)
|
||||
}
|
||||
|
||||
// Execute EC encoding
|
||||
var output bytes.Buffer
|
||||
ecEncodeCmd := shell.Commands[findCommandIndex("ec.encode")]
|
||||
args := []string{"-volumeId", fmt.Sprintf("%d", volumeId), "-collection", "test", "-force"}
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
os.Stderr = w
|
||||
|
||||
err = ecEncodeCmd.Do(args, commandEnv, &output)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
|
||||
capturedOutput, _ := io.ReadAll(r)
|
||||
outputStr := string(capturedOutput) + output.String()
|
||||
|
||||
t.Logf("EC encode output:\n%s", outputStr)
|
||||
|
||||
if err != nil {
|
||||
t.Logf("EC encoding completed with error: %v", err)
|
||||
} else {
|
||||
t.Logf("EC encoding completed successfully")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_disk_level_shard_distribution", func(t *testing.T) {
|
||||
// Wait for shards to be distributed
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Count shards on each disk of each server
|
||||
diskDistribution := countShardsPerDisk(testDir, uint32(volumeId))
|
||||
|
||||
totalShards := 0
|
||||
disksWithShards := 0
|
||||
maxShardsOnSingleDisk := 0
|
||||
|
||||
t.Logf("Disk-level shard distribution for volume %d:", volumeId)
|
||||
for server, disks := range diskDistribution {
|
||||
for diskId, shardCount := range disks {
|
||||
if shardCount > 0 {
|
||||
t.Logf(" %s disk %d: %d shards", server, diskId, shardCount)
|
||||
totalShards += shardCount
|
||||
disksWithShards++
|
||||
if shardCount > maxShardsOnSingleDisk {
|
||||
maxShardsOnSingleDisk = shardCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Summary: %d total shards across %d disks (max %d on single disk)",
|
||||
totalShards, disksWithShards, maxShardsOnSingleDisk)
|
||||
|
||||
// EC creates 14 shards (10 data + 4 parity), plus .ecx and .ecj files
|
||||
// We should see shards distributed across multiple disks
|
||||
if disksWithShards > 1 {
|
||||
t.Logf("PASS: Shards distributed across %d disks", disksWithShards)
|
||||
} else {
|
||||
t.Logf("INFO: Shards on %d disk(s) - may be expected if volume was on single disk", disksWithShards)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test_ec_balance_disk_awareness", func(t *testing.T) {
|
||||
// Calculate initial disk balance variance
|
||||
initialDistribution := countShardsPerDisk(testDir, uint32(volumeId))
|
||||
initialVariance := calculateDiskShardVariance(initialDistribution)
|
||||
t.Logf("Initial disk shard variance: %.2f", initialVariance)
|
||||
|
||||
// Run ec.balance command
|
||||
var output bytes.Buffer
|
||||
ecBalanceCmd := shell.Commands[findCommandIndex("ec.balance")]
|
||||
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
os.Stderr = w
|
||||
|
||||
err := ecBalanceCmd.Do([]string{"-force"}, commandEnv, &output)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
|
||||
capturedOutput, _ := io.ReadAll(r)
|
||||
outputStr := string(capturedOutput) + output.String()
|
||||
|
||||
if err != nil {
|
||||
t.Logf("ec.balance error: %v", err)
|
||||
}
|
||||
t.Logf("ec.balance output:\n%s", outputStr)
|
||||
|
||||
// Wait for balance to complete
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Calculate final disk balance variance
|
||||
finalDistribution := countShardsPerDisk(testDir, uint32(volumeId))
|
||||
finalVariance := calculateDiskShardVariance(finalDistribution)
|
||||
t.Logf("Final disk shard variance: %.2f", finalVariance)
|
||||
|
||||
t.Logf("Variance change: %.2f -> %.2f", initialVariance, finalVariance)
|
||||
})
|
||||
|
||||
t.Run("verify_no_disk_overload", func(t *testing.T) {
|
||||
// Verify that no single disk has too many shards of the same volume
|
||||
diskDistribution := countShardsPerDisk(testDir, uint32(volumeId))
|
||||
|
||||
for server, disks := range diskDistribution {
|
||||
for diskId, shardCount := range disks {
|
||||
// With 14 EC shards and 12 disks (3 servers x 4 disks), ideally ~1-2 shards per disk
|
||||
// Allow up to 4 shards per disk as a reasonable threshold
|
||||
if shardCount > 4 {
|
||||
t.Logf("WARNING: %s disk %d has %d shards (may indicate imbalance)",
|
||||
server, diskId, shardCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MultiDiskCluster represents a test cluster with multiple disks per volume server
|
||||
type MultiDiskCluster struct {
|
||||
masterCmd *exec.Cmd
|
||||
volumeServers []*exec.Cmd
|
||||
testDir string
|
||||
}
|
||||
|
||||
func (c *MultiDiskCluster) Stop() {
|
||||
// Stop volume servers first
|
||||
for _, cmd := range c.volumeServers {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop master server
|
||||
if c.masterCmd != nil && c.masterCmd.Process != nil {
|
||||
c.masterCmd.Process.Kill()
|
||||
c.masterCmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// startMultiDiskCluster starts a SeaweedFS cluster with multiple disks per volume server
|
||||
func startMultiDiskCluster(ctx context.Context, dataDir string) (*MultiDiskCluster, error) {
|
||||
weedBinary := findWeedBinary()
|
||||
if weedBinary == "" {
|
||||
return nil, fmt.Errorf("weed binary not found")
|
||||
}
|
||||
|
||||
cluster := &MultiDiskCluster{testDir: dataDir}
|
||||
|
||||
// Create master directory
|
||||
masterDir := filepath.Join(dataDir, "master")
|
||||
os.MkdirAll(masterDir, 0755)
|
||||
|
||||
// Start master server on a different port to avoid conflict
|
||||
masterCmd := exec.CommandContext(ctx, weedBinary, "master",
|
||||
"-port", "9334",
|
||||
"-mdir", masterDir,
|
||||
"-volumeSizeLimitMB", "10",
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
|
||||
masterLogFile, err := os.Create(filepath.Join(masterDir, "master.log"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create master log file: %v", err)
|
||||
}
|
||||
masterCmd.Stdout = masterLogFile
|
||||
masterCmd.Stderr = masterLogFile
|
||||
|
||||
if err := masterCmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start master server: %v", err)
|
||||
}
|
||||
cluster.masterCmd = masterCmd
|
||||
|
||||
// Wait for master to be ready
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start 3 volume servers, each with 4 disks
|
||||
const numServers = 3
|
||||
const disksPerServer = 4
|
||||
|
||||
for i := 0; i < numServers; i++ {
|
||||
// Create 4 disk directories per server
|
||||
var diskDirs []string
|
||||
var maxVolumes []string
|
||||
|
||||
for d := 0; d < disksPerServer; d++ {
|
||||
diskDir := filepath.Join(dataDir, fmt.Sprintf("server%d_disk%d", i, d))
|
||||
if err := os.MkdirAll(diskDir, 0755); err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("failed to create disk dir: %v", err)
|
||||
}
|
||||
diskDirs = append(diskDirs, diskDir)
|
||||
maxVolumes = append(maxVolumes, "5")
|
||||
}
|
||||
|
||||
port := fmt.Sprintf("809%d", i)
|
||||
rack := fmt.Sprintf("rack%d", i)
|
||||
|
||||
volumeCmd := exec.CommandContext(ctx, weedBinary, "volume",
|
||||
"-port", port,
|
||||
"-dir", strings.Join(diskDirs, ","),
|
||||
"-max", strings.Join(maxVolumes, ","),
|
||||
"-mserver", "127.0.0.1:9334",
|
||||
"-ip", "127.0.0.1",
|
||||
"-dataCenter", "dc1",
|
||||
"-rack", rack,
|
||||
)
|
||||
|
||||
// Create log file for this volume server
|
||||
logDir := filepath.Join(dataDir, fmt.Sprintf("server%d_logs", i))
|
||||
os.MkdirAll(logDir, 0755)
|
||||
volumeLogFile, err := os.Create(filepath.Join(logDir, "volume.log"))
|
||||
if err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("failed to create volume log file: %v", err)
|
||||
}
|
||||
volumeCmd.Stdout = volumeLogFile
|
||||
volumeCmd.Stderr = volumeLogFile
|
||||
|
||||
if err := volumeCmd.Start(); err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("failed to start volume server %d: %v", i, err)
|
||||
}
|
||||
cluster.volumeServers = append(cluster.volumeServers, volumeCmd)
|
||||
}
|
||||
|
||||
// Wait for volume servers to register with master
|
||||
// Multi-disk servers may take longer to initialize
|
||||
time.Sleep(8 * time.Second)
|
||||
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// uploadTestDataToMaster uploads test data to a specific master address
|
||||
func uploadTestDataToMaster(data []byte, masterAddress string) (needle.VolumeId, error) {
|
||||
assignResult, err := operation.Assign(context.Background(), func(ctx context.Context) pb.ServerAddress {
|
||||
return pb.ServerAddress(masterAddress)
|
||||
}, grpc.WithInsecure(), &operation.VolumeAssignRequest{
|
||||
Count: 1,
|
||||
Collection: "test",
|
||||
Replication: "000",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
uploader, err := operation.NewUploader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
uploadResult, err, _ := uploader.Upload(context.Background(), bytes.NewReader(data), &operation.UploadOption{
|
||||
UploadUrl: "http://" + assignResult.Url + "/" + assignResult.Fid,
|
||||
Filename: "testfile.txt",
|
||||
MimeType: "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if uploadResult.Error != "" {
|
||||
return 0, fmt.Errorf("upload error: %s", uploadResult.Error)
|
||||
}
|
||||
|
||||
fid, err := needle.ParseFileIdFromString(assignResult.Fid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return fid.VolumeId, nil
|
||||
}
|
||||
|
||||
// countShardsPerDisk counts EC shards on each disk of each server
|
||||
// Returns map: "serverN" -> map[diskId]shardCount
|
||||
func countShardsPerDisk(testDir string, volumeId uint32) map[string]map[int]int {
|
||||
result := make(map[string]map[int]int)
|
||||
|
||||
const numServers = 3
|
||||
const disksPerServer = 4
|
||||
|
||||
for server := 0; server < numServers; server++ {
|
||||
serverKey := fmt.Sprintf("server%d", server)
|
||||
result[serverKey] = make(map[int]int)
|
||||
|
||||
for disk := 0; disk < disksPerServer; disk++ {
|
||||
diskDir := filepath.Join(testDir, fmt.Sprintf("server%d_disk%d", server, disk))
|
||||
count, err := countECShardFiles(diskDir, volumeId)
|
||||
if err == nil && count > 0 {
|
||||
result[serverKey][disk] = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// calculateDiskShardVariance measures how evenly shards are distributed across disks
|
||||
// Lower variance means better distribution
|
||||
func calculateDiskShardVariance(distribution map[string]map[int]int) float64 {
|
||||
var counts []float64
|
||||
|
||||
for _, disks := range distribution {
|
||||
for _, count := range disks {
|
||||
if count > 0 {
|
||||
counts = append(counts, float64(count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(counts) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
mean := 0.0
|
||||
for _, c := range counts {
|
||||
mean += c
|
||||
}
|
||||
mean /= float64(len(counts))
|
||||
|
||||
// Calculate variance
|
||||
variance := 0.0
|
||||
for _, c := range counts {
|
||||
variance += (c - mean) * (c - mean)
|
||||
}
|
||||
|
||||
return math.Sqrt(variance / float64(len(counts)))
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ services:
|
||||
- "8888:8888"
|
||||
- "8333:8333"
|
||||
- "18888:18888"
|
||||
command: "server -ip=seaweedfs -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8333 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false"
|
||||
command: "server -ip=seaweedfs -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8333 -s3.allowDeleteBucketNotEmpty=false"
|
||||
volumes:
|
||||
- ./s3.json:/etc/seaweedfs/s3.json
|
||||
- ./filer.toml:/etc/seaweedfs/filer.toml
|
||||
|
||||
@@ -116,7 +116,7 @@ services:
|
||||
- WEED_FOUNDATIONDB_MAX_RETRY_DELAY
|
||||
- WEED_MASTER_VOLUME_GROWTH_COPY_1=1
|
||||
- WEED_MASTER_VOLUME_GROWTH_COPY_OTHER=1
|
||||
command: "weed server -ip=seaweedfs -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8333 -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=false"
|
||||
command: "weed server -ip=seaweedfs -filer -master.volumeSizeLimitMB=16 -volume.max=0 -volume -volume.preStopSeconds=1 -s3 -s3.config=/etc/seaweedfs/s3.json -s3.port=8333 -s3.allowDeleteBucketNotEmpty=false"
|
||||
|
||||
configs:
|
||||
fdb.cluster:
|
||||
|
||||
+10
-10
@@ -20,12 +20,12 @@ require (
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.2 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.218 // indirect
|
||||
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
|
||||
@@ -43,7 +43,7 @@ require (
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go v1.55.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.20 // indirect
|
||||
@@ -61,7 +61,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 // indirect
|
||||
github.com/aws/smithy-go v1.23.1 // indirect
|
||||
github.com/aws/smithy-go v1.23.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
@@ -85,7 +85,7 @@ require (
|
||||
github.com/eapache/go-resiliency v1.7.0 // indirect
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
|
||||
github.com/eapache/queue v1.1.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -139,7 +139,7 @@ require (
|
||||
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||
github.com/karlseguin/ccache/v2 v2.0.8 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.5 // indirect
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
|
||||
@@ -176,7 +176,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.19.1 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
|
||||
github.com/rclone/rclone v1.71.2 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||
@@ -189,7 +189,7 @@ require (
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/samber/lo v1.51.0 // indirect
|
||||
github.com/seaweedfs/goexif v1.0.3 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/smarty/assertions v1.16.0 // indirect
|
||||
@@ -204,8 +204,8 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 // indirect
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 // indirect
|
||||
github.com/unknwon/goconfig v1.0.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
|
||||
+20
-20
@@ -39,8 +39,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
@@ -55,8 +55,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.218 h1:tIvcbHXNY/bq+Sno6vajOJOxhe5XbU59Fa1ohOybK+s=
|
||||
@@ -102,8 +102,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.3 h1:RIb3yr/+PZ18YYNe6MDiG/3jVoJrPmdoCARwNkMGvco=
|
||||
@@ -138,8 +138,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 h1:tBw2Qhf0kj4ZwtsVpDiVRU3z
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4/go.mod h1:Deq4B7sRM6Awq/xyOBlxBdgW8/Z926KYNNaGMW2lrkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 h1:C+BRMnasSYFcgDw8o9H5hzehKzXyAb9GY5v/8bP9DUY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0/go.mod h1:4EjU+4mIx6+JqKQkruye+CaigV7alL3thVPfDd9VlMs=
|
||||
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
|
||||
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
|
||||
@@ -208,8 +208,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
|
||||
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
|
||||
@@ -429,8 +429,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/reedsolomon v1.12.5 h1:4cJuyH926If33BeDgiZpI5OU0pE+wUHZvMSyNGqN73Y=
|
||||
@@ -532,8 +532,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.19.1 h1:QVtROpTkphuXuNlnCv3m1ut3JytkXHtQ3xvck/YmzMM=
|
||||
github.com/prometheus/procfs v0.19.1/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
@@ -569,8 +569,8 @@ github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9
|
||||
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
|
||||
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -624,10 +624,10 @@ github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965/go.mod h1:9OrXJ
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c h1:BLopNCyqewbE8+BtlIp/Juzu8AJGxz0gHdGADnsblVc=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
|
||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 h1:QEePdg0ty2r0t1+qwfZmQ4OOl/MB2UXIeJSpIZv56lg=
|
||||
|
||||
@@ -30,7 +30,6 @@ services:
|
||||
- -s3=true
|
||||
- -s3.port=8333
|
||||
- -webdav=false
|
||||
- -s3.allowEmptyFolder=false
|
||||
- -mq.broker=true
|
||||
- -mq.agent=true
|
||||
- -ip=seaweedfs
|
||||
|
||||
@@ -79,12 +79,11 @@ start-server: check-deps
|
||||
@echo "🔍 DEBUG: Creating volume directory..."
|
||||
@mkdir -p ./test-volume-data
|
||||
@echo "🔍 DEBUG: Launching SeaweedFS server in background..."
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@$(WEED_BINARY) server \
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowEmptyFolder=false \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
|
||||
@@ -81,12 +81,11 @@ start-server: check-deps
|
||||
@echo "🔍 DEBUG: Creating volume directory..."
|
||||
@mkdir -p ./test-volume-data
|
||||
@echo "🔍 DEBUG: Launching SeaweedFS server in background..."
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@$(WEED_BINARY) server \
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowEmptyFolder=false \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
|
||||
@@ -236,7 +236,7 @@ func TestObjectLockHeadersNonVersionedBucket(t *testing.T) {
|
||||
bucketName := getNewBucketName()
|
||||
|
||||
// Create regular bucket without object lock/versioning
|
||||
createBucket(t, client, bucketName)
|
||||
createBucketWithoutObjectLock(t, client, bucketName)
|
||||
defer deleteBucket(t, client, bucketName)
|
||||
|
||||
key := "test-non-versioned"
|
||||
|
||||
@@ -69,8 +69,19 @@ func getNewBucketName() string {
|
||||
return fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, timestamp)
|
||||
}
|
||||
|
||||
// createBucket creates a new bucket for testing
|
||||
// createBucket creates a new bucket for testing with Object Lock enabled
|
||||
// Object Lock is required for retention and legal hold functionality per AWS S3 specification
|
||||
func createBucket(t *testing.T, client *s3.Client, bucketName string) {
|
||||
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
ObjectLockEnabledForBucket: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// createBucketWithoutObjectLock creates a new bucket without Object Lock enabled
|
||||
// Use this only for tests that specifically need to verify non-Object-Lock bucket behavior
|
||||
func createBucketWithoutObjectLock(t *testing.T, client *s3.Client, bucketName string) {
|
||||
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
|
||||
@@ -2082,6 +2082,78 @@ func TestCopyToBucketDefaultEncryptedRegression(t *testing.T) {
|
||||
require.NoError(t, err, "Failed to read object")
|
||||
assertDataEqual(t, testData, data, "Data mismatch")
|
||||
})
|
||||
|
||||
t.Run("LargeFileCopyEncrypted_ToTemp_ToEncrypted", func(t *testing.T) {
|
||||
// Test with large file (1MB) to exercise chunk-by-chunk copy path
|
||||
// This verifies consistent behavior with SSE-C and SSE-KMS
|
||||
largeTestData := generateTestData(1024 * 1024) // 1MB
|
||||
objectKey := "large-file-test.bin"
|
||||
|
||||
// Step 1: Upload large object to source bucket (will be automatically encrypted)
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader(largeTestData),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload large file to source bucket")
|
||||
|
||||
// Verify source object is encrypted
|
||||
srcHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD source object")
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, srcHead.ServerSideEncryption,
|
||||
"Source object should be SSE-S3 encrypted")
|
||||
|
||||
// Step 2: Copy to temp bucket (unencrypted) - exercises chunk-by-chunk decrypt
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(tempBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", srcBucket, objectKey)),
|
||||
})
|
||||
require.NoError(t, err, "Failed to copy large file to temp bucket")
|
||||
|
||||
// Verify temp object is unencrypted and data is correct
|
||||
tempGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(tempBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET temp object")
|
||||
tempData, err := io.ReadAll(tempGet.Body)
|
||||
tempGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read temp object")
|
||||
assertDataEqual(t, largeTestData, tempData, "Temp object data mismatch after decrypt")
|
||||
|
||||
// Step 3: Copy from temp bucket to dest bucket (with default encryption)
|
||||
// This exercises chunk-by-chunk encrypt copy
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(dstBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", tempBucket, objectKey)),
|
||||
})
|
||||
require.NoError(t, err, "Failed to copy large file to destination bucket")
|
||||
|
||||
// Verify destination object is encrypted
|
||||
dstHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(dstBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD destination object")
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, dstHead.ServerSideEncryption,
|
||||
"Destination object should be SSE-S3 encrypted via bucket default")
|
||||
|
||||
// Verify destination object content is correct after re-encryption
|
||||
dstGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(dstBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET destination object")
|
||||
dstData, err := io.ReadAll(dstGet.Body)
|
||||
dstGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read destination object")
|
||||
assertDataEqual(t, largeTestData, dstData, "Large file data mismatch after re-encryption")
|
||||
})
|
||||
}
|
||||
|
||||
// REGRESSION TESTS FOR CRITICAL BUGS FIXED
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# S3 Object Tagging Tests
|
||||
# Tests for GitHub issue #7589: S3 object Tags query comes back empty
|
||||
|
||||
.PHONY: help build-weed setup-server start-server stop-server test-tagging test-tagging-quick test-tagging-comprehensive test-all clean logs check-deps test-with-server
|
||||
|
||||
# Configuration
|
||||
WEED_BINARY := ../../../weed/weed_binary
|
||||
S3_PORT := 8006
|
||||
MASTER_PORT := 9338
|
||||
VOLUME_PORT := 8085
|
||||
FILER_PORT := 8893
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN := TestObjectTaggingOnUpload|TestPutObjectTaggingAPI|TestDeleteObjectTagging|TestTag
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "S3 Object Tagging Tests Makefile"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " help - Show this help message"
|
||||
@echo " build-weed - Build the SeaweedFS binary"
|
||||
@echo " check-deps - Check dependencies and build binary if needed"
|
||||
@echo " start-server - Start SeaweedFS server for testing"
|
||||
@echo " stop-server - Stop SeaweedFS server"
|
||||
@echo " test-tagging - Run all tagging tests"
|
||||
@echo " test-tagging-quick - Run core tagging tests only"
|
||||
@echo " test-tagging-comprehensive - Run comprehensive tagging tests"
|
||||
@echo " test-with-server - Start server, run tests, stop server"
|
||||
@echo " logs - Show server logs"
|
||||
@echo " clean - Clean up test artifacts and stop server"
|
||||
@echo " health-check - Check if server is accessible"
|
||||
@echo ""
|
||||
@echo "Configuration:"
|
||||
@echo " S3_PORT=${S3_PORT}"
|
||||
@echo " TEST_TIMEOUT=${TEST_TIMEOUT}"
|
||||
|
||||
# Build the SeaweedFS binary
|
||||
build-weed:
|
||||
@echo "Building SeaweedFS binary..."
|
||||
@cd ../../../weed && go build -o weed_binary .
|
||||
@chmod +x $(WEED_BINARY)
|
||||
@echo "✅ SeaweedFS binary built at $(WEED_BINARY)"
|
||||
|
||||
check-deps: build-weed
|
||||
@echo "Checking dependencies..."
|
||||
@echo "🔍 DEBUG: Checking Go installation..."
|
||||
@command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1)
|
||||
@echo "🔍 DEBUG: Go version: $$(go version)"
|
||||
@echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)..."
|
||||
@test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1)
|
||||
@echo "🔍 DEBUG: Binary size: $$(ls -lh $(WEED_BINARY) | awk '{print $$5}')"
|
||||
@echo "🔍 DEBUG: Binary permissions: $$(ls -la $(WEED_BINARY) | awk '{print $$1}')"
|
||||
@echo "🔍 DEBUG: Checking Go module dependencies..."
|
||||
@go list -m github.com/aws/aws-sdk-go-v2 >/dev/null 2>&1 || (echo "AWS SDK Go v2 not found. Run 'go mod tidy'." && exit 1)
|
||||
@go list -m github.com/stretchr/testify >/dev/null 2>&1 || (echo "Testify not found. Run 'go mod tidy'." && exit 1)
|
||||
@echo "✅ All dependencies are available"
|
||||
|
||||
# Start SeaweedFS server for testing
|
||||
start-server: check-deps
|
||||
@echo "Starting SeaweedFS server..."
|
||||
@echo "🔍 DEBUG: Current working directory: $$(pwd)"
|
||||
@echo "🔍 DEBUG: Checking for existing weed processes..."
|
||||
@ps aux | grep weed | grep -v grep || echo "No existing weed processes found"
|
||||
@echo "🔍 DEBUG: Cleaning up any existing PID file..."
|
||||
@rm -f weed-server.pid
|
||||
@echo "🔍 DEBUG: Checking for port conflicts..."
|
||||
@if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \
|
||||
echo "⚠️ Port $(S3_PORT) is already in use, trying to find the process..."; \
|
||||
netstat -tlnp 2>/dev/null | grep $(S3_PORT) || true; \
|
||||
else \
|
||||
echo "✅ Port $(S3_PORT) is available"; \
|
||||
fi
|
||||
@echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)"
|
||||
@ls -la $(WEED_BINARY) || (echo "❌ Binary not found!" && exit 1)
|
||||
@echo "🔍 DEBUG: Checking config file at ../../../docker/compose/s3.json"
|
||||
@ls -la ../../../docker/compose/s3.json || echo "⚠️ Config file not found, continuing without it"
|
||||
@echo "🔍 DEBUG: Creating volume directory..."
|
||||
@mkdir -p ./test-volume-data
|
||||
@echo "🔍 DEBUG: Launching SeaweedFS server in background..."
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -filer -filer.maxMB=64 -s3 -ip.bind 0.0.0.0 -dir=./test-volume-data -master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 -volume.max=100 -volume.preStopSeconds=1 -master.port=$(MASTER_PORT) -volume.port=$(VOLUME_PORT) -filer.port=$(FILER_PORT) -s3.port=$(S3_PORT) -metricsPort=9329 -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -master.peers=none"
|
||||
@$(WEED_BINARY) server \
|
||||
-filer \
|
||||
-filer.maxMB=64 \
|
||||
-s3 \
|
||||
-ip.bind 0.0.0.0 \
|
||||
-dir=./test-volume-data \
|
||||
-master.raftHashicorp \
|
||||
-master.electionTimeout 1s \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-volume.max=100 \
|
||||
-volume.preStopSeconds=1 \
|
||||
-master.port=$(MASTER_PORT) \
|
||||
-volume.port=$(VOLUME_PORT) \
|
||||
-filer.port=$(FILER_PORT) \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-metricsPort=9329 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-master.peers=none \
|
||||
> weed-test.log 2>&1 & echo $$! > weed-server.pid
|
||||
@echo "🔍 DEBUG: Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')"
|
||||
@echo "🔍 DEBUG: Checking if PID is still running..."
|
||||
@sleep 2
|
||||
@if [ -f weed-server.pid ]; then \
|
||||
SERVER_PID=$$(cat weed-server.pid); \
|
||||
ps -p $$SERVER_PID || echo "⚠️ Server PID $$SERVER_PID not found after 2 seconds"; \
|
||||
else \
|
||||
echo "⚠️ PID file not found"; \
|
||||
fi
|
||||
@echo "🔍 DEBUG: Waiting for server to start (up to 90 seconds)..."
|
||||
@for i in $$(seq 1 90); do \
|
||||
echo "🔍 DEBUG: Attempt $$i/90 - checking port $(S3_PORT)"; \
|
||||
if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
|
||||
echo "✅ SeaweedFS server started successfully on port $(S3_PORT) after $$i seconds"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
if [ $$i -eq 5 ]; then \
|
||||
echo "🔍 DEBUG: After 5 seconds, checking process and logs..."; \
|
||||
ps aux | grep weed | grep -v grep || echo "No weed processes found"; \
|
||||
if [ -f weed-test.log ]; then \
|
||||
echo "=== First server logs ==="; \
|
||||
head -20 weed-test.log; \
|
||||
fi; \
|
||||
fi; \
|
||||
if [ $$i -eq 15 ]; then \
|
||||
echo "🔍 DEBUG: After 15 seconds, checking port bindings..."; \
|
||||
netstat -tlnp 2>/dev/null | grep $(S3_PORT) || echo "Port $(S3_PORT) not bound"; \
|
||||
netstat -tlnp 2>/dev/null | grep $(MASTER_PORT) || echo "Port $(MASTER_PORT) not bound"; \
|
||||
netstat -tlnp 2>/dev/null | grep $(VOLUME_PORT) || echo "Port $(VOLUME_PORT) not bound"; \
|
||||
fi; \
|
||||
if [ $$i -eq 30 ]; then \
|
||||
echo "⚠️ Server taking longer than expected (30s), checking logs..."; \
|
||||
if [ -f weed-test.log ]; then \
|
||||
echo "=== Recent server logs ==="; \
|
||||
tail -20 weed-test.log; \
|
||||
fi; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "❌ Server failed to start within 90 seconds"; \
|
||||
echo "🔍 DEBUG: Final process check:"; \
|
||||
ps aux | grep weed | grep -v grep || echo "No weed processes found"; \
|
||||
echo "🔍 DEBUG: Final port check:"; \
|
||||
netstat -tlnp 2>/dev/null | grep -E "($(S3_PORT)|$(MASTER_PORT)|$(VOLUME_PORT))" || echo "No ports bound"; \
|
||||
echo "=== Full server logs ==="; \
|
||||
if [ -f weed-test.log ]; then \
|
||||
cat weed-test.log; \
|
||||
else \
|
||||
echo "No log file found"; \
|
||||
fi; \
|
||||
exit 1
|
||||
|
||||
# Stop SeaweedFS server
|
||||
stop-server:
|
||||
@echo "Stopping SeaweedFS server..."
|
||||
@if [ -f weed-server.pid ]; then \
|
||||
SERVER_PID=$$(cat weed-server.pid); \
|
||||
echo "Killing server PID $$SERVER_PID"; \
|
||||
if ps -p $$SERVER_PID >/dev/null 2>&1; then \
|
||||
kill -TERM $$SERVER_PID 2>/dev/null || true; \
|
||||
sleep 2; \
|
||||
if ps -p $$SERVER_PID >/dev/null 2>&1; then \
|
||||
echo "Process still running, sending KILL signal..."; \
|
||||
kill -KILL $$SERVER_PID 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
fi; \
|
||||
else \
|
||||
echo "Process $$SERVER_PID not found (already stopped)"; \
|
||||
fi; \
|
||||
rm -f weed-server.pid; \
|
||||
else \
|
||||
echo "No PID file found, checking for running processes..."; \
|
||||
echo "⚠️ Skipping automatic process cleanup to avoid CI issues"; \
|
||||
echo "Note: Any remaining weed processes should be cleaned up by the CI environment"; \
|
||||
fi
|
||||
@echo "✅ SeaweedFS server stopped"
|
||||
|
||||
# Show server logs
|
||||
logs:
|
||||
@if test -f weed-test.log; then \
|
||||
echo "=== SeaweedFS Server Logs ==="; \
|
||||
tail -f weed-test.log; \
|
||||
else \
|
||||
echo "No log file found. Server may not be running."; \
|
||||
fi
|
||||
|
||||
# Core tagging tests (basic functionality)
|
||||
test-tagging-quick: check-deps
|
||||
@echo "Running core tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestObjectTaggingOnUpload|TestPutObjectTaggingAPI" .
|
||||
@echo "✅ Core tagging tests completed"
|
||||
|
||||
# All tagging tests (comprehensive)
|
||||
test-tagging: check-deps
|
||||
@echo "Running all tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" .
|
||||
@echo "✅ All tagging tests completed"
|
||||
|
||||
# Comprehensive tagging tests (all features)
|
||||
test-tagging-comprehensive: check-deps
|
||||
@echo "Running comprehensive tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) .
|
||||
@echo "✅ Comprehensive tagging tests completed"
|
||||
|
||||
# All tests without server management
|
||||
test-tagging-simple: check-deps
|
||||
@echo "Running tagging tests (assuming server is already running)..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) .
|
||||
@echo "✅ All tagging tests completed"
|
||||
|
||||
# Start server, run tests, stop server
|
||||
test-with-server: start-server
|
||||
@echo "Running tagging tests with managed server..."
|
||||
@sleep 5 # Give server time to fully start
|
||||
@make test-tagging-comprehensive || (echo "Tests failed, stopping server..." && make stop-server && exit 1)
|
||||
@make stop-server
|
||||
@echo "✅ All tests completed with managed server"
|
||||
|
||||
# Health check
|
||||
health-check:
|
||||
@echo "Checking server health..."
|
||||
@if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
|
||||
echo "✅ Server is accessible on port $(S3_PORT)"; \
|
||||
else \
|
||||
echo "❌ Server is not accessible on port $(S3_PORT)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
clean:
|
||||
@echo "Cleaning up test artifacts..."
|
||||
@make stop-server
|
||||
@rm -f weed-test.log
|
||||
@rm -f weed-server.pid
|
||||
@rm -rf ./test-volume-data
|
||||
@rm -f tagging.test
|
||||
@go clean -testcache
|
||||
@echo "✅ Cleanup completed"
|
||||
|
||||
# Individual test targets for specific functionality
|
||||
test-upload:
|
||||
@echo "Running upload tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestObjectTaggingOnUpload" .
|
||||
|
||||
test-special-chars:
|
||||
@echo "Running special characters tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestObjectTaggingOnUploadWithSpecialCharacters" .
|
||||
|
||||
test-api:
|
||||
@echo "Running API tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestPutObjectTaggingAPI" .
|
||||
|
||||
test-get:
|
||||
@echo "Running get tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestGetObjectTaggingAPI" .
|
||||
|
||||
test-delete:
|
||||
@echo "Running delete tagging tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestDeleteObjectTaggingAPI" .
|
||||
|
||||
# Development targets
|
||||
dev-start: start-server
|
||||
@echo "Development server started. Access S3 API at http://localhost:$(S3_PORT)"
|
||||
@echo "To stop: make stop-server"
|
||||
|
||||
dev-test: check-deps
|
||||
@echo "Running tests in development mode..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "TestObjectTaggingOnUpload" .
|
||||
|
||||
# CI targets
|
||||
ci-test: check-deps
|
||||
@echo "Running tests in CI mode..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -race .
|
||||
|
||||
# All targets
|
||||
test-all: test-tagging test-tagging-comprehensive
|
||||
@echo "✅ All tagging tests completed"
|
||||
|
||||
# Benchmark targets
|
||||
benchmark-tagging:
|
||||
@echo "Running tagging performance benchmarks..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -bench=. -benchmem .
|
||||
|
||||
# Coverage targets
|
||||
coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -coverprofile=coverage.out .
|
||||
@go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
# Format and lint
|
||||
fmt:
|
||||
@echo "Formatting Go code..."
|
||||
@go fmt .
|
||||
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
@golint . || echo "golint not available, skipping..."
|
||||
|
||||
# Install dependencies for development
|
||||
install-deps:
|
||||
@echo "Installing Go dependencies..."
|
||||
@go mod tidy
|
||||
@go mod download
|
||||
|
||||
# Show current configuration
|
||||
show-config:
|
||||
@echo "Current configuration:"
|
||||
@echo " WEED_BINARY: $(WEED_BINARY)"
|
||||
@echo " S3_PORT: $(S3_PORT)"
|
||||
@echo " TEST_TIMEOUT: $(TEST_TIMEOUT)"
|
||||
@echo " TEST_PATTERN: $(TEST_PATTERN)"
|
||||
|
||||
# Legacy targets for backward compatibility
|
||||
test: test-with-server
|
||||
test-verbose: test-tagging-comprehensive
|
||||
test-single: test-upload
|
||||
test-clean: clean
|
||||
build: check-deps
|
||||
setup: check-deps
|
||||
@@ -0,0 +1,53 @@
|
||||
# S3 Object Tagging Tests
|
||||
|
||||
This directory contains tests for S3 object tagging functionality.
|
||||
|
||||
## Issue Reference
|
||||
|
||||
These tests were created to verify the fix for [GitHub Issue #7589](https://github.com/seaweedfs/seaweedfs/issues/7589):
|
||||
**S3 object Tags query comes back empty**
|
||||
|
||||
## Problem Description
|
||||
|
||||
When uploading an object with tags using the `X-Amz-Tagging` header, the tags were not being stored.
|
||||
When querying the object tagging with `GetObjectTagging`, the response was empty.
|
||||
|
||||
This was a regression between SeaweedFS 4.00 and 4.01.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `putToFiler` function in `s3api_object_handlers_put.go` was not parsing the `X-Amz-Tagging` header
|
||||
and storing the tags in the entry's Extended metadata. The code was only copying user metadata
|
||||
(headers starting with `X-Amz-Meta-`) but not object tags.
|
||||
|
||||
## Fix
|
||||
|
||||
Added tag parsing logic to `putToFiler` that:
|
||||
1. Reads the `X-Amz-Tagging` header
|
||||
2. Parses it using `url.ParseQuery()` for proper URL decoding
|
||||
3. Stores each tag with the prefix `X-Amz-Tagging-` in the entry's Extended metadata
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tagging tests
|
||||
cd test/s3/tagging
|
||||
make test
|
||||
|
||||
# Run specific test
|
||||
make test-upload
|
||||
|
||||
# Or using go test directly
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
## Test Cases
|
||||
|
||||
1. **TestObjectTaggingOnUpload** - Basic test for tags sent during object upload
|
||||
2. **TestObjectTaggingOnUploadWithSpecialCharacters** - Tests URL-encoded tag values
|
||||
3. **TestObjectTaggingOnUploadWithEmptyValue** - Tests tags with empty values
|
||||
4. **TestPutObjectTaggingAPI** - Tests the PutObjectTagging API separately
|
||||
5. **TestDeleteObjectTagging** - Tests tag deletion
|
||||
6. **TestTagsNotPreservedAfterObjectOverwrite** - Verifies AWS S3 behavior on overwrite
|
||||
7. **TestMaximumNumberOfTags** - Tests storing the maximum 10 tags
|
||||
8. **TestTagCountHeader** - Tests the x-amz-tagging-count header in HeadObject
|
||||
@@ -0,0 +1,446 @@
|
||||
package tagging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// S3TestConfig holds configuration for S3 tests
|
||||
type S3TestConfig struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
BucketPrefix string
|
||||
UseSSL bool
|
||||
SkipVerifySSL bool
|
||||
}
|
||||
|
||||
// getDefaultConfig returns a fresh instance of the default test configuration
|
||||
func getDefaultConfig() *S3TestConfig {
|
||||
endpoint := os.Getenv("S3_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "http://localhost:8333" // Default SeaweedFS S3 port
|
||||
}
|
||||
accessKey := os.Getenv("S3_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "some_access_key1"
|
||||
}
|
||||
secretKey := os.Getenv("S3_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "some_secret_key1"
|
||||
}
|
||||
return &S3TestConfig{
|
||||
Endpoint: endpoint,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
Region: "us-east-1",
|
||||
BucketPrefix: "test-tagging-",
|
||||
UseSSL: false,
|
||||
SkipVerifySSL: true,
|
||||
}
|
||||
}
|
||||
|
||||
// getS3Client creates an AWS S3 client for testing
|
||||
func getS3Client(t *testing.T) *s3.Client {
|
||||
defaultConfig := getDefaultConfig()
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(defaultConfig.Region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
defaultConfig.AccessKey,
|
||||
defaultConfig.SecretKey,
|
||||
"",
|
||||
)),
|
||||
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
|
||||
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: defaultConfig.Endpoint,
|
||||
SigningRegion: defaultConfig.Region,
|
||||
}, nil
|
||||
})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
// createTestBucket creates a test bucket with a unique name
|
||||
func createTestBucket(t *testing.T, client *s3.Client) string {
|
||||
defaultConfig := getDefaultConfig()
|
||||
bucketName := fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, time.Now().UnixNano())
|
||||
|
||||
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for bucket metadata to be fully processed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
return bucketName
|
||||
}
|
||||
|
||||
// cleanupTestBucket removes the test bucket and all its contents
|
||||
func cleanupTestBucket(t *testing.T, client *s3.Client, bucketName string) {
|
||||
// First, delete all objects in the bucket
|
||||
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err == nil {
|
||||
for _, obj := range listResp.Contents {
|
||||
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: obj.Key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to delete object %s: %v", *obj.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then delete the bucket
|
||||
_, err = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to delete bucket %s: %v", bucketName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectTaggingOnUpload tests that tags sent during object upload (via X-Amz-Tagging header)
|
||||
// are properly stored and can be retrieved. This is the fix for GitHub issue #7589.
|
||||
func TestObjectTaggingOnUpload(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-with-tags"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Put object with tags using the Tagging parameter (X-Amz-Tagging header)
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("env=production&team=platform"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with tags")
|
||||
|
||||
// Get the tags back
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
|
||||
// Verify tags were stored correctly
|
||||
require.Len(t, tagResp.TagSet, 2, "Should have 2 tags")
|
||||
|
||||
// Build a map for easier assertion
|
||||
tagMap := make(map[string]string)
|
||||
for _, tag := range tagResp.TagSet {
|
||||
tagMap[*tag.Key] = *tag.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "production", tagMap["env"], "env tag should be 'production'")
|
||||
assert.Equal(t, "platform", tagMap["team"], "team tag should be 'platform'")
|
||||
}
|
||||
|
||||
// TestObjectTaggingOnUploadWithSpecialCharacters tests tags with URL-encoded characters
|
||||
func TestObjectTaggingOnUploadWithSpecialCharacters(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-with-special-tags"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Put object with tags containing special characters
|
||||
// AWS SDK will URL-encode these automatically
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("timestamp=2025-07-16 14:40:39&path=/tmp/file.txt"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with special character tags")
|
||||
|
||||
// Get the tags back
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
|
||||
// Verify tags were stored and URL-decoded correctly
|
||||
require.Len(t, tagResp.TagSet, 2, "Should have 2 tags")
|
||||
|
||||
tagMap := make(map[string]string)
|
||||
for _, tag := range tagResp.TagSet {
|
||||
tagMap[*tag.Key] = *tag.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "2025-07-16 14:40:39", tagMap["timestamp"], "timestamp tag should be decoded correctly")
|
||||
assert.Equal(t, "/tmp/file.txt", tagMap["path"], "path tag should be decoded correctly")
|
||||
}
|
||||
|
||||
// TestObjectTaggingOnUploadWithEmptyValue tests tags with empty values
|
||||
func TestObjectTaggingOnUploadWithEmptyValue(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-with-empty-tag"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Put object with a tag that has an empty value
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("marker=&env=dev"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with empty tag value")
|
||||
|
||||
// Get the tags back
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
|
||||
// Verify tags were stored correctly
|
||||
require.Len(t, tagResp.TagSet, 2, "Should have 2 tags")
|
||||
|
||||
tagMap := make(map[string]string)
|
||||
for _, tag := range tagResp.TagSet {
|
||||
tagMap[*tag.Key] = *tag.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "", tagMap["marker"], "marker tag should have empty value")
|
||||
assert.Equal(t, "dev", tagMap["env"], "env tag should be 'dev'")
|
||||
}
|
||||
|
||||
// TestPutObjectTaggingAPI tests the PutObjectTagging API separately from upload
|
||||
func TestPutObjectTaggingAPI(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-for-tagging-api"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// First, put object without tags
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object without tags")
|
||||
|
||||
// Get tags - should be empty
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
assert.Len(t, tagResp.TagSet, 0, "Should have no tags initially")
|
||||
|
||||
// Now add tags using PutObjectTagging API
|
||||
_, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Tagging: &types.Tagging{
|
||||
TagSet: []types.Tag{
|
||||
{Key: aws.String("env"), Value: aws.String("staging")},
|
||||
{Key: aws.String("version"), Value: aws.String("1.0")},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object tags via API")
|
||||
|
||||
// Get tags - should now have the tags
|
||||
tagResp, err = client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags after PutObjectTagging")
|
||||
require.Len(t, tagResp.TagSet, 2, "Should have 2 tags")
|
||||
|
||||
tagMap := make(map[string]string)
|
||||
for _, tag := range tagResp.TagSet {
|
||||
tagMap[*tag.Key] = *tag.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "staging", tagMap["env"], "env tag should be 'staging'")
|
||||
assert.Equal(t, "1.0", tagMap["version"], "version tag should be '1.0'")
|
||||
}
|
||||
|
||||
// TestDeleteObjectTagging tests the DeleteObjectTagging API
|
||||
func TestDeleteObjectTagging(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-for-delete-tags"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Put object with tags
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("env=production"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with tags")
|
||||
|
||||
// Verify tags exist
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
require.Len(t, tagResp.TagSet, 1, "Should have 1 tag")
|
||||
|
||||
// Delete tags
|
||||
_, err = client.DeleteObjectTagging(context.TODO(), &s3.DeleteObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to delete object tags")
|
||||
|
||||
// Verify tags are deleted
|
||||
tagResp, err = client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags after deletion")
|
||||
assert.Len(t, tagResp.TagSet, 0, "Should have no tags after deletion")
|
||||
}
|
||||
|
||||
// TestTagsNotPreservedAfterObjectOverwrite tests that tags are NOT preserved when an object is overwritten
|
||||
// This matches AWS S3 behavior where overwriting an object replaces all metadata including tags
|
||||
func TestTagsNotPreservedAfterObjectOverwrite(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-overwrite-tags"
|
||||
objectContent := "Original content"
|
||||
|
||||
// Put object with tags
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("original=true"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with tags")
|
||||
|
||||
// Verify original tags exist
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
require.Len(t, tagResp.TagSet, 1, "Should have 1 tag")
|
||||
assert.Equal(t, "original", *tagResp.TagSet[0].Key)
|
||||
|
||||
// Overwrite the object WITHOUT tags
|
||||
_, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader("New content"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to overwrite object")
|
||||
|
||||
// Tags should be gone after overwrite (matches AWS S3 behavior)
|
||||
tagResp, err = client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags after overwrite")
|
||||
assert.Len(t, tagResp.TagSet, 0, "Tags should be cleared after object overwrite")
|
||||
}
|
||||
|
||||
// TestMaximumNumberOfTags tests that we can store the maximum 10 tags per object
|
||||
func TestMaximumNumberOfTags(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-max-tags"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Build 10 tags (S3 max)
|
||||
tags := []string{}
|
||||
for i := 1; i <= 10; i++ {
|
||||
tags = append(tags, fmt.Sprintf("key%d=value%d", i, i))
|
||||
}
|
||||
tagging := strings.Join(tags, "&")
|
||||
|
||||
// Put object with 10 tags
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String(tagging),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with 10 tags")
|
||||
|
||||
// Get the tags back
|
||||
tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to get object tags")
|
||||
assert.Len(t, tagResp.TagSet, 10, "Should have 10 tags")
|
||||
}
|
||||
|
||||
// TestTagCountHeader tests that the x-amz-tagging-count header is returned in HeadObject
|
||||
func TestTagCountHeader(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := createTestBucket(t, client)
|
||||
defer cleanupTestBucket(t, client, bucketName)
|
||||
|
||||
objectKey := "test-object-tag-count"
|
||||
objectContent := "Hello, World!"
|
||||
|
||||
// Put object with tags
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
Tagging: aws.String("env=prod&team=backend&version=2.0"),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to put object with tags")
|
||||
|
||||
// Head object to get tag count
|
||||
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to head object")
|
||||
|
||||
// Check tag count header
|
||||
if headResp.TagCount != nil {
|
||||
assert.Equal(t, int32(3), *headResp.TagCount, "Tag count should be 3")
|
||||
} else {
|
||||
t.Log("Warning: TagCount header not returned - this may be expected depending on implementation")
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,11 @@ start-server: check-deps
|
||||
@echo "🔍 DEBUG: Creating volume directory..."
|
||||
@mkdir -p ./test-volume-data
|
||||
@echo "🔍 DEBUG: Launching SeaweedFS server in background..."
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
|
||||
@$(WEED_BINARY) server \
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowEmptyFolder=false \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
@@ -222,7 +221,7 @@ test-with-server: start-server
|
||||
test-versioning-with-configs: check-deps
|
||||
@echo "Testing with different S3 configurations..."
|
||||
@echo "Testing with empty folder allowed..."
|
||||
@$(WEED_BINARY) server -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=true -filer -master.volumeSizeLimitMB=100 -volume.max=100 > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
|
||||
@$(WEED_BINARY) server -s3 -s3.port=$(S3_PORT) -filer -master.volumeSizeLimitMB=100 -volume.max=100 > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
|
||||
@sleep 5
|
||||
@go test -v -timeout=5m -run "TestVersioningBasicWorkflow" . || true
|
||||
@if [ -f weed-config1.pid ]; then kill -TERM $$(cat weed-config1.pid) 2>/dev/null || true; rm -f weed-config1.pid; fi
|
||||
@@ -268,7 +267,6 @@ debug-server:
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowEmptyFolder=false \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
@@ -317,7 +315,6 @@ start-server-simple: check-deps
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowEmptyFolder=false \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
|
||||
@@ -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=
|
||||
Vendored
+37
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -98,33 +98,24 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string)
|
||||
glog.Warningf("Failed to initialize credential manager: %v", err)
|
||||
// Continue without credential manager - will fall back to legacy approach
|
||||
} else {
|
||||
// For stores that need filer client details, set them
|
||||
if store := credentialManager.GetStore(); store != nil {
|
||||
if filerClientSetter, ok := store.(interface {
|
||||
SetFilerClient(string, grpc.DialOption)
|
||||
}); ok {
|
||||
// We'll set the filer client later when we discover filers
|
||||
// For now, just store the credential manager
|
||||
server.credentialManager = credentialManager
|
||||
server.credentialManager = credentialManager
|
||||
glog.V(0).Infof("Credential manager initialized with store type: %s", credentialManager.GetStore().GetName())
|
||||
|
||||
// Set up a goroutine to set filer client once we discover filers
|
||||
go func() {
|
||||
for {
|
||||
filerAddr := server.GetFilerAddress()
|
||||
if filerAddr != "" {
|
||||
filerClientSetter.SetFilerClient(filerAddr, server.grpcDialOption)
|
||||
glog.V(1).Infof("Set filer client for credential manager: %s", filerAddr)
|
||||
break
|
||||
}
|
||||
glog.V(1).Infof("Waiting for filer discovery for credential manager...")
|
||||
time.Sleep(5 * time.Second) // Retry every 5 seconds
|
||||
}
|
||||
}()
|
||||
// For stores that need filer address function, configure them
|
||||
if store := credentialManager.GetStore(); store != nil {
|
||||
if filerFuncSetter, ok := store.(interface {
|
||||
SetFilerAddressFunc(func() pb.ServerAddress, grpc.DialOption)
|
||||
}); ok {
|
||||
// Configure the filer address function to dynamically return the current active filer
|
||||
// This function will be called each time credentials need to be loaded/saved,
|
||||
// so it will automatically use whatever filer is currently available (HA-aware)
|
||||
filerFuncSetter.SetFilerAddressFunc(func() pb.ServerAddress {
|
||||
return pb.ServerAddress(server.GetFilerAddress())
|
||||
}, server.grpcDialOption)
|
||||
glog.V(0).Infof("Credential store configured with dynamic filer address function")
|
||||
} else {
|
||||
server.credentialManager = credentialManager
|
||||
glog.V(0).Infof("Credential store %s does not support filer address function", store.GetName())
|
||||
}
|
||||
} else {
|
||||
server.credentialManager = credentialManager
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc" // Import to register filer_etc store
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// TestFilerAddressFunctionInterface tests that the filer_etc store
|
||||
// implements the correct SetFilerAddressFunc interface (issue #7575)
|
||||
func TestFilerAddressFunctionInterface(t *testing.T) {
|
||||
// Create credential manager with filer_etc store
|
||||
credentialManager, err := credential.NewCredentialManagerWithDefaults("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize credential manager: %v", err)
|
||||
}
|
||||
|
||||
store := credentialManager.GetStore()
|
||||
if store == nil {
|
||||
t.Fatal("Credential store is nil")
|
||||
}
|
||||
|
||||
// Check if store is filer_etc type
|
||||
if store.GetName() != credential.StoreTypeFilerEtc {
|
||||
t.Skipf("Skipping test - store is not filer_etc (got: %s)", store.GetName())
|
||||
}
|
||||
|
||||
// Check if store implements SetFilerAddressFunc interface
|
||||
// This is the critical check for bug #7575
|
||||
filerFuncSetter, ok := store.(interface {
|
||||
SetFilerAddressFunc(func() pb.ServerAddress, grpc.DialOption)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("FilerEtcStore does not implement SetFilerAddressFunc interface - bug #7575")
|
||||
}
|
||||
|
||||
// Verify we can call the method without panic
|
||||
mockFilerAddress := pb.ServerAddress("localhost:8888")
|
||||
filerFuncSetter.SetFilerAddressFunc(func() pb.ServerAddress {
|
||||
return mockFilerAddress
|
||||
}, grpc.WithInsecure())
|
||||
|
||||
t.Log("FilerEtcStore correctly implements SetFilerAddressFunc interface")
|
||||
}
|
||||
|
||||
// TestGenerateAccessKey tests the access key generation function
|
||||
func TestGenerateAccessKey(t *testing.T) {
|
||||
key1 := generateAccessKey()
|
||||
key2 := generateAccessKey()
|
||||
|
||||
// Check length
|
||||
if len(key1) != 20 {
|
||||
t.Errorf("Expected access key length 20, got %d", len(key1))
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
if key1 == key2 {
|
||||
t.Error("Generated access keys should be unique")
|
||||
}
|
||||
|
||||
// Check character set
|
||||
for _, c := range key1 {
|
||||
if !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
t.Errorf("Access key contains invalid character: %c", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSecretKey tests the secret key generation function
|
||||
func TestGenerateSecretKey(t *testing.T) {
|
||||
key1 := generateSecretKey()
|
||||
key2 := generateSecretKey()
|
||||
|
||||
// Check length (base64 encoding of 30 bytes = 40 characters)
|
||||
if len(key1) != 40 {
|
||||
t.Errorf("Expected secret key length 40, got %d", len(key1))
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
if key1 == key2 {
|
||||
t.Error("Generated secret keys should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAccountId tests the account ID generation function
|
||||
func TestGenerateAccountId(t *testing.T) {
|
||||
id1 := generateAccountId()
|
||||
id2 := generateAccountId()
|
||||
|
||||
// Check length
|
||||
if len(id1) != 12 {
|
||||
t.Errorf("Expected account ID length 12, got %d", len(id1))
|
||||
}
|
||||
|
||||
// Check that it's a number
|
||||
for _, c := range id1 {
|
||||
if c < '0' || c > '9' {
|
||||
t.Errorf("Account ID contains non-digit character: %c", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Check uniqueness (they should usually be different)
|
||||
if id1 == id2 {
|
||||
t.Log("Warning: Generated account IDs are the same (rare but possible)")
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -20,15 +22,37 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/http/client"
|
||||
)
|
||||
|
||||
type FileBrowserHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
httpClient *client.HTTPClient
|
||||
}
|
||||
|
||||
func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers {
|
||||
// Create HTTP client with TLS support from https.client configuration
|
||||
// The client is created without a timeout - each operation will set its own timeout
|
||||
// If TLS is enabled but misconfigured, fail fast to alert the operator immediately
|
||||
// rather than silently falling back to HTTP and causing confusing runtime errors
|
||||
httpClient, err := client.NewHttpClient(client.Client)
|
||||
if err != nil {
|
||||
glog.Fatalf("Failed to create HTTPS client for file browser: %v", err)
|
||||
}
|
||||
|
||||
return &FileBrowserHandlers{
|
||||
adminServer: adminServer,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// newClientWithTimeout creates a temporary http.Client with the specified timeout,
|
||||
// reusing the TLS transport from the shared httpClient.
|
||||
func (h *FileBrowserHandlers) newClientWithTimeout(timeout time.Duration) http.Client {
|
||||
return http.Client{
|
||||
Transport: h.httpClient.Client.Transport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,8 +269,12 @@ func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create full path for the file
|
||||
fullPath := filepath.Join(currentPath, fileName)
|
||||
// Normalize Windows-style backslashes to forward slashes
|
||||
fileName = util.CleanWindowsPath(fileName)
|
||||
|
||||
// Create full path for the file using path.Join for URL path semantics
|
||||
// path.Join handles double slashes and is not OS-specific like filepath.Join
|
||||
fullPath := path.Join(currentPath, fileName)
|
||||
if !strings.HasPrefix(fullPath, "/") {
|
||||
fullPath = "/" + fullPath
|
||||
}
|
||||
@@ -327,8 +355,10 @@ func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *mul
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Create form file field
|
||||
part, err := writer.CreateFormFile("file", fileHeader.Filename)
|
||||
// Create form file field with normalized base filename
|
||||
// Use path.Base (not filepath.Base) since cleanFilePath uses URL path semantics
|
||||
baseFileName := path.Base(cleanFilePath)
|
||||
part, err := writer.CreateFormFile("file", baseFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
@@ -345,8 +375,15 @@ func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *mul
|
||||
return fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create the upload URL with validated components
|
||||
uploadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
||||
// Create the upload URL - the httpClient will normalize to the correct scheme (http/https)
|
||||
// based on the https.client configuration in security.toml
|
||||
uploadURL := fmt.Sprintf("%s%s", filerAddress, cleanFilePath)
|
||||
|
||||
// Normalize the URL scheme based on TLS configuration
|
||||
uploadURL, err = h.httpClient.NormalizeHttpScheme(uploadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize URL scheme: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", uploadURL, &body)
|
||||
@@ -357,11 +394,11 @@ func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *mul
|
||||
// Set content type with boundary
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Send request
|
||||
client := &http.Client{Timeout: 60 * time.Second} // Increased timeout for larger files
|
||||
// Send request using TLS-aware HTTP client with 60s timeout for large file uploads
|
||||
// lgtm[go/ssrf]
|
||||
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
|
||||
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
|
||||
client := h.newClientWithTimeout(60 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file: %w", err)
|
||||
@@ -423,8 +460,12 @@ func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string,
|
||||
return "", fmt.Errorf("file path cannot be empty")
|
||||
}
|
||||
|
||||
// Normalize Windows-style backslashes to forward slashes
|
||||
filePath = util.CleanWindowsPath(filePath)
|
||||
|
||||
// Clean the path to remove any .. or . components
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
// Use path.Clean (not filepath.Clean) since this is a URL path
|
||||
cleanPath := path.Clean(filePath)
|
||||
|
||||
// Ensure the path starts with /
|
||||
if !strings.HasPrefix(cleanPath, "/") {
|
||||
@@ -444,7 +485,57 @@ func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string,
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
// DownloadFile handles file download requests
|
||||
// fetchFileContent fetches file content from the filer and returns the content or an error.
|
||||
func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Duration) (string, error) {
|
||||
filerAddress := h.adminServer.GetFilerAddress()
|
||||
if filerAddress == "" {
|
||||
return "", fmt.Errorf("filer address not configured")
|
||||
}
|
||||
|
||||
if err := h.validateFilerAddress(filerAddress); err != nil {
|
||||
return "", fmt.Errorf("invalid filer address configuration: %w", err)
|
||||
}
|
||||
|
||||
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create the file URL with proper scheme based on TLS configuration
|
||||
fileURL := fmt.Sprintf("%s%s", filerAddress, cleanFilePath)
|
||||
fileURL, err = h.httpClient.NormalizeHttpScheme(fileURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to construct file URL: %w", err)
|
||||
}
|
||||
|
||||
// lgtm[go/ssrf]
|
||||
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
|
||||
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
|
||||
client := h.newClientWithTimeout(timeout)
|
||||
resp, err := client.Get(fileURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch file from filer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("filer returned status %d but failed to read response body: %w", resp.StatusCode, err)
|
||||
}
|
||||
return "", fmt.Errorf("filer returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
contentBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file content: %w", err)
|
||||
}
|
||||
|
||||
return string(contentBytes), nil
|
||||
}
|
||||
|
||||
// DownloadFile handles file download requests by proxying through the Admin UI server
|
||||
// This ensures mTLS works correctly since the Admin UI server has the client certificates
|
||||
func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
|
||||
filePath := c.Query("path")
|
||||
if filePath == "" {
|
||||
@@ -459,6 +550,12 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate filer address to prevent SSRF
|
||||
if err := h.validateFilerAddress(filerAddress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid filer address configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate and sanitize the file path
|
||||
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
||||
if err != nil {
|
||||
@@ -466,16 +563,66 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the download URL
|
||||
downloadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
||||
// Create the download URL with proper scheme based on TLS configuration
|
||||
downloadURL := fmt.Sprintf("%s%s", filerAddress, cleanFilePath)
|
||||
downloadURL, err = h.httpClient.NormalizeHttpScheme(downloadURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to construct download URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Proxy the download through the Admin UI server to support mTLS
|
||||
// lgtm[go/ssrf]
|
||||
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
|
||||
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
|
||||
// Use request context so download is cancelled when client disconnects
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
client := h.newClientWithTimeout(5 * time.Minute) // Longer timeout for large file downloads
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch file from filer: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err)})
|
||||
return
|
||||
}
|
||||
c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body))})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers for file download
|
||||
fileName := filepath.Base(cleanFilePath)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
// Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition,
|
||||
// properly handling non-ASCII characters and special characters
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
|
||||
|
||||
// Proxy the request to filer
|
||||
c.Redirect(http.StatusFound, downloadURL)
|
||||
// Use content type from filer response, or default to octet-stream
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", contentType)
|
||||
|
||||
// Set content length if available
|
||||
if resp.ContentLength > 0 {
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
|
||||
}
|
||||
|
||||
// Stream the response body to the client
|
||||
c.Status(http.StatusOK)
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
glog.Errorf("Error streaming file download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ViewFile handles file viewing requests (for text files, images, etc.)
|
||||
@@ -559,46 +706,13 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
|
||||
viewable = false
|
||||
reason = "File too large for viewing (>1MB)"
|
||||
} else {
|
||||
// Get file content from filer
|
||||
filerAddress := h.adminServer.GetFilerAddress()
|
||||
if filerAddress != "" {
|
||||
// Validate filer address to prevent SSRF
|
||||
if err := h.validateFilerAddress(filerAddress); err != nil {
|
||||
viewable = false
|
||||
reason = "Invalid filer address configuration"
|
||||
} else {
|
||||
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
||||
if err == nil {
|
||||
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
// lgtm[go/ssrf]
|
||||
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
|
||||
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
|
||||
resp, err := client.Get(fileURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
contentBytes, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
content = string(contentBytes)
|
||||
viewable = true
|
||||
} else {
|
||||
viewable = false
|
||||
reason = "Failed to read file content"
|
||||
}
|
||||
} else {
|
||||
viewable = false
|
||||
reason = "Failed to fetch file from filer"
|
||||
}
|
||||
} else {
|
||||
viewable = false
|
||||
reason = "Invalid file path"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewable = false
|
||||
reason = "Filer address not configured"
|
||||
// Fetch file content from filer
|
||||
var err error
|
||||
content, err = h.fetchFileContent(filePath, 30*time.Second)
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
viewable = (err == nil)
|
||||
}
|
||||
} else {
|
||||
// Not a text file, but might be viewable as image or PDF
|
||||
@@ -893,18 +1007,28 @@ func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int
|
||||
return false
|
||||
}
|
||||
|
||||
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
||||
// Create the file URL with proper scheme based on TLS configuration
|
||||
fileURL := fmt.Sprintf("%s%s", filerAddress, cleanFilePath)
|
||||
fileURL, err = h.httpClient.NormalizeHttpScheme(fileURL)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to normalize URL scheme: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
// lgtm[go/ssrf]
|
||||
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
|
||||
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
|
||||
client := h.newClientWithTimeout(10 * time.Second)
|
||||
resp, err := client.Get(fileURL)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
// Read first few bytes to check if it's text
|
||||
buffer := make([]byte, min(maxCheckSize, 512))
|
||||
n, err := resp.Body.Read(buffer)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/handlers"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc" // Register filer_etc credential store
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
|
||||
@@ -121,14 +121,17 @@ func init() {
|
||||
filerS3Options.tlsPrivateKey = cmdFiler.Flag.String("s3.key.file", "", "path to the TLS private key file")
|
||||
filerS3Options.tlsCertificate = cmdFiler.Flag.String("s3.cert.file", "", "path to the TLS certificate file")
|
||||
filerS3Options.config = cmdFiler.Flag.String("s3.config", "", "path to the config file")
|
||||
filerS3Options.iamConfig = cmdFiler.Flag.String("s3.iam.config", "", "path to the advanced IAM config file")
|
||||
filerS3Options.auditLogConfig = cmdFiler.Flag.String("s3.auditLogConfig", "", "path to the audit log config file")
|
||||
filerS3Options.allowEmptyFolder = cmdFiler.Flag.Bool("s3.allowEmptyFolder", true, "allow empty folders")
|
||||
filerS3Options.metricsHttpPort = cmdFiler.Flag.Int("s3.metricsPort", 0, "Prometheus metrics listen port")
|
||||
filerS3Options.metricsHttpIp = cmdFiler.Flag.String("s3.metricsIp", "", "metrics listen ip. If empty, default to same as -s3.ip.bind option.")
|
||||
cmdFiler.Flag.Bool("s3.allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
filerS3Options.allowDeleteBucketNotEmpty = cmdFiler.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
filerS3Options.localSocket = cmdFiler.Flag.String("s3.localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
filerS3Options.tlsCACertificate = cmdFiler.Flag.String("s3.cacert.file", "", "path to the TLS CA certificate file")
|
||||
filerS3Options.tlsVerifyClientCert = cmdFiler.Flag.Bool("s3.tlsVerifyClientCert", false, "whether to verify the client's certificate")
|
||||
filerS3Options.bindIp = cmdFiler.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
||||
filerS3Options.idleTimeout = cmdFiler.Flag.Int("s3.idleTimeout", 10, "connection idle seconds")
|
||||
filerS3Options.idleTimeout = cmdFiler.Flag.Int("s3.idleTimeout", 120, "connection idle seconds")
|
||||
filerS3Options.concurrentUploadLimitMB = cmdFiler.Flag.Int("s3.concurrentUploadLimitMB", 128, "limit total concurrent upload size for S3")
|
||||
filerS3Options.concurrentFileUploadLimit = cmdFiler.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
|
||||
|
||||
@@ -229,6 +232,10 @@ func runFiler(cmd *Command, args []string) bool {
|
||||
if *f.dataCenter != "" && *filerS3Options.dataCenter == "" {
|
||||
filerS3Options.dataCenter = f.dataCenter
|
||||
}
|
||||
// Set S3 metrics IP based on bind IP if not explicitly set
|
||||
if *filerS3Options.metricsHttpIp == "" {
|
||||
*filerS3Options.metricsHttpIp = *filerS3Options.bindIp
|
||||
}
|
||||
go func(delay time.Duration) {
|
||||
time.Sleep(delay * time.Second)
|
||||
filerS3Options.startS3Server()
|
||||
|
||||
+2
-4
@@ -49,7 +49,6 @@ type S3Options struct {
|
||||
tlsVerifyClientCert *bool
|
||||
metricsHttpPort *int
|
||||
metricsHttpIp *string
|
||||
allowEmptyFolder *bool
|
||||
allowDeleteBucketNotEmpty *bool
|
||||
auditLogConfig *string
|
||||
localFilerSocket *string
|
||||
@@ -80,11 +79,11 @@ func init() {
|
||||
s3StandaloneOptions.tlsVerifyClientCert = cmdS3.Flag.Bool("tlsVerifyClientCert", false, "whether to verify the client's certificate")
|
||||
s3StandaloneOptions.metricsHttpPort = cmdS3.Flag.Int("metricsPort", 0, "Prometheus metrics listen port")
|
||||
s3StandaloneOptions.metricsHttpIp = cmdS3.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
s3StandaloneOptions.allowEmptyFolder = cmdS3.Flag.Bool("allowEmptyFolder", true, "allow empty folders")
|
||||
cmdS3.Flag.Bool("allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
s3StandaloneOptions.allowDeleteBucketNotEmpty = cmdS3.Flag.Bool("allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
s3StandaloneOptions.localFilerSocket = cmdS3.Flag.String("localFilerSocket", "", "local filer socket path")
|
||||
s3StandaloneOptions.localSocket = cmdS3.Flag.String("localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
s3StandaloneOptions.idleTimeout = cmdS3.Flag.Int("idleTimeout", 10, "connection idle seconds")
|
||||
s3StandaloneOptions.idleTimeout = cmdS3.Flag.Int("idleTimeout", 120, "connection idle seconds")
|
||||
s3StandaloneOptions.concurrentUploadLimitMB = cmdS3.Flag.Int("concurrentUploadLimitMB", 128, "limit total concurrent upload size")
|
||||
s3StandaloneOptions.concurrentFileUploadLimit = cmdS3.Flag.Int("concurrentFileUploadLimit", 0, "limit number of concurrent file uploads, 0 means unlimited")
|
||||
}
|
||||
@@ -273,7 +272,6 @@ func (s3opt *S3Options) startS3Server() bool {
|
||||
AllowedOrigins: strings.Split(*s3opt.allowedOrigins, ","),
|
||||
BucketsPath: filerBucketsPath,
|
||||
GrpcDialOption: grpcDialOption,
|
||||
AllowEmptyFolder: *s3opt.allowEmptyFolder,
|
||||
AllowDeleteBucketNotEmpty: *s3opt.allowDeleteBucketNotEmpty,
|
||||
LocalFilerSocket: localFilerSocket,
|
||||
DataCenter: *s3opt.dataCenter,
|
||||
|
||||
@@ -63,9 +63,9 @@ port = 3306
|
||||
username = "root"
|
||||
password = ""
|
||||
database = "" # create or use an existing database
|
||||
connection_max_idle = 2
|
||||
connection_max_open = 100
|
||||
connection_max_lifetime_seconds = 0
|
||||
connection_max_idle = 10
|
||||
connection_max_open = 50
|
||||
connection_max_lifetime_seconds = 300
|
||||
interpolateParams = false
|
||||
# if insert/upsert failing, you can disable upsert or update query syntax to match your RDBMS syntax:
|
||||
enableUpsert = true
|
||||
@@ -87,9 +87,9 @@ port = 3306
|
||||
username = "root"
|
||||
password = ""
|
||||
database = "" # create or use an existing database
|
||||
connection_max_idle = 2
|
||||
connection_max_open = 100
|
||||
connection_max_lifetime_seconds = 0
|
||||
connection_max_idle = 10
|
||||
connection_max_open = 50
|
||||
connection_max_lifetime_seconds = 300
|
||||
interpolateParams = false
|
||||
# if insert/upsert failing, you can disable upsert or update query syntax to match your RDBMS syntax:
|
||||
enableUpsert = true
|
||||
@@ -117,9 +117,9 @@ sslmode = "disable"
|
||||
# sslkey = "/path/to/client.key" # client private key file
|
||||
# sslrootcert = "/path/to/ca.crt" # CA certificate file
|
||||
# sslcrl = "/path/to/client.crl" # Certificate Revocation List (CRL) (optional)
|
||||
connection_max_idle = 100
|
||||
connection_max_open = 100
|
||||
connection_max_lifetime_seconds = 0
|
||||
connection_max_idle = 10
|
||||
connection_max_open = 50
|
||||
connection_max_lifetime_seconds = 300
|
||||
# Set to true when using PgBouncer connection pooler
|
||||
pgbouncer_compatible = false
|
||||
# if insert/upsert failing, you can disable upsert or update query syntax to match your RDBMS syntax:
|
||||
@@ -156,9 +156,9 @@ sslmode = "disable"
|
||||
# sslkey = "/path/to/client.key" # client private key file
|
||||
# sslrootcert = "/path/to/ca.crt" # CA certificate file
|
||||
# sslcrl = "/path/to/client.crl" # Certificate Revocation List (CRL) (optional)
|
||||
connection_max_idle = 100
|
||||
connection_max_open = 100
|
||||
connection_max_lifetime_seconds = 0
|
||||
connection_max_idle = 10
|
||||
connection_max_open = 50
|
||||
connection_max_lifetime_seconds = 300
|
||||
# Set to true when using PgBouncer connection pooler
|
||||
pgbouncer_compatible = false
|
||||
# if insert/upsert failing, you can disable upsert or update query syntax to match your RDBMS syntax:
|
||||
@@ -201,8 +201,11 @@ table = "seaweedfs"
|
||||
[redis2]
|
||||
enabled = false
|
||||
address = "localhost:6379"
|
||||
username = ""
|
||||
password = ""
|
||||
database = 0
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -217,6 +220,8 @@ masterName = "master"
|
||||
username = ""
|
||||
password = ""
|
||||
database = 0
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -232,7 +237,10 @@ addresses = [
|
||||
"localhost:30005",
|
||||
"localhost:30006",
|
||||
]
|
||||
username = ""
|
||||
password = ""
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -248,8 +256,11 @@ superLargeDirectories = []
|
||||
[redis_lua]
|
||||
enabled = false
|
||||
address = "localhost:6379"
|
||||
username = ""
|
||||
password = ""
|
||||
database = 0
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -264,6 +275,8 @@ masterName = "master"
|
||||
username = ""
|
||||
password = ""
|
||||
database = 0
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -279,7 +292,10 @@ addresses = [
|
||||
"localhost:30005",
|
||||
"localhost:30006",
|
||||
]
|
||||
username = ""
|
||||
password = ""
|
||||
# prefix for filer redis keys
|
||||
keyPrefix = ""
|
||||
enable_tls = false
|
||||
ca_cert_path = ""
|
||||
client_cert_path = ""
|
||||
@@ -373,8 +389,10 @@ dialTimeOut = 10
|
||||
enabled = false
|
||||
location = "/tmp/"
|
||||
address = "localhost:6379"
|
||||
username = ""
|
||||
password = ""
|
||||
database = 1
|
||||
keyPrefix = ""
|
||||
|
||||
[tikv]
|
||||
enabled = false
|
||||
|
||||
@@ -133,11 +133,13 @@ func init() {
|
||||
serverOptions.v.port = cmdServer.Flag.Int("volume.port", 8080, "volume server http listen port")
|
||||
serverOptions.v.portGrpc = cmdServer.Flag.Int("volume.port.grpc", 0, "volume server grpc listen port")
|
||||
serverOptions.v.publicPort = cmdServer.Flag.Int("volume.port.public", 0, "volume server public port")
|
||||
serverOptions.v.id = cmdServer.Flag.String("volume.id", "", "volume server id. If empty, default to ip:port")
|
||||
serverOptions.v.indexType = cmdServer.Flag.String("volume.index", "memory", "Choose [memory|leveldb|leveldbMedium|leveldbLarge] mode for memory~performance balance.")
|
||||
serverOptions.v.diskType = cmdServer.Flag.String("volume.disk", "", "[hdd|ssd|<tag>] hard drive or solid state drive or any tag")
|
||||
serverOptions.v.fixJpgOrientation = cmdServer.Flag.Bool("volume.images.fix.orientation", false, "Adjust jpg orientation when uploading.")
|
||||
serverOptions.v.readMode = cmdServer.Flag.String("volume.readMode", "proxy", "[local|proxy|redirect] how to deal with non-local volume: 'not found|read in remote node|redirect volume location'.")
|
||||
serverOptions.v.compactionMBPerSecond = cmdServer.Flag.Int("volume.compactionMBps", 0, "limit compaction speed in mega bytes per second")
|
||||
serverOptions.v.maintenanceMBPerSecond = cmdServer.Flag.Int("volume.maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
|
||||
serverOptions.v.fileSizeLimitMB = cmdServer.Flag.Int("volume.fileSizeLimitMB", 256, "limit file size to avoid out of memory")
|
||||
serverOptions.v.ldbTimeout = cmdServer.Flag.Int64("volume.index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
|
||||
serverOptions.v.concurrentUploadLimitMB = cmdServer.Flag.Int("volume.concurrentUploadLimitMB", 64, "limit total concurrent upload size")
|
||||
@@ -164,11 +166,11 @@ func init() {
|
||||
s3Options.config = cmdServer.Flag.String("s3.config", "", "path to the config file")
|
||||
s3Options.iamConfig = cmdServer.Flag.String("s3.iam.config", "", "path to the advanced IAM config file for S3. Overrides -iam.config if both are provided.")
|
||||
s3Options.auditLogConfig = cmdServer.Flag.String("s3.auditLogConfig", "", "path to the audit log config file")
|
||||
s3Options.allowEmptyFolder = cmdServer.Flag.Bool("s3.allowEmptyFolder", true, "allow empty folders")
|
||||
cmdServer.Flag.Bool("s3.allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
s3Options.allowDeleteBucketNotEmpty = cmdServer.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
s3Options.localSocket = cmdServer.Flag.String("s3.localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
s3Options.bindIp = cmdServer.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
||||
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 10, "connection idle seconds")
|
||||
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 120, "connection idle seconds")
|
||||
s3Options.concurrentUploadLimitMB = cmdServer.Flag.Int("s3.concurrentUploadLimitMB", 128, "limit total concurrent upload size for S3")
|
||||
s3Options.concurrentFileUploadLimit = cmdServer.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/sftpd"
|
||||
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/grace"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -148,6 +149,9 @@ func (sftpOpt *SftpOptions) startSftpServer() bool {
|
||||
UserStoreFile: *sftpOpt.userStoreFile,
|
||||
})
|
||||
|
||||
// Register reload hook for HUP signal
|
||||
grace.OnReload(service.Reload)
|
||||
|
||||
// Set up Unix socket if on non-Windows platforms
|
||||
if runtime.GOOS != "windows" {
|
||||
localSocket := *sftpOpt.localSocket
|
||||
|
||||
@@ -41,6 +41,7 @@ type VolumeServerOptions struct {
|
||||
folderMaxLimits []int32
|
||||
idxFolder *string
|
||||
ip *string
|
||||
id *string
|
||||
publicUrl *string
|
||||
bindIp *string
|
||||
mastersString *string
|
||||
@@ -57,6 +58,7 @@ type VolumeServerOptions struct {
|
||||
cpuProfile *string
|
||||
memProfile *string
|
||||
compactionMBPerSecond *int
|
||||
maintenanceMBPerSecond *int
|
||||
fileSizeLimitMB *int
|
||||
concurrentUploadLimitMB *int
|
||||
concurrentDownloadLimitMB *int
|
||||
@@ -78,6 +80,7 @@ func init() {
|
||||
v.portGrpc = cmdVolume.Flag.Int("port.grpc", 0, "grpc listen port")
|
||||
v.publicPort = cmdVolume.Flag.Int("port.public", 0, "port opened to public")
|
||||
v.ip = cmdVolume.Flag.String("ip", util.DetectedHostAddress(), "ip or server name, also used as identifier")
|
||||
v.id = cmdVolume.Flag.String("id", "", "volume server id. If empty, default to ip:port")
|
||||
v.publicUrl = cmdVolume.Flag.String("publicUrl", "", "Publicly accessible address")
|
||||
v.bindIp = cmdVolume.Flag.String("ip.bind", "", "ip address to bind to. If empty, default to same as -ip option.")
|
||||
v.mastersString = cmdVolume.Flag.String("master", "localhost:9333", "comma-separated master servers")
|
||||
@@ -94,6 +97,7 @@ func init() {
|
||||
v.cpuProfile = cmdVolume.Flag.String("cpuprofile", "", "cpu profile output file")
|
||||
v.memProfile = cmdVolume.Flag.String("memprofile", "", "memory profile output file")
|
||||
v.compactionMBPerSecond = cmdVolume.Flag.Int("compactionMBps", 0, "limit background compaction or copying speed in mega bytes per second")
|
||||
v.maintenanceMBPerSecond = cmdVolume.Flag.Int("maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
|
||||
v.fileSizeLimitMB = cmdVolume.Flag.Int("fileSizeLimitMB", 256, "limit file size to avoid out of memory")
|
||||
v.ldbTimeout = cmdVolume.Flag.Int64("index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
|
||||
v.concurrentUploadLimitMB = cmdVolume.Flag.Int("concurrentUploadLimitMB", 256, "limit total concurrent upload size")
|
||||
@@ -253,8 +257,11 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v
|
||||
volumeNeedleMapKind = storage.NeedleMapLevelDbLarge
|
||||
}
|
||||
|
||||
// Determine volume server ID: if not specified, use ip:port
|
||||
volumeServerId := util.GetVolumeServerId(*v.id, *v.ip, *v.port)
|
||||
|
||||
volumeServer := weed_server.NewVolumeServer(volumeMux, publicVolumeMux,
|
||||
*v.ip, *v.port, *v.portGrpc, *v.publicUrl,
|
||||
*v.ip, *v.port, *v.portGrpc, *v.publicUrl, volumeServerId,
|
||||
v.folders, v.folderMaxLimits, minFreeSpaces, diskTypes,
|
||||
*v.idxFolder,
|
||||
volumeNeedleMapKind,
|
||||
@@ -262,6 +269,7 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v
|
||||
v.whiteList,
|
||||
*v.fixJpgOrientation, *v.readMode,
|
||||
*v.compactionMBPerSecond,
|
||||
*v.maintenanceMBPerSecond,
|
||||
*v.fileSizeLimitMB,
|
||||
int64(*v.concurrentUploadLimitMB)*1024*1024,
|
||||
int64(*v.concurrentDownloadLimitMB)*1024*1024,
|
||||
|
||||
@@ -58,7 +58,7 @@ func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFiler
|
||||
store.mu.RLock()
|
||||
if store.filerAddressFunc == nil {
|
||||
store.mu.RUnlock()
|
||||
return fmt.Errorf("filer_etc: filer address function not configured")
|
||||
return fmt.Errorf("filer_etc: filer not yet available - please wait for filer discovery to complete and try again")
|
||||
}
|
||||
|
||||
filerAddress := store.filerAddressFunc()
|
||||
@@ -66,7 +66,7 @@ func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFiler
|
||||
store.mu.RUnlock()
|
||||
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer_etc: filer address is empty")
|
||||
return fmt.Errorf("filer_etc: no filer discovered yet - please ensure a filer is running and accessible")
|
||||
}
|
||||
|
||||
// Use the pb.WithGrpcFilerClient helper similar to existing code
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer/empty_folder_cleanup"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
@@ -56,6 +57,7 @@ type Filer struct {
|
||||
MaxFilenameLength uint32
|
||||
deletionQuit chan struct{}
|
||||
DeletionRetryQueue *DeletionRetryQueue
|
||||
EmptyFolderCleaner *empty_folder_cleanup.EmptyFolderCleaner
|
||||
}
|
||||
|
||||
func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerHost pb.ServerAddress, filerGroup string, collection string, replication string, dataCenter string, maxFilenameLength uint32, notifyFn func()) *Filer {
|
||||
@@ -116,6 +118,9 @@ func (f *Filer) AggregateFromPeers(self pb.ServerAddress, existingNodes []*maste
|
||||
f.Dlm.LockRing.SetSnapshot(snapshot)
|
||||
glog.V(0).Infof("%s aggregate from peers %+v", self, snapshot)
|
||||
|
||||
// Initialize the empty folder cleaner using the same LockRing as Dlm for consistent hashing
|
||||
f.EmptyFolderCleaner = empty_folder_cleanup.NewEmptyFolderCleaner(f, f.Dlm.LockRing, self, f.DirBucketsPath)
|
||||
|
||||
f.MetaAggregator = NewMetaAggregator(f, self, f.GrpcDialOption)
|
||||
f.MasterClient.SetOnPeerUpdateFn(func(update *master_pb.ClusterNodeUpdate, startFrom time.Time) {
|
||||
if update.NodeType != cluster.FilerType {
|
||||
@@ -506,6 +511,9 @@ func (f *Filer) IsDirectoryEmpty(ctx context.Context, dirPath util.FullPath) (bo
|
||||
|
||||
func (f *Filer) Shutdown() {
|
||||
close(f.deletionQuit)
|
||||
if f.EmptyFolderCleaner != nil {
|
||||
f.EmptyFolderCleaner.Stop()
|
||||
}
|
||||
f.LocalMetaLogBuffer.ShutdownLogBuffer()
|
||||
f.Store.Shutdown()
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ func (f *Filer) NotifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
|
||||
|
||||
f.logMetaEvent(ctx, fullpath, eventNotification)
|
||||
|
||||
// Trigger empty folder cleanup for local events
|
||||
// Remote events are handled via MetaAggregator.onMetadataChangeEvent
|
||||
f.triggerLocalEmptyFolderCleanup(oldEntry, newEntry)
|
||||
|
||||
}
|
||||
|
||||
func (f *Filer) logMetaEvent(ctx context.Context, fullpath string, eventNotification *filer_pb.EventNotification) {
|
||||
@@ -89,6 +93,41 @@ func (f *Filer) logMetaEvent(ctx context.Context, fullpath string, eventNotifica
|
||||
|
||||
}
|
||||
|
||||
// triggerLocalEmptyFolderCleanup triggers empty folder cleanup for local events
|
||||
// This is needed because onMetadataChangeEvent is only called for remote peer events
|
||||
func (f *Filer) triggerLocalEmptyFolderCleanup(oldEntry, newEntry *Entry) {
|
||||
if f.EmptyFolderCleaner == nil || !f.EmptyFolderCleaner.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
eventTime := time.Now()
|
||||
|
||||
// Handle delete events (oldEntry exists, newEntry is nil)
|
||||
if oldEntry != nil && newEntry == nil {
|
||||
dir, name := oldEntry.FullPath.DirAndName()
|
||||
f.EmptyFolderCleaner.OnDeleteEvent(dir, name, oldEntry.IsDirectory(), eventTime)
|
||||
}
|
||||
|
||||
// Handle create events (oldEntry is nil, newEntry exists)
|
||||
if oldEntry == nil && newEntry != nil {
|
||||
dir, name := newEntry.FullPath.DirAndName()
|
||||
f.EmptyFolderCleaner.OnCreateEvent(dir, name, newEntry.IsDirectory())
|
||||
}
|
||||
|
||||
// Handle rename/move events (both exist but paths differ)
|
||||
if oldEntry != nil && newEntry != nil {
|
||||
oldDir, oldName := oldEntry.FullPath.DirAndName()
|
||||
newDir, newName := newEntry.FullPath.DirAndName()
|
||||
|
||||
if oldDir != newDir || oldName != newName {
|
||||
// Treat old location as delete
|
||||
f.EmptyFolderCleaner.OnDeleteEvent(oldDir, oldName, oldEntry.IsDirectory(), eventTime)
|
||||
// Treat new location as create
|
||||
f.EmptyFolderCleaner.OnCreateEvent(newDir, newName, newEntry.IsDirectory())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filer) logFlushFunc(logBuffer *log_buffer.LogBuffer, startTime, stopTime time.Time, buf []byte, minOffset, maxOffset int64) {
|
||||
|
||||
if len(buf) == 0 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package filer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -13,6 +14,7 @@ func (f *Filer) onMetadataChangeEvent(event *filer_pb.SubscribeMetadataResponse)
|
||||
f.maybeReloadFilerConfiguration(event)
|
||||
f.maybeReloadRemoteStorageConfigurationAndMapping(event)
|
||||
f.onBucketEvents(event)
|
||||
f.onEmptyFolderCleanupEvents(event)
|
||||
}
|
||||
|
||||
func (f *Filer) onBucketEvents(event *filer_pb.SubscribeMetadataResponse) {
|
||||
@@ -32,6 +34,43 @@ func (f *Filer) onBucketEvents(event *filer_pb.SubscribeMetadataResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
// onEmptyFolderCleanupEvents handles create/delete events for empty folder cleanup
|
||||
func (f *Filer) onEmptyFolderCleanupEvents(event *filer_pb.SubscribeMetadataResponse) {
|
||||
if f.EmptyFolderCleaner == nil || !f.EmptyFolderCleaner.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
message := event.EventNotification
|
||||
directory := event.Directory
|
||||
eventTime := time.Unix(0, event.TsNs)
|
||||
|
||||
// Handle delete events - trigger folder cleanup check
|
||||
if filer_pb.IsDelete(event) && message.OldEntry != nil {
|
||||
f.EmptyFolderCleaner.OnDeleteEvent(directory, message.OldEntry.Name, message.OldEntry.IsDirectory, eventTime)
|
||||
}
|
||||
|
||||
// Handle create events - cancel pending cleanup for the folder
|
||||
if filer_pb.IsCreate(event) && message.NewEntry != nil {
|
||||
f.EmptyFolderCleaner.OnCreateEvent(directory, message.NewEntry.Name, message.NewEntry.IsDirectory)
|
||||
}
|
||||
|
||||
// Handle rename/move events
|
||||
if filer_pb.IsRename(event) {
|
||||
// Treat the old location as a delete
|
||||
if message.OldEntry != nil {
|
||||
f.EmptyFolderCleaner.OnDeleteEvent(directory, message.OldEntry.Name, message.OldEntry.IsDirectory, eventTime)
|
||||
}
|
||||
// Treat the new location as a create
|
||||
if message.NewEntry != nil {
|
||||
newDir := message.NewParentPath
|
||||
if newDir == "" {
|
||||
newDir = directory
|
||||
}
|
||||
f.EmptyFolderCleaner.OnCreateEvent(newDir, message.NewEntry.Name, message.NewEntry.IsDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filer) maybeReloadFilerConfiguration(event *filer_pb.SubscribeMetadataResponse) {
|
||||
if DirectoryEtcSeaweedFS != event.Directory {
|
||||
if DirectoryEtcSeaweedFS != event.EventNotification.NewParentPath {
|
||||
|
||||
@@ -41,6 +41,19 @@ func (f *Filer) ListDirectoryEntries(ctx context.Context, p util.FullPath, start
|
||||
return entries, hasMore, err
|
||||
}
|
||||
|
||||
// CountDirectoryEntries counts entries in a directory up to limit
|
||||
func (f *Filer) CountDirectoryEntries(ctx context.Context, p util.FullPath, limit int) (count int, err error) {
|
||||
entries, hasMore, err := f.ListDirectoryEntries(ctx, p, "", false, int64(limit), "", "", "")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count = len(entries)
|
||||
if hasMore {
|
||||
count = limit // At least this many
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// For now, prefix and namePattern are mutually exclusive
|
||||
func (f *Filer) StreamListDirectoryEntries(ctx context.Context, p util.FullPath, startFileName string, inclusive bool, limit int64, prefix string, namePattern string, namePatternExclude string, eachEntryFunc ListEachEntryFunc) (lastFileName string, err error) {
|
||||
if strings.HasSuffix(string(p), "/") && len(p) > 1 {
|
||||
|
||||
+114
-28
@@ -7,6 +7,8 @@ import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -19,6 +21,11 @@ import (
|
||||
// the prefetch count is derived from the -concurrentReaders option.
|
||||
const DefaultPrefetchCount = 4
|
||||
|
||||
// minReadConcurrency is the minimum number of parallel chunk fetches.
|
||||
// This ensures at least some parallelism even when prefetchCount is low,
|
||||
// improving throughput for reads spanning multiple chunks.
|
||||
const minReadConcurrency = 4
|
||||
|
||||
type ChunkReadAt struct {
|
||||
masterClient *wdclient.MasterClient
|
||||
chunkViews *IntervalList[*ChunkView]
|
||||
@@ -175,67 +182,139 @@ func (c *ChunkReadAt) ReadAtWithTime(ctx context.Context, p []byte, offset int64
|
||||
return c.doReadAt(ctx, p, offset)
|
||||
}
|
||||
|
||||
// chunkReadTask represents a single chunk read operation for parallel processing
|
||||
type chunkReadTask struct {
|
||||
chunk *ChunkView
|
||||
bufferStart int64 // start position in the output buffer
|
||||
bufferEnd int64 // end position in the output buffer
|
||||
chunkOffset uint64 // offset within the chunk to read from
|
||||
bytesRead int
|
||||
modifiedTsNs int64
|
||||
}
|
||||
|
||||
func (c *ChunkReadAt) doReadAt(ctx context.Context, p []byte, offset int64) (n int, ts int64, err error) {
|
||||
|
||||
// Collect all chunk read tasks
|
||||
var tasks []*chunkReadTask
|
||||
var gaps []struct{ start, length int64 } // gaps that need zero-filling
|
||||
|
||||
startOffset, remaining := offset, int64(len(p))
|
||||
var nextChunks *Interval[*ChunkView]
|
||||
var lastChunk *Interval[*ChunkView]
|
||||
|
||||
for x := c.chunkViews.Front(); x != nil; x = x.Next {
|
||||
chunk := x.Value
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if x.Next != nil {
|
||||
nextChunks = x.Next
|
||||
}
|
||||
lastChunk = x
|
||||
|
||||
// Handle gap before this chunk
|
||||
if startOffset < chunk.ViewOffset {
|
||||
gap := chunk.ViewOffset - startOffset
|
||||
glog.V(4).Infof("zero [%d,%d)", startOffset, chunk.ViewOffset)
|
||||
n += zero(p, startOffset-offset, gap)
|
||||
gaps = append(gaps, struct{ start, length int64 }{startOffset - offset, gap})
|
||||
startOffset, remaining = chunk.ViewOffset, remaining-gap
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// fmt.Printf(">>> doReadAt [%d,%d), chunk[%d,%d)\n", offset, offset+int64(len(p)), chunk.ViewOffset, chunk.ViewOffset+int64(chunk.ViewSize))
|
||||
|
||||
chunkStart, chunkStop := max(chunk.ViewOffset, startOffset), min(chunk.ViewOffset+int64(chunk.ViewSize), startOffset+remaining)
|
||||
if chunkStart >= chunkStop {
|
||||
continue
|
||||
}
|
||||
// glog.V(4).Infof("read [%d,%d), %d/%d chunk %s [%d,%d)", chunkStart, chunkStop, i, len(c.chunkViews), chunk.FileId, chunk.ViewOffset-chunk.Offset, chunk.ViewOffset-chunk.Offset+int64(chunk.ViewSize))
|
||||
bufferOffset := chunkStart - chunk.ViewOffset + chunk.OffsetInChunk
|
||||
ts = chunk.ModifiedTsNs
|
||||
copied, err := c.readChunkSliceAt(ctx, p[startOffset-offset:chunkStop-chunkStart+startOffset-offset], chunk, nextChunks, uint64(bufferOffset))
|
||||
if err != nil {
|
||||
glog.Errorf("fetching chunk %+v: %v\n", chunk, err)
|
||||
return copied, ts, err
|
||||
}
|
||||
|
||||
n += copied
|
||||
startOffset, remaining = startOffset+int64(copied), remaining-int64(copied)
|
||||
bufferOffset := chunkStart - chunk.ViewOffset + chunk.OffsetInChunk
|
||||
tasks = append(tasks, &chunkReadTask{
|
||||
chunk: chunk,
|
||||
bufferStart: startOffset - offset,
|
||||
bufferEnd: chunkStop - chunkStart + startOffset - offset,
|
||||
chunkOffset: uint64(bufferOffset),
|
||||
})
|
||||
|
||||
startOffset, remaining = chunkStop, remaining-(chunkStop-chunkStart)
|
||||
}
|
||||
|
||||
// glog.V(4).Infof("doReadAt [%d,%d), n:%v, err:%v", offset, offset+int64(len(p)), n, err)
|
||||
// Zero-fill gaps
|
||||
for _, gap := range gaps {
|
||||
glog.V(4).Infof("zero [%d,%d)", offset+gap.start, offset+gap.start+gap.length)
|
||||
n += zero(p, gap.start, gap.length)
|
||||
}
|
||||
|
||||
// zero the remaining bytes if a gap exists at the end of the last chunk (or a fully sparse file)
|
||||
if err == nil && remaining > 0 {
|
||||
// If only one chunk or random access mode, use sequential reading
|
||||
if len(tasks) <= 1 || c.readerPattern.IsRandomMode() {
|
||||
for _, task := range tasks {
|
||||
copied, readErr := c.readChunkSliceAt(ctx, p[task.bufferStart:task.bufferEnd], task.chunk, nil, task.chunkOffset)
|
||||
ts = max(ts, task.chunk.ModifiedTsNs)
|
||||
if readErr != nil {
|
||||
glog.Errorf("fetching chunk %+v: %v\n", task.chunk, readErr)
|
||||
return n + copied, ts, readErr
|
||||
}
|
||||
n += copied
|
||||
}
|
||||
} else {
|
||||
// Parallel chunk fetching for multiple chunks
|
||||
// This significantly improves throughput when chunks are on different volume servers
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// Limit concurrency to avoid overwhelming the system
|
||||
concurrency := c.prefetchCount
|
||||
if concurrency < minReadConcurrency {
|
||||
concurrency = minReadConcurrency
|
||||
}
|
||||
if concurrency > len(tasks) {
|
||||
concurrency = len(tasks)
|
||||
}
|
||||
g.SetLimit(concurrency)
|
||||
|
||||
for _, task := range tasks {
|
||||
g.Go(func() error {
|
||||
// Read directly into the correct position in the output buffer
|
||||
copied, readErr := c.readChunkSliceAtForParallel(gCtx, p[task.bufferStart:task.bufferEnd], task.chunk, task.chunkOffset)
|
||||
task.bytesRead = copied
|
||||
task.modifiedTsNs = task.chunk.ModifiedTsNs
|
||||
return readErr
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for all chunk reads to complete
|
||||
if waitErr := g.Wait(); waitErr != nil {
|
||||
err = waitErr
|
||||
}
|
||||
|
||||
// Aggregate results (order is preserved since we read directly into buffer positions)
|
||||
for _, task := range tasks {
|
||||
n += task.bytesRead
|
||||
ts = max(ts, task.modifiedTsNs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return n, ts, err
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger prefetch for sequential reads
|
||||
if lastChunk != nil && lastChunk.Next != nil && c.prefetchCount > 0 && !c.readerPattern.IsRandomMode() {
|
||||
c.readerCache.MaybeCache(lastChunk.Next, c.prefetchCount)
|
||||
}
|
||||
|
||||
// Zero the remaining bytes if a gap exists at the end
|
||||
if remaining > 0 {
|
||||
var delta int64
|
||||
if c.fileSize >= startOffset {
|
||||
delta = min(remaining, c.fileSize-startOffset)
|
||||
startOffset -= offset
|
||||
}
|
||||
if delta > 0 {
|
||||
glog.V(4).Infof("zero2 [%d,%d) of file size %d bytes", startOffset, startOffset+delta, c.fileSize)
|
||||
n += zero(p, startOffset, delta)
|
||||
bufStart := startOffset - offset
|
||||
if delta > 0 {
|
||||
glog.V(4).Infof("zero2 [%d,%d) of file size %d bytes", startOffset, startOffset+delta, c.fileSize)
|
||||
n += zero(p, bufStart, delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && offset+int64(len(p)) >= c.fileSize {
|
||||
err = io.EOF
|
||||
}
|
||||
// fmt.Printf("~~~ filled %d, err: %v\n\n", n, err)
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func (c *ChunkReadAt) readChunkSliceAt(ctx context.Context, buffer []byte, chunkView *ChunkView, nextChunkViews *Interval[*ChunkView], offset uint64) (n int, err error) {
|
||||
@@ -249,7 +328,7 @@ func (c *ChunkReadAt) readChunkSliceAt(ctx context.Context, buffer []byte, chunk
|
||||
}
|
||||
|
||||
shouldCache := (uint64(chunkView.ViewOffset) + chunkView.ChunkSize) <= c.readerCache.chunkCache.GetMaxFilePartSizeInCache()
|
||||
n, err = c.readerCache.ReadChunkAt(buffer, chunkView.FileId, chunkView.CipherKey, chunkView.IsGzipped, int64(offset), int(chunkView.ChunkSize), shouldCache)
|
||||
n, err = c.readerCache.ReadChunkAt(ctx, buffer, chunkView.FileId, chunkView.CipherKey, chunkView.IsGzipped, int64(offset), int(chunkView.ChunkSize), shouldCache)
|
||||
if c.lastChunkFid != chunkView.FileId {
|
||||
if chunkView.OffsetInChunk == 0 { // start of a new chunk
|
||||
if c.lastChunkFid != "" {
|
||||
@@ -266,6 +345,13 @@ func (c *ChunkReadAt) readChunkSliceAt(ctx context.Context, buffer []byte, chunk
|
||||
return
|
||||
}
|
||||
|
||||
// readChunkSliceAtForParallel is a simplified version for parallel chunk fetching
|
||||
// It doesn't update lastChunkFid or trigger prefetch (handled by the caller)
|
||||
func (c *ChunkReadAt) readChunkSliceAtForParallel(ctx context.Context, buffer []byte, chunkView *ChunkView, offset uint64) (n int, err error) {
|
||||
shouldCache := (uint64(chunkView.ViewOffset) + chunkView.ChunkSize) <= c.readerCache.chunkCache.GetMaxFilePartSizeInCache()
|
||||
return c.readerCache.ReadChunkAt(ctx, buffer, chunkView.FileId, chunkView.CipherKey, chunkView.IsGzipped, int64(offset), int(chunkView.ChunkSize), shouldCache)
|
||||
}
|
||||
|
||||
func zero(buffer []byte, start, length int64) int {
|
||||
if length <= 0 {
|
||||
return 0
|
||||
|
||||
+63
-23
@@ -35,6 +35,7 @@ type SingleChunkCacher struct {
|
||||
shouldCache bool
|
||||
wg sync.WaitGroup
|
||||
cacheStartedCh chan struct{}
|
||||
done chan struct{} // signals when download is complete
|
||||
}
|
||||
|
||||
func NewReaderCache(limit int, chunkCache chunk_cache.ChunkCache, lookupFileIdFn wdclient.LookupFileIdFunctionType) *ReaderCache {
|
||||
@@ -93,14 +94,18 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
|
||||
return
|
||||
}
|
||||
|
||||
func (rc *ReaderCache) ReadChunkAt(buffer []byte, fileId string, cipherKey []byte, isGzipped bool, offset int64, chunkSize int, shouldCache bool) (int, error) {
|
||||
func (rc *ReaderCache) ReadChunkAt(ctx context.Context, buffer []byte, fileId string, cipherKey []byte, isGzipped bool, offset int64, chunkSize int, shouldCache bool) (int, error) {
|
||||
rc.Lock()
|
||||
|
||||
if cacher, found := rc.downloaders[fileId]; found {
|
||||
if n, err := cacher.readChunkAt(buffer, offset); n != 0 && err == nil {
|
||||
rc.Unlock()
|
||||
rc.Unlock()
|
||||
n, err := cacher.readChunkAt(ctx, buffer, offset)
|
||||
if n > 0 || err != nil {
|
||||
return n, err
|
||||
}
|
||||
// If n=0 and err=nil, the cacher couldn't provide data for this offset.
|
||||
// Fall through to try chunkCache.
|
||||
rc.Lock()
|
||||
}
|
||||
if shouldCache || rc.lookupFileIdFn == nil {
|
||||
n, err := rc.chunkCache.ReadChunkAt(buffer, fileId, uint64(offset))
|
||||
@@ -134,7 +139,7 @@ func (rc *ReaderCache) ReadChunkAt(buffer []byte, fileId string, cipherKey []byt
|
||||
rc.downloaders[fileId] = cacher
|
||||
rc.Unlock()
|
||||
|
||||
return cacher.readChunkAt(buffer, offset)
|
||||
return cacher.readChunkAt(ctx, buffer, offset)
|
||||
}
|
||||
|
||||
func (rc *ReaderCache) UnCache(fileId string) {
|
||||
@@ -166,38 +171,53 @@ func newSingleChunkCacher(parent *ReaderCache, fileId string, cipherKey []byte,
|
||||
chunkSize: chunkSize,
|
||||
shouldCache: shouldCache,
|
||||
cacheStartedCh: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// startCaching downloads the chunk data in the background.
|
||||
// It does NOT hold the lock during the HTTP download to allow concurrent readers
|
||||
// to wait efficiently using the done channel.
|
||||
func (s *SingleChunkCacher) startCaching() {
|
||||
s.wg.Add(1)
|
||||
defer s.wg.Done()
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
defer close(s.done) // guarantee completion signal even on panic
|
||||
|
||||
s.cacheStartedCh <- struct{}{} // means this has been started
|
||||
s.cacheStartedCh <- struct{}{} // signal that we've started
|
||||
|
||||
// Note: We intentionally use context.Background() here, NOT a request-specific context.
|
||||
// The downloaded chunk is a shared resource - multiple concurrent readers may be waiting
|
||||
// for this same download to complete. If we used a request context and that request was
|
||||
// cancelled, it would abort the download and cause errors for all other waiting readers.
|
||||
// The download should always complete once started to serve all potential consumers.
|
||||
|
||||
// Lookup file ID without holding the lock
|
||||
urlStrings, err := s.parent.lookupFileIdFn(context.Background(), s.chunkFileId)
|
||||
if err != nil {
|
||||
s.Lock()
|
||||
s.err = fmt.Errorf("operation LookupFileId %s failed, err: %v", s.chunkFileId, err)
|
||||
s.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.data = mem.Allocate(s.chunkSize)
|
||||
// Allocate buffer and download without holding the lock
|
||||
// This allows multiple downloads to proceed in parallel
|
||||
data := mem.Allocate(s.chunkSize)
|
||||
_, fetchErr := util_http.RetriedFetchChunkData(context.Background(), data, urlStrings, s.cipherKey, s.isGzipped, true, 0, s.chunkFileId)
|
||||
|
||||
_, s.err = util_http.RetriedFetchChunkData(context.Background(), s.data, urlStrings, s.cipherKey, s.isGzipped, true, 0, s.chunkFileId)
|
||||
if s.err != nil {
|
||||
mem.Free(s.data)
|
||||
s.data = nil
|
||||
return
|
||||
// Now acquire lock to update state
|
||||
s.Lock()
|
||||
if fetchErr != nil {
|
||||
mem.Free(data)
|
||||
s.err = fetchErr
|
||||
} else {
|
||||
s.data = data
|
||||
if s.shouldCache {
|
||||
s.parent.chunkCache.SetChunk(s.chunkFileId, s.data)
|
||||
}
|
||||
atomic.StoreInt64(&s.completedTimeNew, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
if s.shouldCache {
|
||||
s.parent.chunkCache.SetChunk(s.chunkFileId, s.data)
|
||||
}
|
||||
atomic.StoreInt64(&s.completedTimeNew, time.Now().UnixNano())
|
||||
|
||||
return
|
||||
s.Unlock()
|
||||
}
|
||||
|
||||
func (s *SingleChunkCacher) destroy() {
|
||||
@@ -209,13 +229,34 @@ func (s *SingleChunkCacher) destroy() {
|
||||
if s.data != nil {
|
||||
mem.Free(s.data)
|
||||
s.data = nil
|
||||
close(s.cacheStartedCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SingleChunkCacher) readChunkAt(buf []byte, offset int64) (int, error) {
|
||||
// readChunkAt reads data from the cached chunk.
|
||||
// It waits for the download to complete if it's still in progress.
|
||||
// The ctx parameter allows the reader to cancel its wait (but the download continues
|
||||
// for other readers - see comment in startCaching about shared resource semantics).
|
||||
func (s *SingleChunkCacher) readChunkAt(ctx context.Context, buf []byte, offset int64) (int, error) {
|
||||
s.wg.Add(1)
|
||||
defer s.wg.Done()
|
||||
|
||||
// Wait for download to complete, but allow reader cancellation.
|
||||
// Prioritize checking done first - if data is already available,
|
||||
// return it even if context is also cancelled.
|
||||
select {
|
||||
case <-s.done:
|
||||
// Download already completed, proceed immediately
|
||||
default:
|
||||
// Download not complete, wait for it or context cancellation
|
||||
select {
|
||||
case <-s.done:
|
||||
// Download completed
|
||||
case <-ctx.Done():
|
||||
// Reader cancelled while waiting - download continues for other readers
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
@@ -228,5 +269,4 @@ func (s *SingleChunkCacher) readChunkAt(buf []byte, offset int64) (int, error) {
|
||||
}
|
||||
|
||||
return copy(buf, s.data[offset:]), nil
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -25,20 +25,24 @@ func (store *RedisCluster2Store) Initialize(configuration util.Configuration, pr
|
||||
|
||||
return store.initialize(
|
||||
configuration.GetStringSlice(prefix+"addresses"),
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
configuration.GetBool(prefix+"useReadOnly"),
|
||||
configuration.GetBool(prefix+"routeByLatency"),
|
||||
configuration.GetStringSlice(prefix+"superLargeDirectories"),
|
||||
)
|
||||
}
|
||||
|
||||
func (store *RedisCluster2Store) initialize(addresses []string, password string, readOnly, routeByLatency bool, superLargeDirectories []string) (err error) {
|
||||
func (store *RedisCluster2Store) initialize(addresses []string, username string, password string, keyPrefix string, readOnly, routeByLatency bool, superLargeDirectories []string) (err error) {
|
||||
store.Client = redis.NewClusterClient(&redis.ClusterOptions{
|
||||
Addrs: addresses,
|
||||
Username: username,
|
||||
Password: password,
|
||||
ReadOnly: readOnly,
|
||||
RouteByLatency: routeByLatency,
|
||||
})
|
||||
store.keyPrefix = keyPrefix
|
||||
store.loadSuperLargeDirectories(superLargeDirectories)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ func (store *Redis2SentinelStore) Initialize(configuration util.Configuration, p
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetInt(prefix+"database"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
)
|
||||
}
|
||||
|
||||
func (store *Redis2SentinelStore) initialize(addresses []string, masterName string, username string, password string, database int) (err error) {
|
||||
func (store *Redis2SentinelStore) initialize(addresses []string, masterName string, username string, password string, database int, keyPrefix string) (err error) {
|
||||
store.Client = redis.NewFailoverClient(&redis.FailoverOptions{
|
||||
MasterName: masterName,
|
||||
SentinelAddrs: addresses,
|
||||
@@ -41,5 +42,6 @@ func (store *Redis2SentinelStore) initialize(addresses []string, masterName stri
|
||||
ReadTimeout: time.Second * 30,
|
||||
WriteTimeout: time.Second * 5,
|
||||
})
|
||||
store.keyPrefix = keyPrefix
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ func (store *Redis2Store) GetName() string {
|
||||
func (store *Redis2Store) Initialize(configuration util.Configuration, prefix string) (err error) {
|
||||
return store.initialize(
|
||||
configuration.GetString(prefix+"address"),
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetInt(prefix+"database"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
configuration.GetStringSlice(prefix+"superLargeDirectories"),
|
||||
configuration.GetBool(prefix+"enable_mtls"),
|
||||
configuration.GetString(prefix+"ca_cert_path"),
|
||||
@@ -37,7 +39,13 @@ func (store *Redis2Store) Initialize(configuration util.Configuration, prefix st
|
||||
)
|
||||
}
|
||||
|
||||
func (store *Redis2Store) initialize(hostPort string, password string, database int, superLargeDirectories []string, enableMtls bool, caCertPath string, clientCertPath string, clientKeyPath string) (err error) {
|
||||
func (store *Redis2Store) initialize(hostPort string, username string, password string, database int, keyPrefix string, superLargeDirectories []string, enableMtls bool, caCertPath string, clientCertPath string, clientKeyPath string) (err error) {
|
||||
opt := &redis.Options{
|
||||
Addr: hostPort,
|
||||
Username: username,
|
||||
Password: password,
|
||||
DB: database,
|
||||
}
|
||||
if enableMtls {
|
||||
clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
||||
if err != nil {
|
||||
@@ -59,25 +67,15 @@ func (store *Redis2Store) initialize(hostPort string, password string, database
|
||||
glog.Fatalf("Error parsing redis host and port from %s: %v", hostPort, err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
opt.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
RootCAs: caCertPool,
|
||||
ServerName: redisHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
store.Client = redis.NewClient(&redis.Options{
|
||||
Addr: hostPort,
|
||||
Password: password,
|
||||
DB: database,
|
||||
TLSConfig: tlsConfig,
|
||||
})
|
||||
} else {
|
||||
store.Client = redis.NewClient(&redis.Options{
|
||||
Addr: hostPort,
|
||||
Password: password,
|
||||
DB: database,
|
||||
})
|
||||
}
|
||||
store.Client = redis.NewClient(opt)
|
||||
store.keyPrefix = keyPrefix
|
||||
store.loadSuperLargeDirectories(superLargeDirectories)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
|
||||
type UniversalRedis2Store struct {
|
||||
Client redis.UniversalClient
|
||||
keyPrefix string
|
||||
superLargeDirectoryHash map[string]bool
|
||||
}
|
||||
|
||||
@@ -35,6 +36,13 @@ func (store *UniversalRedis2Store) loadSuperLargeDirectories(superLargeDirectori
|
||||
}
|
||||
}
|
||||
|
||||
func (store *UniversalRedis2Store) getKey(key string) string {
|
||||
if store.keyPrefix == "" {
|
||||
return key
|
||||
}
|
||||
return store.keyPrefix + key
|
||||
}
|
||||
|
||||
func (store *UniversalRedis2Store) BeginTransaction(ctx context.Context) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
@@ -57,7 +65,7 @@ func (store *UniversalRedis2Store) InsertEntry(ctx context.Context, entry *filer
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
if err = store.Client.ZAddNX(ctx, genDirectoryListKey(dir), redis.Z{Score: 0, Member: name}).Err(); err != nil {
|
||||
if err = store.Client.ZAddNX(ctx, store.getKey(genDirectoryListKey(dir)), redis.Z{Score: 0, Member: name}).Err(); err != nil {
|
||||
return fmt.Errorf("persisting %s in parent dir: %v", entry.FullPath, err)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +83,7 @@ func (store *UniversalRedis2Store) doInsertEntry(ctx context.Context, entry *fil
|
||||
value = util.MaybeGzipData(value)
|
||||
}
|
||||
|
||||
if err = store.Client.Set(ctx, string(entry.FullPath), value, time.Duration(entry.TtlSec)*time.Second).Err(); err != nil {
|
||||
if err = store.Client.Set(ctx, store.getKey(string(entry.FullPath)), value, time.Duration(entry.TtlSec)*time.Second).Err(); err != nil {
|
||||
return fmt.Errorf("persisting %s : %v", entry.FullPath, err)
|
||||
}
|
||||
return nil
|
||||
@@ -88,7 +96,7 @@ func (store *UniversalRedis2Store) UpdateEntry(ctx context.Context, entry *filer
|
||||
|
||||
func (store *UniversalRedis2Store) FindEntry(ctx context.Context, fullpath util.FullPath) (entry *filer.Entry, err error) {
|
||||
|
||||
data, err := store.Client.Get(ctx, string(fullpath)).Result()
|
||||
data, err := store.Client.Get(ctx, store.getKey(string(fullpath))).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
}
|
||||
@@ -110,12 +118,12 @@ func (store *UniversalRedis2Store) FindEntry(ctx context.Context, fullpath util.
|
||||
|
||||
func (store *UniversalRedis2Store) DeleteEntry(ctx context.Context, fullpath util.FullPath) (err error) {
|
||||
|
||||
_, err = store.Client.Del(ctx, genDirectoryListKey(string(fullpath))).Result()
|
||||
_, err = store.Client.Del(ctx, store.getKey(genDirectoryListKey(string(fullpath)))).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete dir list %s : %v", fullpath, err)
|
||||
}
|
||||
|
||||
_, err = store.Client.Del(ctx, string(fullpath)).Result()
|
||||
_, err = store.Client.Del(ctx, store.getKey(string(fullpath))).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete %s : %v", fullpath, err)
|
||||
}
|
||||
@@ -125,7 +133,7 @@ func (store *UniversalRedis2Store) DeleteEntry(ctx context.Context, fullpath uti
|
||||
return nil
|
||||
}
|
||||
if name != "" {
|
||||
_, err = store.Client.ZRem(ctx, genDirectoryListKey(dir), name).Result()
|
||||
_, err = store.Client.ZRem(ctx, store.getKey(genDirectoryListKey(dir)), name).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteEntry %s in parent dir: %v", fullpath, err)
|
||||
}
|
||||
@@ -140,7 +148,7 @@ func (store *UniversalRedis2Store) DeleteFolderChildren(ctx context.Context, ful
|
||||
return nil
|
||||
}
|
||||
|
||||
members, err := store.Client.ZRangeByLex(ctx, genDirectoryListKey(string(fullpath)), &redis.ZRangeBy{
|
||||
members, err := store.Client.ZRangeByLex(ctx, store.getKey(genDirectoryListKey(string(fullpath))), &redis.ZRangeBy{
|
||||
Min: "-",
|
||||
Max: "+",
|
||||
}).Result()
|
||||
@@ -150,12 +158,12 @@ func (store *UniversalRedis2Store) DeleteFolderChildren(ctx context.Context, ful
|
||||
|
||||
for _, fileName := range members {
|
||||
path := util.NewFullPath(string(fullpath), fileName)
|
||||
_, err = store.Client.Del(ctx, string(path)).Result()
|
||||
_, err = store.Client.Del(ctx, store.getKey(string(path))).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteFolderChildren %s in parent dir: %v", fullpath, err)
|
||||
}
|
||||
// not efficient, but need to remove if it is a directory
|
||||
store.Client.Del(ctx, genDirectoryListKey(string(path)))
|
||||
store.Client.Del(ctx, store.getKey(genDirectoryListKey(string(path))))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -167,7 +175,7 @@ func (store *UniversalRedis2Store) ListDirectoryPrefixedEntries(ctx context.Cont
|
||||
|
||||
func (store *UniversalRedis2Store) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc filer.ListEachEntryFunc) (lastFileName string, err error) {
|
||||
|
||||
dirListKey := genDirectoryListKey(string(dirPath))
|
||||
dirListKey := store.getKey(genDirectoryListKey(string(dirPath)))
|
||||
|
||||
min := "-"
|
||||
if startFileName != "" {
|
||||
@@ -201,7 +209,7 @@ func (store *UniversalRedis2Store) ListDirectoryEntries(ctx context.Context, dir
|
||||
} else {
|
||||
if entry.TtlSec > 0 {
|
||||
if entry.Attr.Crtime.Add(time.Duration(entry.TtlSec) * time.Second).Before(time.Now()) {
|
||||
store.Client.Del(ctx, string(path)).Result()
|
||||
store.Client.Del(ctx, store.getKey(string(path))).Result()
|
||||
store.Client.ZRem(ctx, dirListKey, fileName).Result()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -25,20 +25,24 @@ func (store *RedisLuaClusterStore) Initialize(configuration util.Configuration,
|
||||
|
||||
return store.initialize(
|
||||
configuration.GetStringSlice(prefix+"addresses"),
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
configuration.GetBool(prefix+"useReadOnly"),
|
||||
configuration.GetBool(prefix+"routeByLatency"),
|
||||
configuration.GetStringSlice(prefix+"superLargeDirectories"),
|
||||
)
|
||||
}
|
||||
|
||||
func (store *RedisLuaClusterStore) initialize(addresses []string, password string, readOnly, routeByLatency bool, superLargeDirectories []string) (err error) {
|
||||
func (store *RedisLuaClusterStore) initialize(addresses []string, username string, password string, keyPrefix string, readOnly, routeByLatency bool, superLargeDirectories []string) (err error) {
|
||||
store.Client = redis.NewClusterClient(&redis.ClusterOptions{
|
||||
Addrs: addresses,
|
||||
Username: username,
|
||||
Password: password,
|
||||
ReadOnly: readOnly,
|
||||
RouteByLatency: routeByLatency,
|
||||
})
|
||||
store.keyPrefix = keyPrefix
|
||||
store.loadSuperLargeDirectories(superLargeDirectories)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ func (store *RedisLuaSentinelStore) Initialize(configuration util.Configuration,
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetInt(prefix+"database"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
)
|
||||
}
|
||||
|
||||
func (store *RedisLuaSentinelStore) initialize(addresses []string, masterName string, username string, password string, database int) (err error) {
|
||||
func (store *RedisLuaSentinelStore) initialize(addresses []string, masterName string, username string, password string, database int, keyPrefix string) (err error) {
|
||||
store.Client = redis.NewFailoverClient(&redis.FailoverOptions{
|
||||
MasterName: masterName,
|
||||
SentinelAddrs: addresses,
|
||||
@@ -41,5 +42,6 @@ func (store *RedisLuaSentinelStore) initialize(addresses []string, masterName st
|
||||
ReadTimeout: time.Second * 30,
|
||||
WriteTimeout: time.Second * 5,
|
||||
})
|
||||
store.keyPrefix = keyPrefix
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,18 +21,22 @@ func (store *RedisLuaStore) GetName() string {
|
||||
func (store *RedisLuaStore) Initialize(configuration util.Configuration, prefix string) (err error) {
|
||||
return store.initialize(
|
||||
configuration.GetString(prefix+"address"),
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetInt(prefix+"database"),
|
||||
configuration.GetString(prefix+"keyPrefix"),
|
||||
configuration.GetStringSlice(prefix+"superLargeDirectories"),
|
||||
)
|
||||
}
|
||||
|
||||
func (store *RedisLuaStore) initialize(hostPort string, password string, database int, superLargeDirectories []string) (err error) {
|
||||
func (store *RedisLuaStore) initialize(hostPort string, username string, password string, database int, keyPrefix string, superLargeDirectories []string) (err error) {
|
||||
store.Client = redis.NewClient(&redis.Options{
|
||||
Addr: hostPort,
|
||||
Username: username,
|
||||
Password: password,
|
||||
DB: database,
|
||||
})
|
||||
store.keyPrefix = keyPrefix
|
||||
store.loadSuperLargeDirectories(superLargeDirectories)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
|
||||
type UniversalRedisLuaStore struct {
|
||||
Client redis.UniversalClient
|
||||
keyPrefix string
|
||||
superLargeDirectoryHash map[string]bool
|
||||
}
|
||||
|
||||
@@ -36,6 +37,13 @@ func (store *UniversalRedisLuaStore) loadSuperLargeDirectories(superLargeDirecto
|
||||
}
|
||||
}
|
||||
|
||||
func (store *UniversalRedisLuaStore) getKey(key string) string {
|
||||
if store.keyPrefix == "" {
|
||||
return key
|
||||
}
|
||||
return store.keyPrefix + key
|
||||
}
|
||||
|
||||
func (store *UniversalRedisLuaStore) BeginTransaction(ctx context.Context) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
@@ -60,7 +68,7 @@ func (store *UniversalRedisLuaStore) InsertEntry(ctx context.Context, entry *fil
|
||||
dir, name := entry.FullPath.DirAndName()
|
||||
|
||||
err = stored_procedure.InsertEntryScript.Run(ctx, store.Client,
|
||||
[]string{string(entry.FullPath), genDirectoryListKey(dir)},
|
||||
[]string{store.getKey(string(entry.FullPath)), store.getKey(genDirectoryListKey(dir))},
|
||||
value, entry.TtlSec,
|
||||
store.isSuperLargeDirectory(dir), 0, name).Err()
|
||||
|
||||
@@ -78,7 +86,7 @@ func (store *UniversalRedisLuaStore) UpdateEntry(ctx context.Context, entry *fil
|
||||
|
||||
func (store *UniversalRedisLuaStore) FindEntry(ctx context.Context, fullpath util.FullPath) (entry *filer.Entry, err error) {
|
||||
|
||||
data, err := store.Client.Get(ctx, string(fullpath)).Result()
|
||||
data, err := store.Client.Get(ctx, store.getKey(string(fullpath))).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
}
|
||||
@@ -103,7 +111,7 @@ func (store *UniversalRedisLuaStore) DeleteEntry(ctx context.Context, fullpath u
|
||||
dir, name := fullpath.DirAndName()
|
||||
|
||||
err = stored_procedure.DeleteEntryScript.Run(ctx, store.Client,
|
||||
[]string{string(fullpath), genDirectoryListKey(string(fullpath)), genDirectoryListKey(dir)},
|
||||
[]string{store.getKey(string(fullpath)), store.getKey(genDirectoryListKey(string(fullpath))), store.getKey(genDirectoryListKey(dir))},
|
||||
store.isSuperLargeDirectory(dir), name).Err()
|
||||
|
||||
if err != nil {
|
||||
@@ -120,7 +128,7 @@ func (store *UniversalRedisLuaStore) DeleteFolderChildren(ctx context.Context, f
|
||||
}
|
||||
|
||||
err = stored_procedure.DeleteFolderChildrenScript.Run(ctx, store.Client,
|
||||
[]string{string(fullpath)}).Err()
|
||||
[]string{store.getKey(string(fullpath))}).Err()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteFolderChildren %s : %v", fullpath, err)
|
||||
@@ -135,7 +143,7 @@ func (store *UniversalRedisLuaStore) ListDirectoryPrefixedEntries(ctx context.Co
|
||||
|
||||
func (store *UniversalRedisLuaStore) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc filer.ListEachEntryFunc) (lastFileName string, err error) {
|
||||
|
||||
dirListKey := genDirectoryListKey(string(dirPath))
|
||||
dirListKey := store.getKey(genDirectoryListKey(string(dirPath)))
|
||||
|
||||
min := "-"
|
||||
if startFileName != "" {
|
||||
|
||||
@@ -90,10 +90,9 @@ func (uploadResult *UploadResult) ToPbFileChunkWithSSE(fileId string, offset int
|
||||
}
|
||||
|
||||
var (
|
||||
fileNameEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", "")
|
||||
uploader *Uploader
|
||||
uploaderErr error
|
||||
once sync.Once
|
||||
uploader *Uploader
|
||||
uploaderErr error
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// HTTPClient interface for testing
|
||||
@@ -336,8 +335,9 @@ func (uploader *Uploader) upload_content(ctx context.Context, fillBufferFunction
|
||||
body_writer = multipart.NewWriter(option.BytesBuffer)
|
||||
}
|
||||
h := make(textproto.MIMEHeader)
|
||||
filename := fileNameEscaper.Replace(option.Filename)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
||||
// Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition,
|
||||
// properly handling non-ASCII characters and special characters
|
||||
h.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": option.Filename}))
|
||||
h.Set("Idempotency-Key", option.UploadUrl)
|
||||
if option.MimeType == "" {
|
||||
option.MimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(option.Filename)))
|
||||
|
||||
@@ -81,6 +81,7 @@ message Heartbeat {
|
||||
map<string, uint32> max_volume_counts = 4;
|
||||
uint32 grpc_port = 20;
|
||||
repeated string location_uuids = 21;
|
||||
string id = 22; // volume server id, independent of ip:port for stable identification
|
||||
}
|
||||
|
||||
message HeartbeatResponse {
|
||||
@@ -289,6 +290,7 @@ message DataNodeInfo {
|
||||
string id = 1;
|
||||
map<string, DiskInfo> diskInfos = 2;
|
||||
uint32 grpc_port = 3;
|
||||
string address = 4; // ip:port for connecting to the volume server
|
||||
}
|
||||
message RackInfo {
|
||||
string id = 1;
|
||||
|
||||
@@ -44,6 +44,7 @@ type Heartbeat struct {
|
||||
MaxVolumeCounts map[string]uint32 `protobuf:"bytes,4,rep,name=max_volume_counts,json=maxVolumeCounts,proto3" json:"max_volume_counts,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"`
|
||||
GrpcPort uint32 `protobuf:"varint,20,opt,name=grpc_port,json=grpcPort,proto3" json:"grpc_port,omitempty"`
|
||||
LocationUuids []string `protobuf:"bytes,21,rep,name=location_uuids,json=locationUuids,proto3" json:"location_uuids,omitempty"`
|
||||
Id string `protobuf:"bytes,22,opt,name=id,proto3" json:"id,omitempty"` // volume server id, independent of ip:port for stable identification
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -204,6 +205,13 @@ func (x *Heartbeat) GetLocationUuids() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Heartbeat) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type HeartbeatResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
VolumeSizeLimit uint64 `protobuf:"varint,1,opt,name=volume_size_limit,json=volumeSizeLimit,proto3" json:"volume_size_limit,omitempty"`
|
||||
@@ -2039,6 +2047,7 @@ type DataNodeInfo struct {
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
DiskInfos map[string]*DiskInfo `protobuf:"bytes,2,rep,name=diskInfos,proto3" json:"diskInfos,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
GrpcPort uint32 `protobuf:"varint,3,opt,name=grpc_port,json=grpcPort,proto3" json:"grpc_port,omitempty"`
|
||||
Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` // ip:port for connecting to the volume server
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2094,6 +2103,13 @@ func (x *DataNodeInfo) GetGrpcPort() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DataNodeInfo) GetAddress() string {
|
||||
if x != nil {
|
||||
return x.Address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RackInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -4038,7 +4054,7 @@ var File_master_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_master_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\fmaster.proto\x12\tmaster_pb\"\xc0\a\n" +
|
||||
"\fmaster.proto\x12\tmaster_pb\"\xd0\a\n" +
|
||||
"\tHeartbeat\x12\x0e\n" +
|
||||
"\x02ip\x18\x01 \x01(\tR\x02ip\x12\x12\n" +
|
||||
"\x04port\x18\x02 \x01(\rR\x04port\x12\x1d\n" +
|
||||
@@ -4063,7 +4079,8 @@ const file_master_proto_rawDesc = "" +
|
||||
"\x10has_no_ec_shards\x18\x13 \x01(\bR\rhasNoEcShards\x12U\n" +
|
||||
"\x11max_volume_counts\x18\x04 \x03(\v2).master_pb.Heartbeat.MaxVolumeCountsEntryR\x0fmaxVolumeCounts\x12\x1b\n" +
|
||||
"\tgrpc_port\x18\x14 \x01(\rR\bgrpcPort\x12%\n" +
|
||||
"\x0elocation_uuids\x18\x15 \x03(\tR\rlocationUuids\x1aB\n" +
|
||||
"\x0elocation_uuids\x18\x15 \x03(\tR\rlocationUuids\x12\x0e\n" +
|
||||
"\x02id\x18\x16 \x01(\tR\x02id\x1aB\n" +
|
||||
"\x14MaxVolumeCountsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\rR\x05value:\x028\x01\"\xcd\x02\n" +
|
||||
@@ -4254,11 +4271,12 @@ const file_master_proto_rawDesc = "" +
|
||||
"\fvolume_infos\x18\x06 \x03(\v2#.master_pb.VolumeInformationMessageR\vvolumeInfos\x12P\n" +
|
||||
"\x0eec_shard_infos\x18\a \x03(\v2*.master_pb.VolumeEcShardInformationMessageR\fecShardInfos\x12.\n" +
|
||||
"\x13remote_volume_count\x18\b \x01(\x03R\x11remoteVolumeCount\x12\x17\n" +
|
||||
"\adisk_id\x18\t \x01(\rR\x06diskId\"\xd4\x01\n" +
|
||||
"\adisk_id\x18\t \x01(\rR\x06diskId\"\xee\x01\n" +
|
||||
"\fDataNodeInfo\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12D\n" +
|
||||
"\tdiskInfos\x18\x02 \x03(\v2&.master_pb.DataNodeInfo.DiskInfosEntryR\tdiskInfos\x12\x1b\n" +
|
||||
"\tgrpc_port\x18\x03 \x01(\rR\bgrpcPort\x1aQ\n" +
|
||||
"\tgrpc_port\x18\x03 \x01(\rR\bgrpcPort\x12\x18\n" +
|
||||
"\aaddress\x18\x04 \x01(\tR\aaddress\x1aQ\n" +
|
||||
"\x0eDiskInfosEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12)\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x13.master_pb.DiskInfoR\x05value:\x028\x01\"\xf0\x01\n" +
|
||||
|
||||
@@ -2,11 +2,12 @@ package pb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
type ServerAddress string
|
||||
@@ -32,7 +33,12 @@ func NewServerAddressWithGrpcPort(address string, grpcPort int) ServerAddress {
|
||||
}
|
||||
|
||||
func NewServerAddressFromDataNode(dn *master_pb.DataNodeInfo) ServerAddress {
|
||||
return NewServerAddressWithGrpcPort(dn.Id, int(dn.GrpcPort))
|
||||
// Use Address field if available (new behavior), fall back to Id for backward compatibility
|
||||
addr := dn.Address
|
||||
if addr == "" {
|
||||
addr = dn.Id // backward compatibility: old nodes use ip:port as id
|
||||
}
|
||||
return NewServerAddressWithGrpcPort(addr, int(dn.GrpcPort))
|
||||
}
|
||||
|
||||
func NewServerAddressFromLocation(dn *master_pb.Location) ServerAddress {
|
||||
|
||||
@@ -40,6 +40,7 @@ type IdentityAccessManagement struct {
|
||||
|
||||
identities []*Identity
|
||||
accessKeyIdent map[string]*Identity
|
||||
nameToIdentity map[string]*Identity // O(1) lookup by identity name
|
||||
accounts map[string]*Account
|
||||
emailAccount map[string]*Account
|
||||
hashes map[string]*sync.Pool
|
||||
@@ -219,6 +220,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto
|
||||
if len(iam.identities) == 0 {
|
||||
iam.identities = []*Identity{envIdentity}
|
||||
iam.accessKeyIdent = map[string]*Identity{accessKeyId: envIdentity}
|
||||
iam.nameToIdentity = map[string]*Identity{envIdentity.Name: envIdentity}
|
||||
iam.isAuthEnabled = true
|
||||
}
|
||||
iam.m.Unlock()
|
||||
@@ -270,6 +272,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
var identities []*Identity
|
||||
var identityAnonymous *Identity
|
||||
accessKeyIdent := make(map[string]*Identity)
|
||||
nameToIdentity := make(map[string]*Identity)
|
||||
accounts := make(map[string]*Account)
|
||||
emailAccount := make(map[string]*Account)
|
||||
foundAccountAdmin := false
|
||||
@@ -348,6 +351,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
accessKeyIdent[cred.AccessKey] = t
|
||||
}
|
||||
identities = append(identities, t)
|
||||
nameToIdentity[t.Name] = t
|
||||
}
|
||||
|
||||
iam.m.Lock()
|
||||
@@ -357,6 +361,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
iam.accounts = accounts
|
||||
iam.emailAccount = emailAccount
|
||||
iam.accessKeyIdent = accessKeyIdent
|
||||
iam.nameToIdentity = nameToIdentity
|
||||
if !iam.isAuthEnabled { // one-directional, no toggling
|
||||
iam.isAuthEnabled = len(identities) > 0
|
||||
}
|
||||
@@ -365,7 +370,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
// Log configuration summary
|
||||
glog.V(1).Infof("Loaded %d identities, %d accounts, %d access keys. Auth enabled: %v",
|
||||
len(identities), len(accounts), len(accessKeyIdent), iam.isAuthEnabled)
|
||||
|
||||
|
||||
if glog.V(2) {
|
||||
glog.V(2).Infof("Access key to identity mapping:")
|
||||
for accessKey, identity := range accessKeyIdent {
|
||||
@@ -383,29 +388,42 @@ func (iam *IdentityAccessManagement) isEnabled() bool {
|
||||
func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identity *Identity, cred *Credential, found bool) {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
|
||||
glog.V(3).Infof("Looking up access key: %s (total keys registered: %d)", accessKey, len(iam.accessKeyIdent))
|
||||
|
||||
|
||||
// Helper function to truncate access key for logging to avoid credential exposure
|
||||
truncate := func(key string) string {
|
||||
const mask = "***"
|
||||
if len(key) > 4 {
|
||||
return key[:4] + mask
|
||||
}
|
||||
// For very short keys, never log the full key
|
||||
return mask
|
||||
}
|
||||
|
||||
truncatedKey := truncate(accessKey)
|
||||
|
||||
glog.V(3).Infof("Looking up access key: %s (len=%d, total keys registered: %d)",
|
||||
truncatedKey, len(accessKey), len(iam.accessKeyIdent))
|
||||
|
||||
if ident, ok := iam.accessKeyIdent[accessKey]; ok {
|
||||
for _, credential := range ident.Credentials {
|
||||
if credential.AccessKey == accessKey {
|
||||
glog.V(2).Infof("Found access key %s for identity %s", accessKey, ident.Name)
|
||||
glog.V(2).Infof("Found access key %s for identity %s", truncatedKey, ident.Name)
|
||||
return ident, credential, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Could not find access key %s. Available keys: %d, Auth enabled: %v",
|
||||
accessKey, len(iam.accessKeyIdent), iam.isAuthEnabled)
|
||||
|
||||
|
||||
glog.V(2).Infof("Could not find access key %s (len=%d). Available keys: %d, Auth enabled: %v",
|
||||
truncatedKey, len(accessKey), len(iam.accessKeyIdent), iam.isAuthEnabled)
|
||||
|
||||
// Log all registered access keys at higher verbosity for debugging
|
||||
if glog.V(3) {
|
||||
glog.V(3).Infof("Registered access keys:")
|
||||
for key := range iam.accessKeyIdent {
|
||||
glog.V(3).Infof(" - %s", key)
|
||||
glog.V(3).Infof(" - %s (len=%d)", truncate(key), len(key))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
@@ -418,6 +436,13 @@ func (iam *IdentityAccessManagement) lookupAnonymous() (identity *Identity, foun
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) lookupByIdentityName(name string) *Identity {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
|
||||
return iam.nameToIdentity[name]
|
||||
}
|
||||
|
||||
// generatePrincipalArn generates an ARN for a user identity
|
||||
func generatePrincipalArn(identityName string) string {
|
||||
// Handle special cases
|
||||
@@ -463,6 +488,9 @@ func (iam *IdentityAccessManagement) Auth(f http.HandlerFunc, action Action) htt
|
||||
// Store the authenticated identity in request context (secure, cannot be spoofed)
|
||||
if identity != nil && identity.Name != "" {
|
||||
ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name)
|
||||
// Also store the full identity object for handlers that need it (e.g., ListBuckets)
|
||||
// This is especially important for JWT users whose identity is not in the identities list
|
||||
ctx = s3_constants.SetIdentityInContext(ctx, identity)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
f(w, r)
|
||||
@@ -689,14 +717,14 @@ func (iam *IdentityAccessManagement) GetCredentialManager() *credential.Credenti
|
||||
// LoadS3ApiConfigurationFromCredentialManager loads configuration using the credential manager
|
||||
func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromCredentialManager() error {
|
||||
glog.V(1).Infof("Loading S3 API configuration from credential manager")
|
||||
|
||||
|
||||
s3ApiConfiguration, err := iam.credentialManager.LoadConfiguration(context.Background())
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to load configuration from credential manager: %v", err)
|
||||
return fmt.Errorf("failed to load configuration from credential manager: %w", err)
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Credential manager returned %d identities and %d accounts",
|
||||
glog.V(2).Infof("Credential manager returned %d identities and %d accounts",
|
||||
len(s3ApiConfiguration.Identities), len(s3ApiConfiguration.Accounts))
|
||||
|
||||
if err := iam.loadS3ApiConfiguration(s3ApiConfiguration); err != nil {
|
||||
|
||||
@@ -53,10 +53,11 @@ func (iam *IdentityAccessManagement) reqSignatureV4Verify(r *http.Request) (*Ide
|
||||
|
||||
// Constants specific to this file
|
||||
const (
|
||||
emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
streamingContentSHA256 = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
streamingUnsignedPayload = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"
|
||||
unsignedPayload = "UNSIGNED-PAYLOAD"
|
||||
emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
streamingContentSHA256 = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
streamingContentSHA256Trailer = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"
|
||||
streamingUnsignedPayload = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"
|
||||
unsignedPayload = "UNSIGNED-PAYLOAD"
|
||||
// Limit for IAM/STS request body size to prevent DoS attacks
|
||||
iamRequestBodyLimit = 10 * (1 << 20) // 10 MiB
|
||||
)
|
||||
@@ -214,14 +215,14 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh
|
||||
availableKeys = append(availableKeys, key)
|
||||
}
|
||||
iam.m.RUnlock()
|
||||
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
authInfo.AccessKey, len(availableKeys), iam.isAuthEnabled)
|
||||
|
||||
|
||||
if glog.V(2) && len(availableKeys) > 0 {
|
||||
glog.V(2).Infof("Available access keys: %v", availableKeys)
|
||||
}
|
||||
|
||||
|
||||
return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
@@ -562,10 +563,10 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http.
|
||||
iam.m.RLock()
|
||||
availableKeyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId (POST policy): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
credHeader.accessKey, availableKeyCount, iam.isAuthEnabled)
|
||||
|
||||
|
||||
return s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cr
|
||||
|
||||
// This check ensures we only proceed for streaming uploads.
|
||||
switch authInfo.HashedPayload {
|
||||
case streamingContentSHA256:
|
||||
glog.V(3).Infof("streaming content sha256")
|
||||
case streamingContentSHA256, streamingContentSHA256Trailer:
|
||||
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", authInfo.HashedPayload == streamingContentSHA256Trailer)
|
||||
case streamingUnsignedPayload:
|
||||
glog.V(3).Infof("streaming unsigned payload")
|
||||
default:
|
||||
@@ -87,9 +87,9 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
var errCode s3err.ErrorCode
|
||||
|
||||
switch contentSha256Header {
|
||||
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
|
||||
case streamingContentSHA256:
|
||||
glog.V(3).Infof("streaming content sha256")
|
||||
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' or 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
|
||||
case streamingContentSHA256, streamingContentSHA256Trailer:
|
||||
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", contentSha256Header == streamingContentSHA256Trailer)
|
||||
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
@@ -116,6 +116,7 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
}
|
||||
|
||||
checkSumWriter := getCheckSumWriter(checksumAlgorithm)
|
||||
hasTrailer := amzTrailerHeader != ""
|
||||
|
||||
return &s3ChunkedReader{
|
||||
cred: credential,
|
||||
@@ -129,6 +130,7 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
checkSumWriter: checkSumWriter,
|
||||
state: readChunkHeader,
|
||||
iam: iam,
|
||||
hasTrailer: hasTrailer,
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
@@ -170,6 +172,7 @@ type s3ChunkedReader struct {
|
||||
n uint64 // Unread bytes in chunk
|
||||
err error
|
||||
iam *IdentityAccessManagement
|
||||
hasTrailer bool
|
||||
}
|
||||
|
||||
// Read chunk reads the chunk token signature portion.
|
||||
@@ -281,10 +284,10 @@ func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
// If we're using unsigned streaming upload, there is no signature to verify at each chunk.
|
||||
if cr.chunkSignature != "" {
|
||||
cr.state = verifyChunk
|
||||
} else if cr.lastChunk {
|
||||
if cr.lastChunk && cr.hasTrailer {
|
||||
cr.state = readTrailerChunk
|
||||
} else if cr.chunkSignature != "" {
|
||||
cr.state = verifyChunk
|
||||
} else {
|
||||
cr.state = readChunkHeader
|
||||
}
|
||||
@@ -304,7 +307,11 @@ func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
|
||||
// This implementation currently only supports the first case.
|
||||
// TODO: Implement the second case (signed upload with additional checksum computation for each chunk)
|
||||
|
||||
extractedCheckSumAlgorithm, extractedChecksum := parseChunkChecksum(cr.reader)
|
||||
extractedCheckSumAlgorithm, extractedChecksum, err := parseChunkChecksum(cr.reader)
|
||||
if err != nil {
|
||||
cr.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if extractedCheckSumAlgorithm.String() != cr.checkSumAlgorithm {
|
||||
errorMessage := fmt.Sprintf("checksum algorithm in trailer '%s' does not match the one advertised in the header '%s'", extractedCheckSumAlgorithm.String(), cr.checkSumAlgorithm)
|
||||
@@ -313,6 +320,7 @@ func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
|
||||
return 0, cr.err
|
||||
}
|
||||
|
||||
// Validate checksum for data integrity (required for both signed and unsigned streaming with trailers)
|
||||
computedChecksum := cr.checkSumWriter.Sum(nil)
|
||||
base64Checksum := base64.StdEncoding.EncodeToString(computedChecksum)
|
||||
if string(extractedChecksum) != base64Checksum {
|
||||
@@ -324,11 +332,6 @@ func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
|
||||
// TODO: Extract signature from trailer chunk and verify it.
|
||||
// For now, we just read the trailer chunk and discard it.
|
||||
|
||||
// Reading remaining CRLF.
|
||||
for i := 0; i < 2; i++ {
|
||||
cr.err = readCRLF(cr.reader)
|
||||
}
|
||||
|
||||
cr.state = eofChunk
|
||||
|
||||
case readChunk:
|
||||
@@ -506,41 +509,37 @@ func parseS3ChunkExtension(buf []byte) ([]byte, []byte) {
|
||||
return buf[:semi], parseChunkSignature(buf[semi:])
|
||||
}
|
||||
|
||||
func parseChunkChecksum(b *bufio.Reader) (ChecksumAlgorithm, []byte) {
|
||||
// When using unsigned upload, this would be the raw contents of the trailer chunk:
|
||||
//
|
||||
// x-amz-checksum-crc32:YABb/g==\n\r\n\r\n // Trailer chunk (note optional \n character)
|
||||
// \r\n // CRLF
|
||||
//
|
||||
// When using signed upload with an additional checksum algorithm, this would be the raw contents of the trailer chunk:
|
||||
//
|
||||
// x-amz-checksum-crc32:YABb/g==\n\r\n // Trailer chunk (note optional \n character)
|
||||
// trailer-signature\r\n
|
||||
// \r\n // CRLF
|
||||
//
|
||||
func parseChunkChecksum(b *bufio.Reader) (ChecksumAlgorithm, []byte, error) {
|
||||
// Read trailer lines until empty line
|
||||
var checksumAlgorithm ChecksumAlgorithm
|
||||
var checksum []byte
|
||||
|
||||
// x-amz-checksum-crc32:YABb/g==\n
|
||||
bytesRead, err := readChunkLine(b)
|
||||
if err != nil {
|
||||
return ChecksumAlgorithmNone, nil
|
||||
for {
|
||||
bytesRead, err := readChunkLine(b)
|
||||
if err != nil {
|
||||
return ChecksumAlgorithmNone, nil, err
|
||||
}
|
||||
|
||||
if len(bytesRead) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
parts := bytes.SplitN(bytesRead, []byte(":"), 2)
|
||||
if len(parts) == 2 {
|
||||
key := string(bytes.TrimSpace(parts[0]))
|
||||
value := bytes.TrimSpace(parts[1])
|
||||
if alg, err := extractChecksumAlgorithm(key); err == nil {
|
||||
if checksumAlgorithm != ChecksumAlgorithmNone {
|
||||
glog.V(3).Infof("multiple checksum headers found in trailer, using last: %s", key)
|
||||
}
|
||||
checksumAlgorithm = alg
|
||||
checksum = value
|
||||
}
|
||||
// Ignore other trailer headers like x-amz-trailer-signature
|
||||
}
|
||||
}
|
||||
|
||||
// Split on ':'
|
||||
parts := bytes.SplitN(bytesRead, []byte(":"), 2)
|
||||
checksumKey := string(parts[0])
|
||||
checksumValue := parts[1]
|
||||
|
||||
// Discard all trailing whitespace characters
|
||||
checksumValue = trimTrailingWhitespace(checksumValue)
|
||||
|
||||
// If the checksum key is not a supported checksum algorithm, return an error.
|
||||
// TODO: Bubble that error up to the caller
|
||||
extractedAlgorithm, err := extractChecksumAlgorithm(checksumKey)
|
||||
if err != nil {
|
||||
return ChecksumAlgorithmNone, nil
|
||||
}
|
||||
|
||||
return extractedAlgorithm, checksumValue
|
||||
return checksumAlgorithm, checksum, nil
|
||||
}
|
||||
|
||||
func parseChunkSignature(chunk []byte) []byte {
|
||||
|
||||
@@ -234,6 +234,150 @@ func TestSignedStreamingUpload(t *testing.T) {
|
||||
assert.Equal(t, chunk1Data+chunk2Data, string(data))
|
||||
}
|
||||
|
||||
// createTrailerStreamingRequest creates a streaming upload request with trailer for testing.
|
||||
// If useValidTrailerSignature is true, uses a correctly calculated trailer signature;
|
||||
// otherwise uses an intentionally wrong signature for negative testing.
|
||||
func createTrailerStreamingRequest(t *testing.T, useValidTrailerSignature bool) (*http.Request, string) {
|
||||
chunk1Data := "hello world\n"
|
||||
chunk1DataLen := len(chunk1Data)
|
||||
chunk1DataLenHex := fmt.Sprintf("%x", chunk1DataLen)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
fmt.Sprintf("x-amz-decoded-content-length:%d\n", chunk1DataLen) +
|
||||
"x-amz-trailer:x-amz-checksum-crc32\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-trailer"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signatures
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
// Final chunk (0 bytes)
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Calculate CRC32 checksum for trailer
|
||||
crcWriter := crc32.NewIEEE()
|
||||
_, crcErr := crcWriter.Write([]byte(chunk1Data))
|
||||
assert.NoError(t, crcErr)
|
||||
checksum := crcWriter.Sum(nil)
|
||||
base64EncodedChecksum := base64.StdEncoding.EncodeToString(checksum)
|
||||
|
||||
// The on-wire trailer format uses \r\n (HTTP/aws-chunked convention)
|
||||
trailerOnWire := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\r\n"
|
||||
|
||||
// Calculate or use wrong trailer signature
|
||||
var trailerSignature string
|
||||
if useValidTrailerSignature {
|
||||
// The canonical trailer content uses \n for signing (per AWS SigV4 spec)
|
||||
trailerCanonical := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\n"
|
||||
trailerHash := getSHA256Hash([]byte(trailerCanonical))
|
||||
trailerStringToSign := "AWS4-HMAC-SHA256-TRAILER\n" + amzDate + "\n" + scope + "\n" +
|
||||
finalSignature + "\n" + trailerHash
|
||||
trailerSignature = getSignature(signingKey, trailerStringToSign)
|
||||
} else {
|
||||
// Intentionally wrong signature for negative testing
|
||||
trailerSignature = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
|
||||
// Build the chunked payload with trailer and trailer signature
|
||||
payload := fmt.Sprintf("%s;chunk-signature=%s\r\n%s\r\n", chunk1DataLenHex, chunk1Signature, chunk1Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n", finalSignature) +
|
||||
trailerOnWire +
|
||||
"x-amz-trailer-signature:" + trailerSignature + "\r\n" +
|
||||
"\r\n"
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", fmt.Sprintf("%d", chunk1DataLen))
|
||||
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
return req, chunk1Data
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadWithTrailer tests streaming uploads with signed chunks and trailers
|
||||
// This tests the STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER content-sha256 header value
|
||||
// which is used by AWS SDK v2 when checksum validation is enabled
|
||||
func TestSignedStreamingUploadWithTrailer(t *testing.T) {
|
||||
iam := setupIam()
|
||||
req, expectedData := createTrailerStreamingRequest(t, true)
|
||||
|
||||
// Test the chunked reader
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read and verify the payload
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedData, string(data))
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadWithTrailerInvalidSignature tests behavior with invalid trailer signatures.
|
||||
// This is a negative test case for trailer signature validation. It currently verifies that an invalid
|
||||
// signature doesn't break content reading, and is prepared for when validation is implemented.
|
||||
func TestSignedStreamingUploadWithTrailerInvalidSignature(t *testing.T) {
|
||||
iam := setupIam()
|
||||
req, expectedData := createTrailerStreamingRequest(t, false)
|
||||
|
||||
// Test the chunked reader - it should be created successfully
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read the payload - currently trailer signature validation may not be implemented,
|
||||
// but this test documents the expected behavior and will catch regressions
|
||||
// if trailer signature validation is added in the future
|
||||
data, err := io.ReadAll(reader)
|
||||
// Note: If trailer signature validation is implemented, this should fail with an error
|
||||
// For now, we just verify the content is correctly extracted
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "signature", "Error should indicate signature mismatch")
|
||||
} else {
|
||||
// If no error, content should still be correct (trailer sig validation not yet implemented)
|
||||
assert.Equal(t, expectedData, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadInvalidSignature tests that invalid chunk signatures are rejected
|
||||
// This is a negative test case to ensure signature validation is actually working
|
||||
func TestSignedStreamingUploadInvalidSignature(t *testing.T) {
|
||||
|
||||
@@ -187,7 +187,10 @@ func (s3a *S3ApiServer) completeMultipartUpload(r *http.Request, input *s3.Compl
|
||||
sort.Ints(completedPartNumbers)
|
||||
|
||||
uploadDirectory := s3a.genUploadsFolder(*input.Bucket) + "/" + *input.UploadId
|
||||
entries, _, err := s3a.list(uploadDirectory, "", "", false, 0)
|
||||
// Use explicit limit to ensure all parts are listed (up to S3's max of 10,000 parts)
|
||||
// Previously limit=0 relied on server's DirListingLimit default (1000 in weed server mode),
|
||||
// which caused CompleteMultipartUpload to fail for uploads with more than 1000 parts.
|
||||
entries, _, err := s3a.list(uploadDirectory, "", "", false, s3_constants.MaxS3MultipartParts+1)
|
||||
if err != nil {
|
||||
glog.Errorf("completeMultipartUpload %s %s error: %v, entries:%d", *input.Bucket, *input.UploadId, err, len(entries))
|
||||
stats.S3HandlerCounter.WithLabelValues(stats.ErrorCompletedNoSuchUpload).Inc()
|
||||
|
||||
@@ -178,7 +178,8 @@ func IsSeaweedFSInternalHeader(headerKey string) bool {
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
contextKeyIdentityName contextKey = "s3-identity-name"
|
||||
contextKeyIdentityName contextKey = "s3-identity-name"
|
||||
contextKeyIdentityObject contextKey = "s3-identity-object"
|
||||
)
|
||||
|
||||
// SetIdentityNameInContext stores the authenticated identity name in the request context
|
||||
@@ -199,3 +200,18 @@ func GetIdentityNameFromContext(r *http.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetIdentityInContext stores the full authenticated identity object in the request context
|
||||
// This is used to pass the full identity (including for JWT users) to handlers
|
||||
func SetIdentityInContext(ctx context.Context, identity interface{}) context.Context {
|
||||
if identity != nil {
|
||||
return context.WithValue(ctx, contextKeyIdentityObject, identity)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetIdentityFromContext retrieves the full identity object from the request context
|
||||
// Returns nil if no identity is set (unauthenticated request)
|
||||
func GetIdentityFromContext(r *http.Request) interface{} {
|
||||
return r.Context().Value(contextKeyIdentityObject)
|
||||
}
|
||||
|
||||
@@ -48,14 +48,22 @@ func isRequestPostPolicySignatureV4(r *http.Request) bool {
|
||||
}
|
||||
|
||||
// Verify if the request has AWS Streaming Signature Version '4'. This is only valid for 'PUT' operation.
|
||||
// Supports both with and without trailer variants:
|
||||
// - STREAMING-AWS4-HMAC-SHA256-PAYLOAD (original)
|
||||
// - STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER (with trailing checksums)
|
||||
func isRequestSignStreamingV4(r *http.Request) bool {
|
||||
return r.Header.Get("x-amz-content-sha256") == streamingContentSHA256 &&
|
||||
r.Method == http.MethodPut
|
||||
if r.Method != http.MethodPut {
|
||||
return false
|
||||
}
|
||||
contentSha256 := r.Header.Get("x-amz-content-sha256")
|
||||
return contentSha256 == streamingContentSHA256 || contentSha256 == streamingContentSHA256Trailer
|
||||
}
|
||||
|
||||
func isRequestUnsignedStreaming(r *http.Request) bool {
|
||||
return r.Header.Get("x-amz-content-sha256") == streamingUnsignedPayload &&
|
||||
r.Method == http.MethodPut
|
||||
if r.Method != http.MethodPut {
|
||||
return false
|
||||
}
|
||||
return r.Header.Get("x-amz-content-sha256") == streamingUnsignedPayload
|
||||
}
|
||||
|
||||
// Authorization type.
|
||||
|
||||
@@ -514,12 +514,27 @@ func (s3a *S3ApiServer) isVersioningConfigured(bucket string) (bool, error) {
|
||||
return config.Versioning != "" || config.ObjectLockConfig != nil, nil
|
||||
}
|
||||
|
||||
// isObjectLockEnabled checks if Object Lock is enabled for a bucket (with caching)
|
||||
func (s3a *S3ApiServer) isObjectLockEnabled(bucket string) (bool, error) {
|
||||
config, errCode := s3a.getBucketConfig(bucket)
|
||||
if errCode != s3err.ErrNone {
|
||||
if errCode == s3err.ErrNoSuchBucket {
|
||||
return false, filer_pb.ErrNotFound
|
||||
}
|
||||
return false, fmt.Errorf("failed to get bucket config: %v", errCode)
|
||||
}
|
||||
|
||||
return config.ObjectLockConfig != nil, nil
|
||||
}
|
||||
|
||||
// getVersioningState returns the detailed versioning state for a bucket
|
||||
func (s3a *S3ApiServer) getVersioningState(bucket string) (string, error) {
|
||||
config, errCode := s3a.getBucketConfig(bucket)
|
||||
if errCode != s3err.ErrNone {
|
||||
if errCode == s3err.ErrNoSuchBucket {
|
||||
return "", nil
|
||||
// Signal to callers that the bucket does not exist so they can
|
||||
// decide whether to auto-create it (e.g., in PUT handlers).
|
||||
return "", filer_pb.ErrNotFound
|
||||
}
|
||||
glog.Errorf("getVersioningState: failed to get bucket config for %s: %v", bucket, errCode)
|
||||
return "", fmt.Errorf("failed to get bucket config: %v", errCode)
|
||||
|
||||
@@ -38,15 +38,28 @@ func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
glog.V(3).Infof("ListBucketsHandler")
|
||||
|
||||
// Get authenticated identity from context (set by Auth middleware)
|
||||
// For unauthenticated requests, this returns empty string
|
||||
identityId := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
// Get the full identity object for permission and ownership checks
|
||||
// This is especially important for JWT users whose identity is not in the identities list
|
||||
// Note: We store the full Identity object in context for simplicity. Future optimization
|
||||
// could use a lightweight, credential-free view (name, account, actions, principal ARN)
|
||||
// for better data minimization.
|
||||
var identity *Identity
|
||||
var s3Err s3err.ErrorCode
|
||||
if s3a.iam.isEnabled() {
|
||||
// Use authRequest instead of authUser for consistency with other endpoints
|
||||
// This ensures the same authentication flow and any fixes (like prefix handling) are applied
|
||||
identity, s3Err = s3a.iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
if s3Err != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, s3Err)
|
||||
return
|
||||
// Try to get the full identity from context first (works for all auth types including JWT)
|
||||
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
|
||||
if id, ok := identityObj.(*Identity); ok {
|
||||
identity = id
|
||||
} else {
|
||||
glog.Warningf("ListBucketsHandler: identity object in context has unexpected type: %T", identityObj)
|
||||
}
|
||||
}
|
||||
// Fallback to looking up by name if not in context (backward compatibility)
|
||||
if identity == nil && identityId != "" {
|
||||
identity = s3a.iam.lookupByIdentityName(identityId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +72,6 @@ func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Get authenticated identity from context (secure, cannot be spoofed)
|
||||
// For unauthenticated requests, this returns empty string
|
||||
identityId := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
var listBuckets ListAllMyBucketsList
|
||||
for _, entry := range entries {
|
||||
if entry.IsDirectory {
|
||||
@@ -244,48 +253,66 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// create the folder for bucket, but lazily create actual collection
|
||||
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, setBucketOwner(r)); err != nil {
|
||||
// Check for x-amz-bucket-object-lock-enabled header BEFORE creating bucket
|
||||
// This allows us to create the bucket with Object Lock configuration atomically
|
||||
objectLockEnabled := strings.EqualFold(r.Header.Get(s3_constants.AmzBucketObjectLockEnabled), "true")
|
||||
|
||||
// Capture any Object Lock configuration error from within the callback
|
||||
// The mkdir callback doesn't support returning errors, so we capture it here
|
||||
var objectLockSetupError error
|
||||
|
||||
// Create the folder for bucket with all settings atomically
|
||||
// This ensures Object Lock configuration is set in the same CreateEntry call,
|
||||
// preventing race conditions where the bucket exists without Object Lock enabled
|
||||
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
|
||||
// Set bucket owner
|
||||
setBucketOwner(r)(entry)
|
||||
|
||||
// Set Object Lock configuration atomically during bucket creation
|
||||
if objectLockEnabled {
|
||||
glog.V(3).Infof("PutBucketHandler: enabling Object Lock and Versioning for bucket %s atomically", bucket)
|
||||
|
||||
if entry.Extended == nil {
|
||||
entry.Extended = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// Enable versioning (required for Object Lock)
|
||||
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled)
|
||||
|
||||
// Create and store Object Lock configuration
|
||||
objectLockConfig := &ObjectLockConfiguration{
|
||||
ObjectLockEnabled: s3_constants.ObjectLockEnabled,
|
||||
}
|
||||
if err := StoreObjectLockConfigurationInExtended(entry, objectLockConfig); err != nil {
|
||||
glog.Errorf("PutBucketHandler: failed to store Object Lock config for bucket %s: %v", bucket, err)
|
||||
objectLockSetupError = err
|
||||
// Note: The entry will still be created, but we'll roll it back below
|
||||
} else {
|
||||
glog.V(3).Infof("PutBucketHandler: set ObjectLockConfig for bucket %s: %+v", bucket, objectLockConfig)
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
glog.Errorf("PutBucketHandler mkdir: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
// If Object Lock setup failed, roll back the bucket creation
|
||||
// This ensures we don't leave a bucket without the requested Object Lock configuration
|
||||
if objectLockSetupError != nil {
|
||||
glog.Errorf("PutBucketHandler: rolling back bucket %s creation due to Object Lock setup failure: %v", bucket, objectLockSetupError)
|
||||
if deleteErr := s3a.rm(s3a.option.BucketsPath, bucket, true, true); deleteErr != nil {
|
||||
glog.Errorf("PutBucketHandler: failed to rollback bucket %s after Object Lock setup failure: %v", bucket, deleteErr)
|
||||
}
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove bucket from negative cache after successful creation
|
||||
if s3a.bucketConfigCache != nil {
|
||||
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
|
||||
}
|
||||
|
||||
// Check for x-amz-bucket-object-lock-enabled header (S3 standard compliance)
|
||||
if objectLockHeaderValue := r.Header.Get(s3_constants.AmzBucketObjectLockEnabled); strings.EqualFold(objectLockHeaderValue, "true") {
|
||||
glog.V(3).Infof("PutBucketHandler: enabling Object Lock and Versioning for bucket %s due to x-amz-bucket-object-lock-enabled header", bucket)
|
||||
|
||||
// Atomically update the configuration of the specified bucket. See the updateBucketConfig
|
||||
// function definition for detailed documentation on parameters and behavior.
|
||||
errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error {
|
||||
// Enable versioning (required for Object Lock)
|
||||
bucketConfig.Versioning = s3_constants.VersioningEnabled
|
||||
|
||||
// Create basic Object Lock configuration (enabled without default retention)
|
||||
objectLockConfig := &ObjectLockConfiguration{
|
||||
ObjectLockEnabled: s3_constants.ObjectLockEnabled,
|
||||
}
|
||||
|
||||
// Set the cached Object Lock configuration
|
||||
bucketConfig.ObjectLockConfig = objectLockConfig
|
||||
glog.V(3).Infof("PutBucketHandler: set ObjectLockConfig for bucket %s: %+v", bucket, objectLockConfig)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if errCode != s3err.ErrNone {
|
||||
glog.Errorf("PutBucketHandler: failed to enable Object Lock for bucket %s: %v", bucket, errCode)
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
glog.V(3).Infof("PutBucketHandler: enabled Object Lock and Versioning for bucket %s", bucket)
|
||||
}
|
||||
|
||||
w.Header().Set("Location", "/"+bucket)
|
||||
writeSuccessResponseEmpty(w, r)
|
||||
}
|
||||
|
||||
@@ -663,3 +663,116 @@ func TestListBucketsOwnershipCaseSensitivity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListBucketsIssue7647 reproduces and verifies the fix for issue #7647
|
||||
// where an admin user with proper permissions could create buckets but couldn't list them
|
||||
func TestListBucketsIssue7647(t *testing.T) {
|
||||
t.Run("admin user can see their created buckets", func(t *testing.T) {
|
||||
// Simulate the exact scenario from issue #7647:
|
||||
// User "root" with ["Admin", "Read", "Write", "Tagging", "List"] permissions
|
||||
|
||||
// Create identity for root user with Admin action
|
||||
rootIdentity := &Identity{
|
||||
Name: "root",
|
||||
Credentials: []*Credential{
|
||||
{
|
||||
AccessKey: "ROOTID",
|
||||
SecretKey: "ROOTSECRET",
|
||||
},
|
||||
},
|
||||
Actions: []Action{
|
||||
s3_constants.ACTION_ADMIN,
|
||||
s3_constants.ACTION_READ,
|
||||
s3_constants.ACTION_WRITE,
|
||||
s3_constants.ACTION_TAGGING,
|
||||
s3_constants.ACTION_LIST,
|
||||
},
|
||||
}
|
||||
|
||||
// Create a bucket entry as if it was created by the root user
|
||||
bucketEntry := &filer_pb.Entry{
|
||||
Name: "test",
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.AmzIdentityId: []byte("root"),
|
||||
},
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
// Test bucket visibility - should be visible to root (owner)
|
||||
isVisible := isBucketVisibleToIdentity(bucketEntry, rootIdentity)
|
||||
assert.True(t, isVisible, "Root user should see their own bucket")
|
||||
|
||||
// Test that admin can also see buckets they don't own
|
||||
otherUserBucket := &filer_pb.Entry{
|
||||
Name: "other-bucket",
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.AmzIdentityId: []byte("otheruser"),
|
||||
},
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
isVisible = isBucketVisibleToIdentity(otherUserBucket, rootIdentity)
|
||||
assert.True(t, isVisible, "Admin user should see all buckets, even ones they don't own")
|
||||
|
||||
// Test permission check for List action
|
||||
canList := rootIdentity.canDo(s3_constants.ACTION_LIST, "test", "")
|
||||
assert.True(t, canList, "Root user with List action should be able to list buckets")
|
||||
})
|
||||
|
||||
t.Run("admin user sees buckets without owner metadata", func(t *testing.T) {
|
||||
// Admin users should see buckets even if they don't have owner metadata
|
||||
// (this can happen with legacy buckets or manual creation)
|
||||
|
||||
rootIdentity := &Identity{
|
||||
Name: "root",
|
||||
Actions: []Action{
|
||||
s3_constants.ACTION_ADMIN,
|
||||
s3_constants.ACTION_LIST,
|
||||
},
|
||||
}
|
||||
|
||||
bucketWithoutOwner := &filer_pb.Entry{
|
||||
Name: "legacy-bucket",
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{}, // No owner metadata
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
isVisible := isBucketVisibleToIdentity(bucketWithoutOwner, rootIdentity)
|
||||
assert.True(t, isVisible, "Admin should see buckets without owner metadata")
|
||||
})
|
||||
|
||||
t.Run("non-admin user cannot see buckets without owner", func(t *testing.T) {
|
||||
// Non-admin users should not see buckets without owner metadata
|
||||
|
||||
regularUser := &Identity{
|
||||
Name: "user1",
|
||||
Actions: []Action{
|
||||
s3_constants.ACTION_READ,
|
||||
s3_constants.ACTION_LIST,
|
||||
},
|
||||
}
|
||||
|
||||
bucketWithoutOwner := &filer_pb.Entry{
|
||||
Name: "legacy-bucket",
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{}, // No owner metadata
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
isVisible := isBucketVisibleToIdentity(bucketWithoutOwner, regularUser)
|
||||
assert.False(t, isVisible, "Non-admin should not see buckets without owner metadata")
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user