mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-13 01:55:50 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcce8d164c | ||
|
|
59a7c40043 | ||
|
|
1b1e5f69a2 | ||
|
|
221b352593 | ||
|
|
32a9a1f46f | ||
|
|
60649460b2 | ||
|
|
f64ce759e0 | ||
|
|
7ed7578424 | ||
|
|
8bdc4390a0 | ||
|
|
f734b2d4bf | ||
|
|
f41925b60b | ||
|
|
a77674ead3 | ||
|
|
eb860752e6 | ||
|
|
51c2ab0107 | ||
|
|
f70cd05404 | ||
|
|
f77e6ed2d4 | ||
|
|
d80d8be012 | ||
|
|
36b8b2147b | ||
|
|
93cca3a96b | ||
|
|
72853a3bbf | ||
|
|
662a6ac8ee | ||
|
|
a1eab5ff99 | ||
|
|
6fb3ec968d | ||
|
|
b0e0c5aaab | ||
|
|
e8b7347031 | ||
|
|
50efda9cc5 | ||
|
|
84b8a8e010 | ||
|
|
c6d6ee8297 | ||
|
|
de3ecaf0de | ||
|
|
df4f2f7020 | ||
|
|
1ee03b6411 | ||
|
|
dfac1315ca | ||
|
|
2d06ddab41 | ||
|
|
924d410dc8 | ||
|
|
2fa36549df | ||
|
|
e225940202 | ||
|
|
2188d1ccc5 | ||
|
|
4c36cd04d6 | ||
|
|
27a28faf49 | ||
|
|
c153420022 | ||
|
|
ae7333d28e | ||
|
|
0cd9f34177 | ||
|
|
1e1473ef4a | ||
|
|
4bc2b6d62b | ||
|
|
d970c15d71 | ||
|
|
1b13324fb7 | ||
|
|
4f382b77c8 | ||
|
|
80c7de8d76 | ||
|
|
0ede657a34 | ||
|
|
5c27522507 | ||
|
|
40eee23be9 | ||
|
|
b4e2cca204 | ||
|
|
d6d893c8c3 |
@@ -0,0 +1,92 @@
|
||||
name: "Metadata Subscribe Integration Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'weed/filer/**'
|
||||
- 'weed/pb/filer_pb/**'
|
||||
- 'weed/util/log_buffer/**'
|
||||
- 'weed/server/filer_grpc_server_sub_meta.go'
|
||||
- 'weed/command/filer_backup.go'
|
||||
- 'test/metadata_subscribe/**'
|
||||
- '.github/workflows/metadata-subscribe-tests.yml'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'weed/filer/**'
|
||||
- 'weed/pb/filer_pb/**'
|
||||
- 'weed/util/log_buffer/**'
|
||||
- 'weed/server/filer_grpc_server_sub_meta.go'
|
||||
- 'weed/command/filer_backup.go'
|
||||
- 'test/metadata_subscribe/**'
|
||||
- '.github/workflows/metadata-subscribe-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref }}/metadata-subscribe-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
TEST_TIMEOUT: '10m'
|
||||
|
||||
jobs:
|
||||
metadata-subscribe-integration:
|
||||
name: Metadata Subscribe Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Build SeaweedFS
|
||||
run: |
|
||||
cd weed
|
||||
go build -o weed .
|
||||
chmod +x weed
|
||||
./weed version
|
||||
|
||||
- name: Run Metadata Subscribe Integration Tests
|
||||
run: |
|
||||
cd test/metadata_subscribe
|
||||
|
||||
echo "Running Metadata Subscribe integration tests..."
|
||||
echo "============================================"
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v -timeout=${{ env.TEST_TIMEOUT }} ./...
|
||||
|
||||
echo "============================================"
|
||||
echo "Metadata Subscribe integration tests completed"
|
||||
|
||||
- name: Archive logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: metadata-subscribe-test-logs
|
||||
path: |
|
||||
/tmp/seaweedfs_*
|
||||
retention-days: 7
|
||||
|
||||
- name: Test Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Metadata Subscribe Integration Test Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Coverage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Basic Subscription**: Subscribe to metadata changes and receive events" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Single-Filer No Stall**: Regression test for issue #4977" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Resume from Disk**: Verify subscription can resume from persisted logs" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Related Issues" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [#4977](https://github.com/seaweedfs/seaweedfs/issues/4977): filer.backup synchronisation stall" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: "S3 Filer Group Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref }}/s3-filer-group-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: weed
|
||||
|
||||
jobs:
|
||||
s3-filer-group-tests:
|
||||
name: S3 Filer Group Integration 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 Filer Group Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/s3/filer_group
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
echo "=== Starting Filer Group Tests ==="
|
||||
|
||||
# Run tests with automatic server management
|
||||
# The test-with-server target handles server startup/shutdown automatically
|
||||
# Server is started with -filer.group=testgroup
|
||||
make test-with-server || {
|
||||
echo "❌ Filer group tests 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: Show server logs on failure
|
||||
if: failure()
|
||||
working-directory: test/s3/filer_group
|
||||
run: |
|
||||
echo "=== Server Logs ==="
|
||||
if [ -f weed-test.log ]; then
|
||||
echo "Last 100 lines of server logs:"
|
||||
tail -100 weed-test.log
|
||||
else
|
||||
echo "No server log file found"
|
||||
fi
|
||||
|
||||
echo "=== Test Environment ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp | grep -E "(8333|9333|8080)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: s3-filer-group-test-logs
|
||||
path: test/s3/filer_group/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
name: "TUS Protocol Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'weed/server/filer_server_tus*.go'
|
||||
- 'weed/server/filer_server.go'
|
||||
- 'test/tus/**'
|
||||
- '.github/workflows/tus-tests.yml'
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'weed/server/filer_server_tus*.go'
|
||||
- 'weed/server/filer_server.go'
|
||||
- 'test/tus/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.ref }}/tus-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: weed
|
||||
|
||||
jobs:
|
||||
tus-integration-tests:
|
||||
name: TUS Protocol Integration 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 TUS Integration Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/tus
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
echo "=== Starting TUS Tests ==="
|
||||
|
||||
# Run tests with automatic server management
|
||||
make test-with-server || {
|
||||
echo "TUS integration tests failed, checking logs..."
|
||||
if [ -f /tmp/seaweedfs-tus-filer.log ]; then
|
||||
echo "=== Filer logs ==="
|
||||
tail -100 /tmp/seaweedfs-tus-filer.log
|
||||
fi
|
||||
if [ -f /tmp/seaweedfs-tus-master.log ]; then
|
||||
echo "=== Master logs ==="
|
||||
tail -50 /tmp/seaweedfs-tus-master.log
|
||||
fi
|
||||
if [ -f /tmp/seaweedfs-tus-volume.log ]; then
|
||||
echo "=== Volume logs ==="
|
||||
tail -50 /tmp/seaweedfs-tus-volume.log
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Show server logs on failure
|
||||
if: failure()
|
||||
working-directory: test/tus
|
||||
run: |
|
||||
echo "=== Filer Server Logs ==="
|
||||
if [ -f /tmp/seaweedfs-tus-filer.log ]; then
|
||||
echo "Last 100 lines of filer logs:"
|
||||
tail -100 /tmp/seaweedfs-tus-filer.log
|
||||
else
|
||||
echo "No filer log file found"
|
||||
fi
|
||||
|
||||
echo "=== Master Server Logs ==="
|
||||
if [ -f /tmp/seaweedfs-tus-master.log ]; then
|
||||
tail -50 /tmp/seaweedfs-tus-master.log
|
||||
else
|
||||
echo "No master log file found"
|
||||
fi
|
||||
|
||||
echo "=== Volume Server Logs ==="
|
||||
if [ -f /tmp/seaweedfs-tus-volume.log ]; then
|
||||
tail -50 /tmp/seaweedfs-tus-volume.log
|
||||
else
|
||||
echo "No volume log file found"
|
||||
fi
|
||||
|
||||
echo "=== Test Environment ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp 2>/dev/null | grep -E "(18888|19333|18080)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: tus-test-logs
|
||||
path: |
|
||||
/tmp/seaweedfs-tus-*.log
|
||||
retention-days: 3
|
||||
@@ -124,3 +124,5 @@ ADVANCED_IAM_DEVELOPMENT_PLAN.md
|
||||
*.log
|
||||
weed-iam
|
||||
test/kafka/kafka-client-loadtest/weed-linux-arm64
|
||||
/test/tus/filerldb2
|
||||
coverage.out
|
||||
|
||||
@@ -77,6 +77,7 @@ LABEL author="Chris Lu"
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fuse \
|
||||
wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -27,7 +27,7 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh
|
||||
# To disable: docker run -e GODEBUG=fips140=off ...
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk add --no-cache fuse su-exec && \
|
||||
RUN apk add --no-cache fuse curl su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/filer_rocksdb.
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk add --no-cache fuse snappy gflags su-exec && \
|
||||
RUN apk add --no-cache fuse snappy gflags curl su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/filer_rocksdb.
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk add --no-cache fuse snappy gflags tmux su-exec && \
|
||||
RUN apk add --no-cache fuse snappy gflags curl tmux su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ require (
|
||||
github.com/Jille/raft-grpc-transport v1.6.1
|
||||
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/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
|
||||
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.1
|
||||
@@ -203,8 +203,8 @@ require (
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||
github.com/pierrre/geohash v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
||||
@@ -218,7 +218,6 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
|
||||
@@ -651,8 +651,8 @@ github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmg
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20240515141816-262c6fe778ad h1:fQBkhYv86zyW95PWhzBlkgz3NoY1ue0L+8oYBaoCMbg=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20240515141816-262c6fe778ad/go.mod h1:OMVSB21p9+xQUIqlGizHPZfjK+SHws1ht+ZytVDoz9U=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3 h1:WZaTKNHCfcw7fWSR6/RKnCldVzvYZC+Y20Su4lffEIg=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3/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.9 h1:zckB+xuA16NmHUuYOX7INCJTIyIkoBQjAGqNpiyf2HQ=
|
||||
@@ -1563,10 +1563,10 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
|
||||
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=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
|
||||
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rclone/rclone v1.71.2 h1:3Jk5xNPFrZhVABRuN/OPvApuZQddpE2tkhYMuEn1Ud4=
|
||||
@@ -1872,8 +1872,8 @@ go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
description: SeaweedFS
|
||||
name: seaweedfs
|
||||
appVersion: "4.02"
|
||||
appVersion: "4.03"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.0.402
|
||||
version: 4.0.403
|
||||
|
||||
@@ -145,6 +145,191 @@ stringData:
|
||||
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"snu8yoP6QAlY0ne4","secretKey":"PNzBcmeLNEdR0oviwm04NQAicOrDH1Km"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"SCigFee6c5lbi04A","secretKey":"kgFhbT38R8WUYVtiFQ1OiSVOrYr3NKku"}],"actions":["Read"]}]}'
|
||||
```
|
||||
|
||||
## Admin Component
|
||||
|
||||
The admin component provides a modern web-based administration interface for managing SeaweedFS clusters. It includes:
|
||||
|
||||
- **Dashboard**: Real-time cluster status and metrics
|
||||
- **Volume Management**: Monitor volume servers, capacity, and health
|
||||
- **File Browser**: Browse and manage files in the filer
|
||||
- **Maintenance Operations**: Trigger maintenance tasks via workers
|
||||
- **Object Store Management**: Create and manage buckets with web interface
|
||||
|
||||
### Enabling Admin
|
||||
|
||||
To enable the admin interface, add the following to your values.yaml:
|
||||
|
||||
```yaml
|
||||
admin:
|
||||
enabled: true
|
||||
port: 23646
|
||||
grpcPort: 33646 # For worker connections
|
||||
adminUser: "admin"
|
||||
adminPassword: "your-secure-password" # Leave empty to disable auth
|
||||
|
||||
# Optional: persist admin data
|
||||
data:
|
||||
type: "persistentVolumeClaim"
|
||||
size: "10Gi"
|
||||
storageClass: "your-storage-class"
|
||||
|
||||
# Optional: enable ingress
|
||||
ingress:
|
||||
enabled: true
|
||||
host: "admin.seaweedfs.local"
|
||||
className: "nginx"
|
||||
```
|
||||
|
||||
The admin interface will be available at `http://<admin-service>:23646` (or via ingress). Workers connect to the admin server via gRPC on port `33646`.
|
||||
|
||||
### Admin Authentication
|
||||
|
||||
If `adminPassword` is set, the admin interface requires authentication:
|
||||
- Username: Value of `adminUser` (default: `admin`)
|
||||
- Password: Value of `adminPassword`
|
||||
|
||||
If `adminPassword` is empty or not set, the admin interface runs without authentication (not recommended for production).
|
||||
|
||||
### Admin Data Persistence
|
||||
|
||||
The admin component can store configuration and maintenance data. You can configure storage in several ways:
|
||||
|
||||
- **emptyDir** (default): Data is lost when pod restarts
|
||||
- **persistentVolumeClaim**: Data persists across pod restarts
|
||||
- **hostPath**: Data stored on the host filesystem
|
||||
- **existingClaim**: Use an existing PVC
|
||||
|
||||
## Worker Component
|
||||
|
||||
Workers are maintenance agents that execute cluster maintenance tasks such as vacuum, volume balancing, and erasure coding. Workers connect to the admin server via gRPC and receive task assignments.
|
||||
|
||||
### Enabling Workers
|
||||
|
||||
To enable workers, add the following to your values.yaml:
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2 # Scale based on workload
|
||||
capabilities: "vacuum,balance,erasure_coding" # Tasks this worker can handle
|
||||
maxConcurrent: 3 # Maximum concurrent tasks per worker
|
||||
|
||||
# Working directory for task execution
|
||||
# Default: "/tmp/seaweedfs-worker"
|
||||
# Note: /tmp is ephemeral - use persistent storage (hostPath/existingClaim) for long-running tasks
|
||||
workingDir: "/tmp/seaweedfs-worker"
|
||||
|
||||
# Optional: configure admin server address
|
||||
# If not specified, auto-discovers from admin service in the same namespace by looking for
|
||||
# a service named "<release-name>-admin" (e.g., "seaweedfs-admin").
|
||||
# Auto-discovery only works if the admin is in the same namespace and same Helm release.
|
||||
# For cross-namespace or separate release scenarios, explicitly set this value.
|
||||
# Example: If main SeaweedFS is deployed in "production" namespace:
|
||||
# adminServer: "seaweedfs-admin.production.svc:33646"
|
||||
adminServer: ""
|
||||
|
||||
# Workers need storage for task execution
|
||||
# Note: Workers use a Deployment, which does not support `volumeClaimTemplates`
|
||||
# for dynamic PVC creation per pod. To use persistent storage, you must
|
||||
# pre-provision a PersistentVolumeClaim and use `type: "existingClaim"`.
|
||||
data:
|
||||
type: "emptyDir" # Options: "emptyDir", "hostPath", or "existingClaim"
|
||||
hostPathPrefix: /storage # For hostPath
|
||||
# claimName: "worker-pvc" # For existingClaim with pre-provisioned PVC
|
||||
|
||||
# Resource limits for worker pods
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "2Gi"
|
||||
```
|
||||
|
||||
### Worker Capabilities
|
||||
|
||||
Workers can be configured with different capabilities:
|
||||
- **vacuum**: Reclaim deleted file space
|
||||
- **balance**: Balance volumes across volume servers
|
||||
- **erasure_coding**: Handle erasure coding operations
|
||||
|
||||
You can configure workers with all capabilities or create specialized worker pools with specific capabilities.
|
||||
|
||||
### Worker Deployment Strategy
|
||||
|
||||
For production deployments, consider:
|
||||
|
||||
1. **Multiple Workers**: Deploy 2+ worker replicas for high availability
|
||||
2. **Resource Allocation**: Workers need sufficient CPU/memory for maintenance tasks
|
||||
3. **Storage**: Workers need temporary storage for vacuum and balance operations (size depends on volume size)
|
||||
4. **Specialized Workers**: Create separate worker deployments for different capabilities if needed
|
||||
|
||||
Example specialized worker configuration:
|
||||
|
||||
For specialized worker pools, deploy separate Helm releases with different capabilities:
|
||||
|
||||
**values-worker-vacuum.yaml** (for vacuum operations):
|
||||
```yaml
|
||||
# Disable all other components, enable only workers
|
||||
master:
|
||||
enabled: false
|
||||
volume:
|
||||
enabled: false
|
||||
filer:
|
||||
enabled: false
|
||||
s3:
|
||||
enabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2
|
||||
capabilities: "vacuum"
|
||||
maxConcurrent: 2
|
||||
# REQUIRED: Point to the admin service of your main SeaweedFS release
|
||||
# Replace <namespace> with the namespace where your main seaweedfs is deployed
|
||||
# Example: If deploying in namespace "production":
|
||||
# adminServer: "seaweedfs-admin.production.svc:33646"
|
||||
adminServer: "seaweedfs-admin.<namespace>.svc:33646"
|
||||
```
|
||||
|
||||
**values-worker-balance.yaml** (for balance operations):
|
||||
```yaml
|
||||
# Disable all other components, enable only workers
|
||||
master:
|
||||
enabled: false
|
||||
volume:
|
||||
enabled: false
|
||||
filer:
|
||||
enabled: false
|
||||
s3:
|
||||
enabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
capabilities: "balance"
|
||||
maxConcurrent: 1
|
||||
# REQUIRED: Point to the admin service of your main SeaweedFS release
|
||||
# Replace <namespace> with the namespace where your main seaweedfs is deployed
|
||||
# Example: If deploying in namespace "production":
|
||||
# adminServer: "seaweedfs-admin.production.svc:33646"
|
||||
adminServer: "seaweedfs-admin.<namespace>.svc:33646"
|
||||
```
|
||||
|
||||
Deploy the specialized workers as separate releases:
|
||||
```bash
|
||||
# Deploy vacuum workers
|
||||
helm install seaweedfs-worker-vacuum seaweedfs/seaweedfs -f values-worker-vacuum.yaml
|
||||
|
||||
# Deploy balance workers
|
||||
helm install seaweedfs-worker-balance seaweedfs/seaweedfs -f values-worker-balance.yaml
|
||||
```
|
||||
|
||||
## Enterprise
|
||||
|
||||
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{{- if and .Values.admin.enabled .Values.admin.ingress.enabled }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else }}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ingress-{{ template "seaweedfs.name" . }}-admin
|
||||
namespace: {{ .Release.Namespace }}
|
||||
annotations:
|
||||
{{- if and (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) .Values.admin.ingress.className }}
|
||||
kubernetes.io/ingress.class: {{ .Values.admin.ingress.className }}
|
||||
{{- end }}
|
||||
{{- with .Values.admin.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
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: admin
|
||||
spec:
|
||||
{{- if and (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) .Values.admin.ingress.className }}
|
||||
ingressClassName: {{ .Values.admin.ingress.className | quote }}
|
||||
{{- end }}
|
||||
tls:
|
||||
{{ .Values.admin.ingress.tls | default list | toYaml | nindent 6}}
|
||||
rules:
|
||||
- {{- if .Values.admin.ingress.host }}
|
||||
host: {{ .Values.admin.ingress.host | quote }}
|
||||
{{- end }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.admin.ingress.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: {{ .Values.admin.ingress.pathType | quote }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ template "seaweedfs.name" . }}-admin
|
||||
port:
|
||||
number: {{ .Values.admin.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ template "seaweedfs.name" . }}-admin
|
||||
servicePort: {{ .Values.admin.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if and .Values.admin.enabled .Values.admin.secret.adminPassword (not .Values.admin.secret.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: Opaque
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-admin-secret
|
||||
namespace: {{ .Release.Namespace }}
|
||||
annotations:
|
||||
"helm.sh/resource-policy": keep
|
||||
"helm.sh/hook": "pre-install,pre-upgrade"
|
||||
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: admin
|
||||
data:
|
||||
adminUser: {{ .Values.admin.secret.adminUser | b64enc }}
|
||||
adminPassword: {{ .Values.admin.secret.adminPassword | b64enc }}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{- if .Values.admin.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-admin
|
||||
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: admin
|
||||
{{- if .Values.admin.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.admin.service.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.admin.service.type }}
|
||||
ports:
|
||||
- name: "http"
|
||||
port: {{ .Values.admin.port }}
|
||||
targetPort: {{ .Values.admin.port }}
|
||||
protocol: TCP
|
||||
- name: "grpc"
|
||||
port: {{ .Values.admin.grpcPort }}
|
||||
targetPort: {{ .Values.admin.grpcPort }}
|
||||
protocol: TCP
|
||||
{{- if .Values.admin.metricsPort }}
|
||||
- name: "metrics"
|
||||
port: {{ .Values.admin.metricsPort }}
|
||||
targetPort: {{ .Values.admin.metricsPort }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: admin
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{- if .Values.admin.enabled }}
|
||||
{{- if .Values.admin.metricsPort }}
|
||||
{{- if .Values.global.monitoring.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-admin
|
||||
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: admin
|
||||
{{- with .Values.global.monitoring.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.admin.serviceMonitor.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
endpoints:
|
||||
- interval: 30s
|
||||
port: metrics
|
||||
scrapeTimeout: 5s
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/component: admin
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,339 @@
|
||||
{{- if .Values.admin.enabled }}
|
||||
{{- if and (not .Values.admin.masters) (not .Values.global.masterServer) (not .Values.master.enabled) }}
|
||||
{{- fail "admin.masters or global.masterServer must be set if master.enabled is false" -}}
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-admin
|
||||
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: admin
|
||||
{{- if .Values.admin.annotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.admin.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceName: {{ template "seaweedfs.name" . }}-admin
|
||||
podManagementPolicy: {{ .Values.admin.podManagementPolicy }}
|
||||
replicas: {{ .Values.admin.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: admin
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: admin
|
||||
{{ with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.admin.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{ with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.admin.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: {{ default .Values.global.restartPolicy .Values.admin.restartPolicy }}
|
||||
{{- if .Values.admin.affinity }}
|
||||
affinity:
|
||||
{{ tpl .Values.admin.affinity . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{ tpl .Values.admin.topologySpreadConstraints . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.tolerations }}
|
||||
tolerations:
|
||||
{{ tpl .Values.admin.tolerations . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- include "seaweedfs.imagePullSecrets" . | nindent 6 }}
|
||||
terminationGracePeriodSeconds: 30
|
||||
{{- if .Values.admin.priorityClassName }}
|
||||
priorityClassName: {{ .Values.admin.priorityClassName | quote }}
|
||||
{{- end }}
|
||||
enableServiceLinks: false
|
||||
{{- if .Values.admin.serviceAccountName }}
|
||||
serviceAccountName: {{ .Values.admin.serviceAccountName | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.initContainers }}
|
||||
initContainers:
|
||||
{{ tpl .Values.admin.initContainers . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.podSecurityContext.enabled }}
|
||||
securityContext: {{- omit .Values.admin.podSecurityContext "enabled" | toYaml | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "admin.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.imagePullPolicy }}
|
||||
{{- $adminAuthEnabled := or .Values.admin.secret.existingSecret .Values.admin.secret.adminPassword }}
|
||||
{{- if and .Values.admin.secret.existingSecret (not .Values.admin.secret.userKey) -}}
|
||||
{{- fail "admin.secret.userKey must be set when admin.secret.existingSecret is provided" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.admin.secret.existingSecret (not .Values.admin.secret.pwKey) -}}
|
||||
{{- fail "admin.secret.pwKey must be set when admin.secret.existingSecret is provided" -}}
|
||||
{{- end -}}
|
||||
{{- $adminSecretName := .Values.admin.secret.existingSecret | default (printf "%s-admin-secret" (include "seaweedfs.name" .)) }}
|
||||
env:
|
||||
{{- if $adminAuthEnabled }}
|
||||
- name: SEAWEEDFS_ADMIN_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
key: {{ if .Values.admin.secret.existingSecret }}{{ .Values.admin.secret.userKey }}{{ else }}adminUser{{ end }}
|
||||
- name: SEAWEEDFS_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
key: {{ if .Values.admin.secret.existingSecret }}{{ .Values.admin.secret.pwKey }}{{ else }}adminPassword{{ end }}
|
||||
{{- end }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: SEAWEEDFS_FULLNAME
|
||||
value: "{{ template "seaweedfs.name" . }}"
|
||||
{{- if .Values.admin.extraEnvironmentVars }}
|
||||
{{- range $key, $value := .Values.admin.extraEnvironmentVars }}
|
||||
- name: {{ $key }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ $value | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
{{ toYaml $value | nindent 16 | trim }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.global.extraEnvironmentVars }}
|
||||
{{- range $key, $value := .Values.global.extraEnvironmentVars }}
|
||||
- name: {{ $key }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ $value | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
{{ toYaml $value | nindent 16 | trim }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-ec"
|
||||
- |
|
||||
exec /usr/bin/weed \
|
||||
{{- if or (eq .Values.admin.logs.type "hostPath") (eq .Values.admin.logs.type "persistentVolumeClaim") (eq .Values.admin.logs.type "emptyDir") (eq .Values.admin.logs.type "existingClaim") }}
|
||||
-logdir=/logs \
|
||||
{{- else }}
|
||||
-logtostderr=true \
|
||||
{{- end }}
|
||||
{{- if .Values.admin.loggingOverrideLevel }}
|
||||
-v={{ .Values.admin.loggingOverrideLevel }} \
|
||||
{{- else }}
|
||||
-v={{ .Values.global.loggingLevel }} \
|
||||
{{- end }}
|
||||
admin \
|
||||
-port={{ .Values.admin.port }} \
|
||||
-port.grpc={{ .Values.admin.grpcPort }} \
|
||||
{{- if or (eq .Values.admin.data.type "hostPath") (eq .Values.admin.data.type "persistentVolumeClaim") (eq .Values.admin.data.type "emptyDir") (eq .Values.admin.data.type "existingClaim") }}
|
||||
-dataDir=/data \
|
||||
{{- else if .Values.admin.dataDir }}
|
||||
-dataDir={{ .Values.admin.dataDir }} \
|
||||
{{- end }}
|
||||
{{- if $adminAuthEnabled }}
|
||||
-adminUser="${SEAWEEDFS_ADMIN_USER}" \
|
||||
-adminPassword="${SEAWEEDFS_ADMIN_PASSWORD}" \
|
||||
{{- end }}
|
||||
{{- if .Values.admin.masters }}
|
||||
-masters={{ .Values.admin.masters }}{{- if .Values.admin.extraArgs }} \{{ end }}
|
||||
{{- else if .Values.global.masterServer }}
|
||||
-masters={{ .Values.global.masterServer }}{{- if .Values.admin.extraArgs }} \{{ end }}
|
||||
{{- else }}
|
||||
-masters={{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{- if .Values.admin.extraArgs }} \{{ end }}
|
||||
{{- end }}
|
||||
{{- range $index, $arg := .Values.admin.extraArgs }}
|
||||
{{ $arg }}{{- if lt $index (sub (len $.Values.admin.extraArgs) 1) }} \{{ end }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
{{- if or (eq .Values.admin.data.type "hostPath") (eq .Values.admin.data.type "persistentVolumeClaim") (eq .Values.admin.data.type "emptyDir") (eq .Values.admin.data.type "existingClaim") }}
|
||||
- name: admin-data
|
||||
mountPath: /data
|
||||
{{- end }}
|
||||
{{- if or (eq .Values.admin.logs.type "hostPath") (eq .Values.admin.logs.type "persistentVolumeClaim") (eq .Values.admin.logs.type "emptyDir") (eq .Values.admin.logs.type "existingClaim") }}
|
||||
- name: admin-logs
|
||||
mountPath: /logs
|
||||
{{- end }}
|
||||
{{- if .Values.global.enableSecurity }}
|
||||
- name: security-config
|
||||
readOnly: true
|
||||
mountPath: /etc/seaweedfs/security.toml
|
||||
subPath: security.toml
|
||||
- name: ca-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/ca/
|
||||
- name: master-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/master/
|
||||
- name: volume-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/volume/
|
||||
- name: filer-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/filer/
|
||||
- name: client-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/client/
|
||||
{{- end }}
|
||||
{{ tpl .Values.admin.extraVolumeMounts . | nindent 12 | trim }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.admin.port }}
|
||||
name: http
|
||||
- containerPort: {{ .Values.admin.grpcPort }}
|
||||
name: grpc
|
||||
{{- if .Values.admin.metricsPort }}
|
||||
- containerPort: {{ .Values.admin.metricsPort }}
|
||||
name: metrics
|
||||
{{- end }}
|
||||
{{- if .Values.admin.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.admin.readinessProbe.httpGet.path }}
|
||||
port: http
|
||||
scheme: {{ .Values.admin.readinessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.admin.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.admin.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.admin.readinessProbe.successThreshold }}
|
||||
failureThreshold: {{ .Values.admin.readinessProbe.failureThreshold }}
|
||||
timeoutSeconds: {{ .Values.admin.readinessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.admin.livenessProbe.httpGet.path }}
|
||||
port: http
|
||||
scheme: {{ .Values.admin.livenessProbe.httpGet.scheme }}
|
||||
initialDelaySeconds: {{ .Values.admin.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.admin.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.admin.livenessProbe.successThreshold }}
|
||||
failureThreshold: {{ .Values.admin.livenessProbe.failureThreshold }}
|
||||
timeoutSeconds: {{ .Values.admin.livenessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- with .Values.admin.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.containerSecurityContext.enabled }}
|
||||
securityContext: {{- omit .Values.admin.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.sidecars }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.admin.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.admin.data.type "hostPath" }}
|
||||
- name: admin-data
|
||||
hostPath:
|
||||
path: {{ .Values.admin.data.hostPathPrefix }}/seaweedfs-admin-data
|
||||
type: DirectoryOrCreate
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.data.type "emptyDir" }}
|
||||
- name: admin-data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.data.type "existingClaim" }}
|
||||
- name: admin-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.admin.data.claimName }}
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.logs.type "hostPath" }}
|
||||
- name: admin-logs
|
||||
hostPath:
|
||||
path: {{ .Values.admin.logs.hostPathPrefix }}/logs/seaweedfs/admin
|
||||
type: DirectoryOrCreate
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.logs.type "emptyDir" }}
|
||||
- name: admin-logs
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.logs.type "existingClaim" }}
|
||||
- name: admin-logs
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.admin.logs.claimName }}
|
||||
{{- end }}
|
||||
{{- if .Values.global.enableSecurity }}
|
||||
- name: security-config
|
||||
configMap:
|
||||
name: {{ template "seaweedfs.name" . }}-security-config
|
||||
- name: ca-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-ca-cert
|
||||
- name: master-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-master-cert
|
||||
- name: volume-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-volume-cert
|
||||
- name: filer-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-filer-cert
|
||||
- name: client-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-client-cert
|
||||
{{- end }}
|
||||
{{ tpl .Values.admin.extraVolumes . | indent 8 | trim }}
|
||||
{{- if .Values.admin.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{ tpl .Values.admin.nodeSelector . | indent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- $pvc_exists := include "admin.pvc_exists" . -}}
|
||||
{{- if $pvc_exists }}
|
||||
volumeClaimTemplates:
|
||||
{{- if eq .Values.admin.data.type "persistentVolumeClaim" }}
|
||||
- metadata:
|
||||
name: admin-data
|
||||
{{- with .Values.admin.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
storageClassName: {{ .Values.admin.data.storageClass }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.admin.data.size }}
|
||||
{{- end }}
|
||||
{{- if eq .Values.admin.logs.type "persistentVolumeClaim" }}
|
||||
- metadata:
|
||||
name: admin-logs
|
||||
{{- with .Values.admin.logs.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
storageClassName: {{ .Values.admin.logs.storageClass }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.admin.logs.size }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -83,6 +83,26 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper admin image */}}
|
||||
{{- define "admin.image" -}}
|
||||
{{- if .Values.admin.imageOverride -}}
|
||||
{{- $imageOverride := .Values.admin.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper worker image */}}
|
||||
{{- define "worker.image" -}}
|
||||
{{- if .Values.worker.imageOverride -}}
|
||||
{{- $imageOverride := .Values.worker.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper volume image */}}
|
||||
{{- define "volume.image" -}}
|
||||
{{- if .Values.volume.imageOverride -}}
|
||||
@@ -136,6 +156,15 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any Admin PVC exists */}}
|
||||
{{- define "admin.pvc_exists" -}}
|
||||
{{- if or (eq .Values.admin.data.type "persistentVolumeClaim") (eq .Values.admin.logs.type "persistentVolumeClaim") -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
{{- printf "" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any InitContainers exist for Volumes */}}
|
||||
{{- define "volume.initContainers_exists" -}}
|
||||
{{- if or (not (empty .Values.volume.idx )) (not (empty .Values.volume.initContainers )) -}}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
{{- if .Values.worker.enabled }}
|
||||
{{- if and (not .Values.worker.adminServer) (not .Values.admin.enabled) }}
|
||||
{{- fail "worker.adminServer must be set if admin.enabled is false within the same release" -}}
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-worker
|
||||
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: worker
|
||||
{{- if .Values.worker.annotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.worker.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{ with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{ with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: {{ default .Values.global.restartPolicy .Values.worker.restartPolicy }}
|
||||
{{- if .Values.worker.affinity }}
|
||||
affinity:
|
||||
{{ tpl .Values.worker.affinity . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{ tpl .Values.worker.topologySpreadConstraints . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.tolerations }}
|
||||
tolerations:
|
||||
{{ tpl .Values.worker.tolerations . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- include "seaweedfs.imagePullSecrets" . | nindent 6 }}
|
||||
terminationGracePeriodSeconds: 60
|
||||
{{- if .Values.worker.priorityClassName }}
|
||||
priorityClassName: {{ .Values.worker.priorityClassName | quote }}
|
||||
{{- end }}
|
||||
enableServiceLinks: false
|
||||
{{- if .Values.worker.serviceAccountName }}
|
||||
serviceAccountName: {{ .Values.worker.serviceAccountName | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.initContainers }}
|
||||
initContainers:
|
||||
{{ tpl .Values.worker.initContainers . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.podSecurityContext.enabled }}
|
||||
securityContext: {{- omit .Values.worker.podSecurityContext "enabled" | toYaml | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "worker.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: SEAWEEDFS_FULLNAME
|
||||
value: "{{ template "seaweedfs.name" . }}"
|
||||
{{- if .Values.worker.extraEnvironmentVars }}
|
||||
{{- range $key, $value := .Values.worker.extraEnvironmentVars }}
|
||||
- name: {{ $key }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ $value | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
{{ toYaml $value | nindent 16 | trim }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.global.extraEnvironmentVars }}
|
||||
{{- range $key, $value := .Values.global.extraEnvironmentVars }}
|
||||
- name: {{ $key }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ $value | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
{{ toYaml $value | nindent 16 | trim }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-ec"
|
||||
- |
|
||||
exec /usr/bin/weed \
|
||||
{{- if or (eq .Values.worker.logs.type "hostPath") (eq .Values.worker.logs.type "emptyDir") (eq .Values.worker.logs.type "existingClaim") }}
|
||||
-logdir=/logs \
|
||||
{{- else }}
|
||||
-logtostderr=true \
|
||||
{{- end }}
|
||||
{{- if .Values.worker.loggingOverrideLevel }}
|
||||
-v={{ .Values.worker.loggingOverrideLevel }} \
|
||||
{{- else }}
|
||||
-v={{ .Values.global.loggingLevel }} \
|
||||
{{- end }}
|
||||
worker \
|
||||
{{- if .Values.worker.adminServer }}
|
||||
-admin={{ .Values.worker.adminServer }} \
|
||||
{{- else }}
|
||||
-admin={{ template "seaweedfs.name" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.grpcPort }} \
|
||||
{{- end }}
|
||||
-capabilities={{ .Values.worker.capabilities }} \
|
||||
-maxConcurrent={{ .Values.worker.maxConcurrent }} \
|
||||
-workingDir={{ .Values.worker.workingDir }}{{- if .Values.worker.extraArgs }} \{{ end }}
|
||||
{{- range $index, $arg := .Values.worker.extraArgs }}
|
||||
{{ $arg }}{{- if lt $index (sub (len $.Values.worker.extraArgs) 1) }} \{{ end }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
{{- if or (eq .Values.worker.data.type "hostPath") (eq .Values.worker.data.type "emptyDir") (eq .Values.worker.data.type "existingClaim") }}
|
||||
- name: worker-data
|
||||
mountPath: {{ .Values.worker.workingDir }}
|
||||
{{- end }}
|
||||
{{- if or (eq .Values.worker.logs.type "hostPath") (eq .Values.worker.logs.type "emptyDir") (eq .Values.worker.logs.type "existingClaim") }}
|
||||
- name: worker-logs
|
||||
mountPath: /logs
|
||||
{{- end }}
|
||||
{{- if .Values.global.enableSecurity }}
|
||||
- name: security-config
|
||||
readOnly: true
|
||||
mountPath: /etc/seaweedfs/security.toml
|
||||
subPath: security.toml
|
||||
- name: ca-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/ca/
|
||||
- name: master-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/master/
|
||||
- name: volume-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/volume/
|
||||
- name: filer-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/filer/
|
||||
- name: client-cert
|
||||
readOnly: true
|
||||
mountPath: /usr/local/share/ca-certificates/client/
|
||||
{{- end }}
|
||||
{{ tpl .Values.worker.extraVolumeMounts . | nindent 12 | trim }}
|
||||
ports:
|
||||
{{- if .Values.worker.metricsPort }}
|
||||
- containerPort: {{ .Values.worker.metricsPort }}
|
||||
name: metrics
|
||||
{{- end }}
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
{{- with .Values.worker.livenessProbe.tcpSocket }}
|
||||
tcpSocket:
|
||||
port: {{ .port }}
|
||||
{{- end }}
|
||||
initialDelaySeconds: {{ .Values.worker.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.livenessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.worker.livenessProbe.successThreshold }}
|
||||
failureThreshold: {{ .Values.worker.livenessProbe.failureThreshold }}
|
||||
timeoutSeconds: {{ .Values.worker.livenessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
{{- with .Values.worker.readinessProbe.tcpSocket }}
|
||||
tcpSocket:
|
||||
port: {{ .port }}
|
||||
{{- end }}
|
||||
initialDelaySeconds: {{ .Values.worker.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.readinessProbe.periodSeconds }}
|
||||
successThreshold: {{ .Values.worker.readinessProbe.successThreshold }}
|
||||
failureThreshold: {{ .Values.worker.readinessProbe.failureThreshold }}
|
||||
timeoutSeconds: {{ .Values.worker.readinessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.containerSecurityContext.enabled }}
|
||||
securityContext: {{- omit .Values.worker.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.sidecars }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.worker.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.worker.data.type "hostPath" }}
|
||||
- name: worker-data
|
||||
hostPath:
|
||||
path: {{ .Values.worker.data.hostPathPrefix }}/seaweedfs-worker-data
|
||||
type: DirectoryOrCreate
|
||||
{{- end }}
|
||||
{{- if eq .Values.worker.data.type "emptyDir" }}
|
||||
- name: worker-data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if eq .Values.worker.data.type "existingClaim" }}
|
||||
- name: worker-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.worker.data.claimName }}
|
||||
{{- end }}
|
||||
{{- if eq .Values.worker.logs.type "hostPath" }}
|
||||
- name: worker-logs
|
||||
hostPath:
|
||||
path: {{ .Values.worker.logs.hostPathPrefix }}/logs/seaweedfs/worker
|
||||
type: DirectoryOrCreate
|
||||
{{- end }}
|
||||
{{- if eq .Values.worker.logs.type "emptyDir" }}
|
||||
- name: worker-logs
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if eq .Values.worker.logs.type "existingClaim" }}
|
||||
- name: worker-logs
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.worker.logs.claimName }}
|
||||
{{- end }}
|
||||
{{- if .Values.global.enableSecurity }}
|
||||
- name: security-config
|
||||
configMap:
|
||||
name: {{ template "seaweedfs.name" . }}-security-config
|
||||
- name: ca-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-ca-cert
|
||||
- name: master-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-master-cert
|
||||
- name: volume-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-volume-cert
|
||||
- name: filer-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-filer-cert
|
||||
- name: client-cert
|
||||
secret:
|
||||
secretName: {{ template "seaweedfs.name" . }}-client-cert
|
||||
{{- end }}
|
||||
{{ tpl .Values.worker.extraVolumes . | indent 8 | trim }}
|
||||
{{- if .Values.worker.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{ tpl .Values.worker.nodeSelector . | indent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-worker
|
||||
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: worker
|
||||
spec:
|
||||
clusterIP: None # Headless service
|
||||
{{- if .Values.worker.metricsPort }}
|
||||
ports:
|
||||
- name: "metrics"
|
||||
port: {{ .Values.worker.metricsPort }}
|
||||
targetPort: {{ .Values.worker.metricsPort }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{- end }}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{- if .Values.worker.enabled }}
|
||||
{{- if .Values.worker.metricsPort }}
|
||||
{{- if .Values.global.monitoring.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ template "seaweedfs.name" . }}-worker
|
||||
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: worker
|
||||
{{- with .Values.global.monitoring.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.serviceMonitor.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
endpoints:
|
||||
- interval: 30s
|
||||
port: metrics
|
||||
scrapeTimeout: 5s
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1088,6 +1088,245 @@ sftp:
|
||||
failureThreshold: 100
|
||||
timeoutSeconds: 10
|
||||
|
||||
admin:
|
||||
enabled: false
|
||||
imageOverride: null
|
||||
restartPolicy: null
|
||||
replicas: 1
|
||||
port: 23646 # Default admin port
|
||||
grpcPort: 33646 # Default gRPC port for worker connections
|
||||
metricsPort: 9327
|
||||
loggingOverrideLevel: null
|
||||
|
||||
# Admin authentication
|
||||
secret:
|
||||
# Name of an existing secret containing admin credentials. If set, adminUser and adminPassword below are ignored.
|
||||
existingSecret: ""
|
||||
# Key in the existing secret for the admin username. Required if existingSecret is set.
|
||||
userKey: ""
|
||||
# Key in the existing secret for the admin password. Required if existingSecret is set.
|
||||
pwKey: ""
|
||||
adminUser: "admin"
|
||||
adminPassword: "" # If empty, authentication is disabled.
|
||||
|
||||
# Data directory for admin configuration and maintenance data
|
||||
dataDir: "" # If empty, configuration is kept in memory only
|
||||
|
||||
# Master servers to connect to
|
||||
# If empty, uses global.masterServer or auto-discovers from master statefulset
|
||||
masters: ""
|
||||
|
||||
# Custom command line arguments to add to the admin command
|
||||
# Example: ["-customFlag", "value", "-anotherFlag"]
|
||||
extraArgs: []
|
||||
|
||||
# Storage configuration
|
||||
data:
|
||||
type: "emptyDir" # Options: "hostPath", "persistentVolumeClaim", "emptyDir", "existingClaim"
|
||||
size: "10Gi"
|
||||
storageClass: ""
|
||||
hostPathPrefix: /storage
|
||||
claimName: ""
|
||||
annotations: {}
|
||||
|
||||
logs:
|
||||
type: "emptyDir" # Options: "hostPath", "persistentVolumeClaim", "emptyDir", "existingClaim"
|
||||
size: "5Gi"
|
||||
storageClass: ""
|
||||
hostPathPrefix: /storage
|
||||
claimName: ""
|
||||
annotations: {}
|
||||
|
||||
# Additional resources
|
||||
sidecars: []
|
||||
initContainers: ""
|
||||
extraVolumes: ""
|
||||
extraVolumeMounts: ""
|
||||
podLabels: {}
|
||||
podAnnotations: {}
|
||||
annotations: {}
|
||||
|
||||
## Set podManagementPolicy
|
||||
podManagementPolicy: Parallel
|
||||
|
||||
# Affinity Settings
|
||||
# Commenting out or setting as empty the affinity variable, will allow
|
||||
# deployment to single node services such as Minikube
|
||||
affinity: |
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: admin
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
# Topology Spread Constraints Settings
|
||||
# This should map directly to the value of the topologySpreadConstraints
|
||||
# for a PodSpec. By Default no constraints are set.
|
||||
topologySpreadConstraints: ""
|
||||
|
||||
resources: {}
|
||||
tolerations: ""
|
||||
nodeSelector: ""
|
||||
priorityClassName: ""
|
||||
serviceAccountName: ""
|
||||
podSecurityContext: {}
|
||||
containerSecurityContext: {}
|
||||
|
||||
extraEnvironmentVars: {}
|
||||
|
||||
# Health checks
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 60
|
||||
successThreshold: 1
|
||||
failureThreshold: 5
|
||||
timeoutSeconds: 10
|
||||
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 15
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
timeoutSeconds: 10
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: "nginx"
|
||||
# host: false for "*" hostname
|
||||
host: "admin.seaweedfs.local"
|
||||
path: "/"
|
||||
pathType: Prefix
|
||||
annotations: {}
|
||||
tls: []
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
annotations: {}
|
||||
|
||||
# ServiceMonitor annotations (separate from pod/deployment annotations)
|
||||
serviceMonitor:
|
||||
annotations: {}
|
||||
|
||||
worker:
|
||||
enabled: false
|
||||
imageOverride: null
|
||||
restartPolicy: null
|
||||
replicas: 1
|
||||
loggingOverrideLevel: null
|
||||
metricsPort: 9327
|
||||
|
||||
# Admin server to connect to
|
||||
# Format: "host:port" or auto-discover from admin service
|
||||
adminServer: ""
|
||||
|
||||
# Worker capabilities - comma-separated list
|
||||
# Available: vacuum, balance, erasure_coding
|
||||
# Default: "vacuum,balance,erasure_coding" (all capabilities)
|
||||
capabilities: "vacuum,balance,erasure_coding"
|
||||
|
||||
# Maximum number of concurrent tasks
|
||||
maxConcurrent: 3
|
||||
|
||||
# Working directory for task execution
|
||||
workingDir: "/tmp/seaweedfs-worker"
|
||||
|
||||
# Custom command line arguments to add to the worker command
|
||||
# Example: ["-customFlag", "value", "-anotherFlag"]
|
||||
extraArgs: []
|
||||
|
||||
# Storage configuration for working directory
|
||||
# Note: Workers use Deployment, so use "emptyDir", "hostPath", or "existingClaim"
|
||||
# Do NOT use "persistentVolumeClaim" - use "existingClaim" with pre-provisioned PVC instead
|
||||
data:
|
||||
type: "emptyDir" # Options: "hostPath", "emptyDir", "existingClaim"
|
||||
hostPathPrefix: /storage
|
||||
claimName: "" # For existingClaim type
|
||||
|
||||
logs:
|
||||
type: "emptyDir" # Options: "hostPath", "emptyDir", "existingClaim"
|
||||
hostPathPrefix: /storage
|
||||
claimName: "" # For existingClaim type
|
||||
|
||||
# Additional resources
|
||||
sidecars: []
|
||||
initContainers: ""
|
||||
extraVolumes: ""
|
||||
extraVolumeMounts: ""
|
||||
podLabels: {}
|
||||
podAnnotations: {}
|
||||
annotations: {}
|
||||
|
||||
# Affinity Settings
|
||||
# Commenting out or setting as empty the affinity variable, will allow
|
||||
# deployment to single node services such as Minikube
|
||||
affinity: |
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: worker
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
# Topology Spread Constraints Settings
|
||||
# This should map directly to the value of the topologySpreadConstraints
|
||||
# for a PodSpec. By Default no constraints are set.
|
||||
topologySpreadConstraints: ""
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "2Gi"
|
||||
tolerations: ""
|
||||
nodeSelector: ""
|
||||
priorityClassName: ""
|
||||
serviceAccountName: ""
|
||||
podSecurityContext: {}
|
||||
containerSecurityContext: {}
|
||||
|
||||
extraEnvironmentVars: {}
|
||||
|
||||
# Health checks for worker pods
|
||||
# Since workers do not have an HTTP endpoint, a tcpSocket probe on the metrics port is recommended.
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
tcpSocket:
|
||||
port: metrics
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 60
|
||||
successThreshold: 1
|
||||
failureThreshold: 5
|
||||
timeoutSeconds: 10
|
||||
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
tcpSocket:
|
||||
port: metrics
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 15
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
timeoutSeconds: 10
|
||||
|
||||
# ServiceMonitor annotations (separate from pod/deployment annotations)
|
||||
serviceMonitor:
|
||||
annotations: {}
|
||||
|
||||
# All-in-one deployment configuration
|
||||
allInOne:
|
||||
enabled: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
.PHONY: test test-short build-weed clean
|
||||
|
||||
# Build weed binary first if needed
|
||||
build-weed:
|
||||
cd ../../weed && go build -o weed .
|
||||
|
||||
# Run all integration tests
|
||||
test: build-weed
|
||||
go test -v -timeout 5m .
|
||||
|
||||
# Run tests in short mode (skip integration tests)
|
||||
test-short:
|
||||
go test -v -short .
|
||||
|
||||
# Run specific test
|
||||
test-basic: build-weed
|
||||
go test -v -timeout 3m -run TestMetadataSubscribeBasic .
|
||||
|
||||
test-stall: build-weed
|
||||
go test -v -timeout 5m -run TestMetadataSubscribeSingleFilerNoStall .
|
||||
|
||||
test-resume: build-weed
|
||||
go test -v -timeout 3m -run TestMetadataSubscribeResumeFromDisk .
|
||||
|
||||
clean:
|
||||
rm -f ../../weed/weed
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Metadata Subscribe Integration Tests
|
||||
|
||||
This directory contains integration tests for the SeaweedFS metadata subscription functionality.
|
||||
|
||||
## Tests
|
||||
|
||||
### TestMetadataSubscribeBasic
|
||||
Tests basic metadata subscription functionality:
|
||||
- Start a SeaweedFS cluster (master, volume, filer)
|
||||
- Subscribe to metadata changes
|
||||
- Upload files and verify events are received
|
||||
|
||||
### TestMetadataSubscribeSingleFilerNoStall
|
||||
Regression test for [issue #4977](https://github.com/seaweedfs/seaweedfs/issues/4977):
|
||||
- Tests that metadata subscription doesn't stall in single-filer setups
|
||||
- Simulates high-load file uploads while a subscriber tries to keep up
|
||||
- Verifies that events are received without significant stalling
|
||||
|
||||
The bug was that in single-filer setups, `SubscribeMetadata` would block indefinitely
|
||||
on `MetaAggregator.MetaLogBuffer` which remains empty (no peers to aggregate from).
|
||||
The fix ensures that when the buffer is empty, the subscription returns to read from
|
||||
persisted logs on disk.
|
||||
|
||||
### TestMetadataSubscribeResumeFromDisk
|
||||
Tests that subscription can resume from disk:
|
||||
- Upload files before starting subscription
|
||||
- Wait for logs to be flushed to disk
|
||||
- Start subscription from the beginning
|
||||
- Verify pre-uploaded files are received from disk
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (requires weed binary in PATH or built)
|
||||
go test -v ./test/metadata_subscribe/...
|
||||
|
||||
# Skip integration tests
|
||||
go test -short ./test/metadata_subscribe/...
|
||||
|
||||
# Run with increased timeout for slow systems
|
||||
go test -v -timeout 5m ./test/metadata_subscribe/...
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- `weed` binary must be available in PATH or in the parent directories
|
||||
- Tests create temporary directories that are cleaned up after completion
|
||||
- Tests use ports 9333 (master), 8080 (volume), 8888 (filer)
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
package metadata_subscribe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// TestMetadataSubscribeBasic tests basic metadata subscription functionality
|
||||
func TestMetadataSubscribeBasic(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_metadata_subscribe_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startSeaweedFSCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Wait for servers to be ready
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
|
||||
|
||||
t.Logf("SeaweedFS cluster started successfully")
|
||||
|
||||
t.Run("subscribe_and_receive_events", func(t *testing.T) {
|
||||
// Create a channel to receive events
|
||||
eventsChan := make(chan *filer_pb.SubscribeMetadataResponse, 100)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
// Start subscribing in a goroutine
|
||||
subCtx, subCancel := context.WithCancel(ctx)
|
||||
defer subCancel()
|
||||
|
||||
go func() {
|
||||
err := subscribeToMetadata(subCtx, "127.0.0.1:8888", "/", eventsChan)
|
||||
if err != nil && !strings.Contains(err.Error(), "context canceled") {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for subscription to be established
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Create test files via HTTP
|
||||
testFiles := []string{
|
||||
"/test/file1.txt",
|
||||
"/test/file2.txt",
|
||||
"/test/subdir/file3.txt",
|
||||
}
|
||||
|
||||
for _, path := range testFiles {
|
||||
err := uploadFile("http://127.0.0.1:8888"+path, []byte("test content for "+path))
|
||||
require.NoError(t, err, "Failed to upload %s", path)
|
||||
t.Logf("Uploaded %s", path)
|
||||
}
|
||||
|
||||
// Collect events with timeout
|
||||
receivedPaths := make(map[string]bool)
|
||||
timeout := time.After(30 * time.Second)
|
||||
|
||||
eventLoop:
|
||||
for {
|
||||
select {
|
||||
case event := <-eventsChan:
|
||||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil {
|
||||
path := filepath.Join(event.Directory, event.EventNotification.NewEntry.Name)
|
||||
t.Logf("Received event for: %s", path)
|
||||
receivedPaths[path] = true
|
||||
}
|
||||
// Check if we received all expected events
|
||||
allReceived := true
|
||||
for _, p := range testFiles {
|
||||
if !receivedPaths[p] {
|
||||
allReceived = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allReceived {
|
||||
break eventLoop
|
||||
}
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Subscription error: %v", err)
|
||||
case <-timeout:
|
||||
t.Logf("Timeout waiting for events. Received %d/%d events", len(receivedPaths), len(testFiles))
|
||||
break eventLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we received events for all test files
|
||||
for _, path := range testFiles {
|
||||
assert.True(t, receivedPaths[path], "Should have received event for %s", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetadataSubscribeSingleFilerNoStall tests that subscription doesn't stall
|
||||
// in single-filer setups (regression test for issue #4977)
|
||||
func TestMetadataSubscribeSingleFilerNoStall(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_single_filer_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startSeaweedFSCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Wait for servers to be ready
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
|
||||
|
||||
t.Logf("Single-filer cluster started")
|
||||
|
||||
t.Run("high_load_subscription_no_stall", func(t *testing.T) {
|
||||
// This test simulates the scenario from issue #4977:
|
||||
// High-load writes while a subscriber tries to keep up
|
||||
|
||||
var receivedCount int64
|
||||
var uploadedCount int64
|
||||
|
||||
eventsChan := make(chan *filer_pb.SubscribeMetadataResponse, 1000)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(ctx)
|
||||
defer subCancel()
|
||||
|
||||
// Start subscriber
|
||||
go func() {
|
||||
err := subscribeToMetadata(subCtx, "127.0.0.1:8888", "/", eventsChan)
|
||||
if err != nil && !strings.Contains(err.Error(), "context canceled") {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for subscription to be established
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start counting received events
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case event := <-eventsChan:
|
||||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil {
|
||||
if !event.EventNotification.NewEntry.IsDirectory {
|
||||
atomic.AddInt64(&receivedCount, 1)
|
||||
}
|
||||
}
|
||||
case <-subCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Upload files concurrently (simulate high load)
|
||||
numFiles := 100
|
||||
numWorkers := 10
|
||||
|
||||
uploadWg := sync.WaitGroup{}
|
||||
for w := 0; w < numWorkers; w++ {
|
||||
uploadWg.Add(1)
|
||||
go func(workerId int) {
|
||||
defer uploadWg.Done()
|
||||
for i := 0; i < numFiles/numWorkers; i++ {
|
||||
path := fmt.Sprintf("/load_test/worker%d/file%d.txt", workerId, i)
|
||||
err := uploadFile("http://127.0.0.1:8888"+path, []byte(fmt.Sprintf("content %d-%d", workerId, i)))
|
||||
if err == nil {
|
||||
atomic.AddInt64(&uploadedCount, 1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
uploadWg.Wait()
|
||||
uploaded := atomic.LoadInt64(&uploadedCount)
|
||||
t.Logf("Uploaded %d files", uploaded)
|
||||
|
||||
// Wait for events to be received (with timeout to detect stall)
|
||||
stallTimeout := time.After(60 * time.Second)
|
||||
checkInterval := time.NewTicker(2 * time.Second)
|
||||
defer checkInterval.Stop()
|
||||
|
||||
lastReceived := atomic.LoadInt64(&receivedCount)
|
||||
staleCount := 0
|
||||
|
||||
waitLoop:
|
||||
for {
|
||||
select {
|
||||
case <-stallTimeout:
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
t.Logf("Timeout: received %d/%d events (%.1f%%)",
|
||||
received, uploaded, float64(received)/float64(uploaded)*100)
|
||||
break waitLoop
|
||||
case <-checkInterval.C:
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
if received >= uploaded {
|
||||
t.Logf("All %d events received", received)
|
||||
break waitLoop
|
||||
}
|
||||
if received == lastReceived {
|
||||
staleCount++
|
||||
if staleCount >= 5 {
|
||||
// If no progress for 10 seconds, subscription may be stalled
|
||||
t.Logf("WARNING: No progress for %d checks. Received %d/%d (%.1f%%)",
|
||||
staleCount, received, uploaded, float64(received)/float64(uploaded)*100)
|
||||
}
|
||||
} else {
|
||||
staleCount = 0
|
||||
t.Logf("Progress: received %d/%d events (%.1f%%)",
|
||||
received, uploaded, float64(received)/float64(uploaded)*100)
|
||||
}
|
||||
lastReceived = received
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Subscription error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
subCancel()
|
||||
wg.Wait()
|
||||
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
|
||||
// With the fix for #4977, we should receive a high percentage of events
|
||||
// Before the fix, this would stall at ~20-40%
|
||||
percentage := float64(received) / float64(uploaded) * 100
|
||||
t.Logf("Final: received %d/%d events (%.1f%%)", received, uploaded, percentage)
|
||||
|
||||
// We should receive at least 80% of events (allowing for some timing issues)
|
||||
assert.GreaterOrEqual(t, percentage, 80.0,
|
||||
"Should receive at least 80%% of events (received %.1f%%)", percentage)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetadataSubscribeResumeFromDisk tests that subscription can resume from disk
|
||||
func TestMetadataSubscribeResumeFromDisk(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_resume_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startSeaweedFSCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
|
||||
|
||||
t.Run("upload_before_subscribe", func(t *testing.T) {
|
||||
// Upload files BEFORE starting subscription
|
||||
numFiles := 20
|
||||
for i := 0; i < numFiles; i++ {
|
||||
path := fmt.Sprintf("/pre_subscribe/file%d.txt", i)
|
||||
err := uploadFile("http://127.0.0.1:8888"+path, []byte(fmt.Sprintf("content %d", i)))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Logf("Uploaded %d files before subscription", numFiles)
|
||||
|
||||
// Wait for logs to be flushed to disk
|
||||
time.Sleep(15 * time.Second)
|
||||
|
||||
// Now start subscription from the beginning
|
||||
eventsChan := make(chan *filer_pb.SubscribeMetadataResponse, 100)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
subCtx, subCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer subCancel()
|
||||
|
||||
go func() {
|
||||
err := subscribeToMetadataFromBeginning(subCtx, "127.0.0.1:8888", "/pre_subscribe/", eventsChan)
|
||||
if err != nil && !strings.Contains(err.Error(), "context") {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Count received events
|
||||
receivedCount := 0
|
||||
timeout := time.After(30 * time.Second)
|
||||
|
||||
countLoop:
|
||||
for {
|
||||
select {
|
||||
case event := <-eventsChan:
|
||||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil {
|
||||
if !event.EventNotification.NewEntry.IsDirectory {
|
||||
receivedCount++
|
||||
t.Logf("Received event %d: %s/%s", receivedCount,
|
||||
event.Directory, event.EventNotification.NewEntry.Name)
|
||||
}
|
||||
}
|
||||
if receivedCount >= numFiles {
|
||||
break countLoop
|
||||
}
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Subscription error: %v", err)
|
||||
case <-timeout:
|
||||
t.Logf("Timeout: received %d/%d events", receivedCount, numFiles)
|
||||
break countLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Should receive all pre-uploaded files from disk
|
||||
assert.GreaterOrEqual(t, receivedCount, numFiles-2, // Allow small margin
|
||||
"Should receive most pre-uploaded files from disk (received %d/%d)", receivedCount, numFiles)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetadataSubscribeConcurrentWrites tests subscription with concurrent writes
|
||||
func TestMetadataSubscribeConcurrentWrites(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_concurrent_writes_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startSeaweedFSCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
|
||||
|
||||
t.Logf("Cluster started for concurrent writes test")
|
||||
|
||||
t.Run("concurrent_goroutine_writes", func(t *testing.T) {
|
||||
var receivedCount int64
|
||||
var uploadedCount int64
|
||||
|
||||
eventsChan := make(chan *filer_pb.SubscribeMetadataResponse, 10000)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(ctx)
|
||||
defer subCancel()
|
||||
|
||||
// Start subscriber
|
||||
go func() {
|
||||
err := subscribeToMetadata(subCtx, "127.0.0.1:8888", "/concurrent/", eventsChan)
|
||||
if err != nil && !strings.Contains(err.Error(), "context") {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start counting received events
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case event := <-eventsChan:
|
||||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil {
|
||||
if !event.EventNotification.NewEntry.IsDirectory {
|
||||
atomic.AddInt64(&receivedCount, 1)
|
||||
}
|
||||
}
|
||||
case <-subCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Launch many concurrent writers
|
||||
numWorkers := 50
|
||||
filesPerWorker := 20
|
||||
totalExpected := int64(numWorkers * filesPerWorker)
|
||||
|
||||
uploadWg := sync.WaitGroup{}
|
||||
for w := 0; w < numWorkers; w++ {
|
||||
uploadWg.Add(1)
|
||||
go func(workerId int) {
|
||||
defer uploadWg.Done()
|
||||
for i := 0; i < filesPerWorker; i++ {
|
||||
path := fmt.Sprintf("/concurrent/w%d/f%d.txt", workerId, i)
|
||||
content := []byte(fmt.Sprintf("worker%d-file%d", workerId, i))
|
||||
if err := uploadFile("http://127.0.0.1:8888"+path, content); err == nil {
|
||||
atomic.AddInt64(&uploadedCount, 1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
uploadWg.Wait()
|
||||
uploaded := atomic.LoadInt64(&uploadedCount)
|
||||
t.Logf("Uploaded %d/%d files from %d concurrent workers", uploaded, totalExpected, numWorkers)
|
||||
|
||||
// Wait for events with progress tracking
|
||||
stallTimeout := time.After(90 * time.Second)
|
||||
checkInterval := time.NewTicker(3 * time.Second)
|
||||
defer checkInterval.Stop()
|
||||
|
||||
lastReceived := int64(0)
|
||||
stableCount := 0
|
||||
|
||||
waitLoop:
|
||||
for {
|
||||
select {
|
||||
case <-stallTimeout:
|
||||
break waitLoop
|
||||
case <-checkInterval.C:
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
if received >= uploaded {
|
||||
t.Logf("All %d events received", received)
|
||||
break waitLoop
|
||||
}
|
||||
if received == lastReceived {
|
||||
stableCount++
|
||||
if stableCount >= 5 {
|
||||
t.Logf("No progress for %d checks, received %d/%d", stableCount, received, uploaded)
|
||||
break waitLoop
|
||||
}
|
||||
} else {
|
||||
stableCount = 0
|
||||
t.Logf("Progress: %d/%d (%.1f%%)", received, uploaded, float64(received)/float64(uploaded)*100)
|
||||
}
|
||||
lastReceived = received
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Subscription error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
subCancel()
|
||||
wg.Wait()
|
||||
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
percentage := float64(received) / float64(uploaded) * 100
|
||||
t.Logf("Final: received %d/%d events (%.1f%%)", received, uploaded, percentage)
|
||||
|
||||
// Should receive at least 80% of events
|
||||
assert.GreaterOrEqual(t, percentage, 80.0,
|
||||
"Should receive at least 80%% of concurrent write events")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetadataSubscribeMillionUpdates tests subscription with 1 million metadata updates
|
||||
// This test creates metadata entries directly via gRPC without actual file content
|
||||
func TestMetadataSubscribeMillionUpdates(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweedfs_million_updates_test_")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(testDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startSeaweedFSCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
|
||||
|
||||
t.Logf("Cluster started for million updates test")
|
||||
|
||||
t.Run("million_metadata_updates", func(t *testing.T) {
|
||||
var receivedCount int64
|
||||
var createdCount int64
|
||||
totalEntries := int64(1_000_000)
|
||||
|
||||
eventsChan := make(chan *filer_pb.SubscribeMetadataResponse, 100000)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(ctx)
|
||||
defer subCancel()
|
||||
|
||||
// Start subscriber
|
||||
go func() {
|
||||
err := subscribeToMetadata(subCtx, "127.0.0.1:8888", "/million/", eventsChan)
|
||||
if err != nil && !strings.Contains(err.Error(), "context") {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start counting received events
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case event := <-eventsChan:
|
||||
if event.EventNotification != nil && event.EventNotification.NewEntry != nil {
|
||||
if !event.EventNotification.NewEntry.IsDirectory {
|
||||
atomic.AddInt64(&receivedCount, 1)
|
||||
}
|
||||
}
|
||||
case <-subCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Create metadata entries directly via gRPC (no actual file content)
|
||||
numWorkers := 100
|
||||
entriesPerWorker := int(totalEntries) / numWorkers
|
||||
|
||||
startTime := time.Now()
|
||||
createWg := sync.WaitGroup{}
|
||||
|
||||
for w := 0; w < numWorkers; w++ {
|
||||
createWg.Add(1)
|
||||
go func(workerId int) {
|
||||
defer createWg.Done()
|
||||
grpcDialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
err := pb.WithFilerClient(false, 0, pb.ServerAddress("127.0.0.1:8888"), grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
for i := 0; i < entriesPerWorker; i++ {
|
||||
dir := fmt.Sprintf("/million/bucket%d", workerId%100)
|
||||
name := fmt.Sprintf("entry_%d_%d", workerId, i)
|
||||
|
||||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileSize: 100,
|
||||
Mtime: time.Now().Unix(),
|
||||
FileMode: 0644,
|
||||
Uid: 1000,
|
||||
Gid: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
atomic.AddInt64(&createdCount, 1)
|
||||
}
|
||||
|
||||
// Log progress every 10000 entries per worker
|
||||
if i > 0 && i%10000 == 0 {
|
||||
created := atomic.LoadInt64(&createdCount)
|
||||
elapsed := time.Since(startTime)
|
||||
rate := float64(created) / elapsed.Seconds()
|
||||
t.Logf("Worker %d: created %d entries, total %d (%.0f/sec)",
|
||||
workerId, i, created, rate)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("Worker %d error: %v", workerId, err)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Progress reporter
|
||||
progressDone := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
created := atomic.LoadInt64(&createdCount)
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
elapsed := time.Since(startTime)
|
||||
createRate := float64(created) / elapsed.Seconds()
|
||||
receiveRate := float64(received) / elapsed.Seconds()
|
||||
t.Logf("Progress: created %d (%.0f/sec), received %d (%.0f/sec), lag %d",
|
||||
created, createRate, received, receiveRate, created-received)
|
||||
case <-progressDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
createWg.Wait()
|
||||
close(progressDone)
|
||||
|
||||
created := atomic.LoadInt64(&createdCount)
|
||||
elapsed := time.Since(startTime)
|
||||
t.Logf("Created %d entries in %v (%.0f/sec)", created, elapsed, float64(created)/elapsed.Seconds())
|
||||
|
||||
// Wait for subscription to catch up
|
||||
catchupTimeout := time.After(5 * time.Minute)
|
||||
checkInterval := time.NewTicker(5 * time.Second)
|
||||
defer checkInterval.Stop()
|
||||
|
||||
lastReceived := int64(0)
|
||||
stableCount := 0
|
||||
|
||||
waitLoop:
|
||||
for {
|
||||
select {
|
||||
case <-catchupTimeout:
|
||||
t.Logf("Catchup timeout reached")
|
||||
break waitLoop
|
||||
case <-checkInterval.C:
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
if received >= created {
|
||||
t.Logf("All %d events received", received)
|
||||
break waitLoop
|
||||
}
|
||||
if received == lastReceived {
|
||||
stableCount++
|
||||
if stableCount >= 10 {
|
||||
t.Logf("No progress for %d checks", stableCount)
|
||||
break waitLoop
|
||||
}
|
||||
} else {
|
||||
stableCount = 0
|
||||
rate := float64(received-lastReceived) / 5.0
|
||||
t.Logf("Catching up: %d/%d (%.1f%%) at %.0f/sec",
|
||||
received, created, float64(received)/float64(created)*100, rate)
|
||||
}
|
||||
lastReceived = received
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Subscription error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
subCancel()
|
||||
wg.Wait()
|
||||
|
||||
received := atomic.LoadInt64(&receivedCount)
|
||||
percentage := float64(received) / float64(created) * 100
|
||||
totalTime := time.Since(startTime)
|
||||
t.Logf("Final: created %d, received %d (%.1f%%) in %v", created, received, percentage, totalTime)
|
||||
|
||||
// For million entries, we expect at least 90% to be received
|
||||
assert.GreaterOrEqual(t, percentage, 90.0,
|
||||
"Should receive at least 90%% of million metadata events (received %.1f%%)", percentage)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper types and functions
|
||||
|
||||
type TestCluster struct {
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
filerCmd *exec.Cmd
|
||||
testDir string
|
||||
}
|
||||
|
||||
func (c *TestCluster) Stop() {
|
||||
if c.filerCmd != nil && c.filerCmd.Process != nil {
|
||||
c.filerCmd.Process.Kill()
|
||||
c.filerCmd.Wait()
|
||||
}
|
||||
if c.volumeCmd != nil && c.volumeCmd.Process != nil {
|
||||
c.volumeCmd.Process.Kill()
|
||||
c.volumeCmd.Wait()
|
||||
}
|
||||
if c.masterCmd != nil && c.masterCmd.Process != nil {
|
||||
c.masterCmd.Process.Kill()
|
||||
c.masterCmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func startSeaweedFSCluster(ctx context.Context, dataDir string) (*TestCluster, error) {
|
||||
weedBinary := findWeedBinary()
|
||||
if weedBinary == "" {
|
||||
return nil, fmt.Errorf("weed binary not found")
|
||||
}
|
||||
|
||||
cluster := &TestCluster{testDir: dataDir}
|
||||
|
||||
// Create directories
|
||||
masterDir := filepath.Join(dataDir, "master")
|
||||
volumeDir := filepath.Join(dataDir, "volume")
|
||||
filerDir := filepath.Join(dataDir, "filer")
|
||||
if err := os.MkdirAll(masterDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create master dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(volumeDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create volume dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filerDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create filer dir: %v", err)
|
||||
}
|
||||
|
||||
// Start master server
|
||||
masterCmd := exec.CommandContext(ctx, weedBinary, "master",
|
||||
"-port", "9333",
|
||||
"-mdir", masterDir,
|
||||
"-volumeSizeLimitMB", "10",
|
||||
"-ip", "127.0.0.1",
|
||||
"-peers", "none",
|
||||
)
|
||||
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: %v", err)
|
||||
}
|
||||
cluster.masterCmd = masterCmd
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start volume server
|
||||
volumeCmd := exec.CommandContext(ctx, weedBinary, "volume",
|
||||
"-port", "8080",
|
||||
"-dir", volumeDir,
|
||||
"-max", "10",
|
||||
"-master", "127.0.0.1:9333",
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
volumeLogFile, err := os.Create(filepath.Join(volumeDir, "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: %v", err)
|
||||
}
|
||||
cluster.volumeCmd = volumeCmd
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start filer server
|
||||
filerCmd := exec.CommandContext(ctx, weedBinary, "filer",
|
||||
"-port", "8888",
|
||||
"-master", "127.0.0.1:9333",
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
filerLogFile, err := os.Create(filepath.Join(filerDir, "filer.log"))
|
||||
if err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("failed to create filer log file: %v", err)
|
||||
}
|
||||
filerCmd.Stdout = filerLogFile
|
||||
filerCmd.Stderr = filerLogFile
|
||||
if err := filerCmd.Start(); err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("failed to start filer: %v", err)
|
||||
}
|
||||
cluster.filerCmd = filerCmd
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func findWeedBinary() string {
|
||||
candidates := []string{
|
||||
"../../../weed/weed",
|
||||
"../../weed/weed",
|
||||
"./weed",
|
||||
"weed",
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("weed"); err == nil {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForHTTPServer(url string, timeout time.Duration) error {
|
||||
start := time.Now()
|
||||
for time.Since(start) < timeout {
|
||||
resp, err := http.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for server %s", url)
|
||||
}
|
||||
|
||||
func uploadFile(url string, content []byte) error {
|
||||
// Create multipart form data
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// Extract filename from URL path
|
||||
parts := strings.Split(url, "/")
|
||||
filename := parts[len(parts)-1]
|
||||
if filename == "" {
|
||||
filename = "file.txt"
|
||||
}
|
||||
|
||||
// Create form file field
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
return fmt.Errorf("write content: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("close writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func subscribeToMetadata(ctx context.Context, filerGrpcAddress, pathPrefix string, eventsChan chan<- *filer_pb.SubscribeMetadataResponse) error {
|
||||
return subscribeToMetadataWithOptions(ctx, filerGrpcAddress, pathPrefix, time.Now().UnixNano(), eventsChan)
|
||||
}
|
||||
|
||||
func subscribeToMetadataFromBeginning(ctx context.Context, filerGrpcAddress, pathPrefix string, eventsChan chan<- *filer_pb.SubscribeMetadataResponse) error {
|
||||
// Start from Unix epoch to get all events
|
||||
return subscribeToMetadataWithOptions(ctx, filerGrpcAddress, pathPrefix, 0, eventsChan)
|
||||
}
|
||||
|
||||
func subscribeToMetadataWithOptions(ctx context.Context, filerGrpcAddress, pathPrefix string, sinceNs int64, eventsChan chan<- *filer_pb.SubscribeMetadataResponse) error {
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
|
||||
if grpcDialOption == nil {
|
||||
grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
|
||||
return pb.WithFilerClient(false, 0, pb.ServerAddress(filerGrpcAddress), grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
stream, err := client.SubscribeMetadata(ctx, &filer_pb.SubscribeMetadataRequest{
|
||||
ClientName: "integration_test",
|
||||
PathPrefix: pathPrefix,
|
||||
SinceNs: sinceNs,
|
||||
ClientId: util.RandomInt32(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case eventsChan <- resp:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Channel full after brief wait, log warning
|
||||
glog.Warningf("Event channel full, skipping event for %s", resp.Directory)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# S3 Filer Group Test Makefile
|
||||
# This Makefile provides targets for running S3 filer group integration tests
|
||||
# These tests verify that S3 bucket operations work correctly when a filer group is configured.
|
||||
|
||||
.PHONY: help build-weed check-deps start-server stop-server test test-with-server clean logs health-check
|
||||
|
||||
# Configuration
|
||||
WEED_BINARY := ../../../weed/weed_binary
|
||||
S3_PORT := 8333
|
||||
MASTER_PORT := 9333
|
||||
MASTER_GRPC_PORT := 19333
|
||||
VOLUME_PORT := 8080
|
||||
FILER_PORT := 8888
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN := Test
|
||||
|
||||
# Filer group configuration
|
||||
FILER_GROUP := testgroup
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "S3 Filer Group Test 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 with filer group"
|
||||
@echo " stop-server - Stop SeaweedFS server"
|
||||
@echo " test - Run filer group tests (server must be running)"
|
||||
@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 " MASTER_GRPC_PORT=${MASTER_GRPC_PORT}"
|
||||
@echo " FILER_GROUP=${FILER_GROUP}"
|
||||
@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..."
|
||||
@command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1)
|
||||
@echo "Go version: $$(go version)"
|
||||
@test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1)
|
||||
@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 with filer group configured
|
||||
start-server: check-deps
|
||||
@echo "Starting SeaweedFS server with filer group: $(FILER_GROUP)..."
|
||||
@rm -f weed-server.pid
|
||||
@mkdir -p ./test-volume-data
|
||||
@if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \
|
||||
echo "⚠️ Port $(S3_PORT) is already in use"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Launching SeaweedFS server with filer group $(FILER_GROUP)..."
|
||||
@$(WEED_BINARY) server \
|
||||
-debug \
|
||||
-s3 \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=../../../docker/compose/s3.json \
|
||||
-filer \
|
||||
-filer.maxMB=64 \
|
||||
-filer.filerGroup=$(FILER_GROUP) \
|
||||
-master.volumeSizeLimitMB=50 \
|
||||
-volume.max=100 \
|
||||
-dir=./test-volume-data \
|
||||
-volume.preStopSeconds=1 \
|
||||
-metricsPort=9325 \
|
||||
> weed-test.log 2>&1 & echo $$! > weed-server.pid
|
||||
@echo "Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')"
|
||||
@echo "Waiting for server to start (up to 90 seconds)..."
|
||||
@for i in $$(seq 1 90); do \
|
||||
if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
|
||||
echo "✅ SeaweedFS server started successfully on port $(S3_PORT) with filer group $(FILER_GROUP)"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
if [ $$i -eq 30 ]; then \
|
||||
echo "⚠️ Server taking longer than expected (30s), checking logs..."; \
|
||||
if [ -f weed-test.log ]; then \
|
||||
tail -20 weed-test.log; \
|
||||
fi; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "❌ Server failed to start within 90 seconds"; \
|
||||
if [ -f weed-test.log ]; then \
|
||||
cat weed-test.log; \
|
||||
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"; \
|
||||
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
|
||||
|
||||
# Run filer group tests (assumes server is already running)
|
||||
test: check-deps
|
||||
@echo "Running filer group S3 tests..."
|
||||
@FILER_GROUP=$(FILER_GROUP) S3_ENDPOINT=http://localhost:$(S3_PORT) MASTER_ADDRESS=localhost:$(MASTER_GRPC_PORT) \
|
||||
go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" .
|
||||
@echo "✅ Filer group tests completed"
|
||||
|
||||
# Run tests with automatic server management
|
||||
test-with-server: start-server
|
||||
@echo "Server started successfully, running filer group tests..."
|
||||
@echo "Test pattern: $(TEST_PATTERN)"
|
||||
@echo "Test timeout: $(TEST_TIMEOUT)"
|
||||
@trap "$(MAKE) stop-server" EXIT; \
|
||||
$(MAKE) test || (echo "❌ Tests failed, showing server logs:" && echo "=== Last 50 lines of server logs ===" && tail -50 weed-test.log && echo "=== End of server logs ===" && exit 1)
|
||||
@$(MAKE) stop-server
|
||||
@echo "✅ Tests completed and server stopped"
|
||||
|
||||
# Clean up test artifacts
|
||||
clean:
|
||||
@echo "Cleaning up test artifacts..."
|
||||
@$(MAKE) stop-server
|
||||
@rm -f weed-test*.log weed-server.pid
|
||||
@rm -rf test-volume-data/
|
||||
@go clean -testcache
|
||||
@echo "✅ Cleanup completed"
|
||||
|
||||
# Quick health check
|
||||
health-check:
|
||||
@echo "Running health check..."
|
||||
@curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1 && echo "✅ S3 API is accessible" || echo "❌ S3 API is not accessible"
|
||||
@curl -s http://localhost:9325/metrics >/dev/null 2>&1 && echo "✅ Metrics endpoint is accessible" || echo "❌ Metrics endpoint is not accessible"
|
||||
@@ -0,0 +1,99 @@
|
||||
# Filer Group S3 Tests
|
||||
|
||||
These tests verify that S3 bucket operations work correctly when a filer group is configured.
|
||||
|
||||
## Background
|
||||
|
||||
When SeaweedFS is configured with a filer group (via `-filer.group` option), collections are named with the filer group prefix:
|
||||
|
||||
```text
|
||||
Collection name = {filerGroup}_{bucketName}
|
||||
```
|
||||
|
||||
For example, with filer group `mygroup` and bucket `mybucket`, the collection will be named `mygroup_mybucket`.
|
||||
|
||||
## Issue Being Tested
|
||||
|
||||
This test suite was created to verify the fix for a bug where:
|
||||
- The admin UI was using just the bucket name when deleting collections
|
||||
- This caused collection deletion to fail when a filer group was configured
|
||||
- After bucket deletion via admin UI, the collection data would be orphaned
|
||||
|
||||
## Running the Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. SeaweedFS servers must be running with a filer group configured
|
||||
2. The S3 gateway must be accessible
|
||||
3. Master server must be accessible for collection verification
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export FILER_GROUP=testgroup
|
||||
export S3_ENDPOINT=http://localhost:8333
|
||||
export MASTER_ADDRESS=localhost:9333
|
||||
|
||||
# Run tests
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
### Using the Makefile
|
||||
|
||||
```bash
|
||||
# Start test servers with filer group configured
|
||||
make start-servers FILER_GROUP=testgroup
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Stop servers
|
||||
make stop-servers
|
||||
|
||||
# Or run full test cycle
|
||||
make full-test
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Tests can be configured via:
|
||||
|
||||
1. Environment variables:
|
||||
- `FILER_GROUP`: The filer group name (required for tests to run)
|
||||
- `S3_ENDPOINT`: S3 API endpoint (default: `http://localhost:8333`)
|
||||
- `MASTER_ADDRESS`: Master server address (default: localhost:9333)
|
||||
|
||||
2. `test_config.json` file
|
||||
|
||||
## Test Cases
|
||||
|
||||
### TestFilerGroupCollectionNaming
|
||||
Verifies that when a bucket is created and objects are uploaded:
|
||||
1. The collection is created with the correct filer group prefix
|
||||
2. Bucket deletion removes the correctly-named collection
|
||||
|
||||
### TestBucketDeletionWithFilerGroup
|
||||
Specifically tests that bucket deletion via S3 API correctly deletes
|
||||
the collection when filer group is configured.
|
||||
|
||||
### TestMultipleBucketsWithFilerGroup
|
||||
Tests creating and deleting multiple buckets to ensure the filer group
|
||||
prefix is correctly applied and removed for all buckets.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
With filer group `testgroup`:
|
||||
|
||||
| Bucket Name | Expected Collection Name |
|
||||
|-------------|-------------------------|
|
||||
| mybucket | testgroup_mybucket |
|
||||
| test-123 | testgroup_test-123 |
|
||||
|
||||
Without filer group:
|
||||
|
||||
| Bucket Name | Expected Collection Name |
|
||||
|-------------|-------------------------|
|
||||
| mybucket | mybucket |
|
||||
| test-123 | test-123 |
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package filer_group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// TestConfig holds configuration for filer group S3 tests
|
||||
type TestConfig struct {
|
||||
S3Endpoint string `json:"s3_endpoint"`
|
||||
MasterAddress string `json:"master_address"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
FilerGroup string `json:"filer_group"`
|
||||
}
|
||||
|
||||
var testConfig = &TestConfig{
|
||||
S3Endpoint: "http://localhost:8333",
|
||||
MasterAddress: "localhost:19333", // gRPC port = 10000 + master HTTP port (9333)
|
||||
AccessKey: "some_access_key1",
|
||||
SecretKey: "some_secret_key1",
|
||||
Region: "us-east-1",
|
||||
FilerGroup: "testgroup", // Expected filer group for these tests
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Load config from file if exists
|
||||
if data, err := os.ReadFile("test_config.json"); err == nil {
|
||||
if err := json.Unmarshal(data, testConfig); err != nil {
|
||||
// Log but don't fail - env vars can still override
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to parse test_config.json: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Override from environment variables
|
||||
if v := os.Getenv("S3_ENDPOINT"); v != "" {
|
||||
testConfig.S3Endpoint = v
|
||||
}
|
||||
if v := os.Getenv("MASTER_ADDRESS"); v != "" {
|
||||
testConfig.MasterAddress = v
|
||||
}
|
||||
if v := os.Getenv("FILER_GROUP"); v != "" {
|
||||
testConfig.FilerGroup = v
|
||||
}
|
||||
}
|
||||
|
||||
func getS3Client(t *testing.T) *s3.Client {
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(testConfig.Region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
testConfig.AccessKey,
|
||||
testConfig.SecretKey,
|
||||
"",
|
||||
)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(testConfig.S3Endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
func getMasterClient(t *testing.T) master_pb.SeaweedClient {
|
||||
conn, err := grpc.NewClient(testConfig.MasterAddress, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return master_pb.NewSeaweedClient(conn)
|
||||
}
|
||||
|
||||
func getNewBucketName() string {
|
||||
return fmt.Sprintf("filergroup-test-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// getExpectedCollectionName returns the expected collection name for a bucket
|
||||
// When filer group is configured, collections are named: {filerGroup}_{bucketName}
|
||||
func getExpectedCollectionName(bucketName string) string {
|
||||
if testConfig.FilerGroup != "" {
|
||||
return fmt.Sprintf("%s_%s", testConfig.FilerGroup, bucketName)
|
||||
}
|
||||
return bucketName
|
||||
}
|
||||
|
||||
// listAllCollections returns a list of all collection names from the master
|
||||
func listAllCollections(t *testing.T, masterClient master_pb.SeaweedClient) []string {
|
||||
collectionResp, err := masterClient.CollectionList(context.Background(), &master_pb.CollectionListRequest{
|
||||
IncludeNormalVolumes: true,
|
||||
IncludeEcVolumes: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to list collections: %v", err)
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, c := range collectionResp.Collections {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// collectionExists checks if a collection exists in the master
|
||||
func collectionExists(t *testing.T, masterClient master_pb.SeaweedClient, collectionName string) bool {
|
||||
for _, name := range listAllCollections(t, masterClient) {
|
||||
if name == collectionName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitForCollectionExists waits for a collection to exist using polling
|
||||
func waitForCollectionExists(t *testing.T, masterClient master_pb.SeaweedClient, collectionName string) {
|
||||
var lastCollections []string
|
||||
success := assert.Eventually(t, func() bool {
|
||||
lastCollections = listAllCollections(t, masterClient)
|
||||
for _, name := range lastCollections {
|
||||
if name == collectionName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 10*time.Second, 200*time.Millisecond)
|
||||
if !success {
|
||||
t.Fatalf("collection %s should be created; existing collections: %v", collectionName, lastCollections)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCollectionDeleted waits for a collection to be deleted using polling
|
||||
func waitForCollectionDeleted(t *testing.T, masterClient master_pb.SeaweedClient, collectionName string) {
|
||||
require.Eventually(t, func() bool {
|
||||
return !collectionExists(t, masterClient, collectionName)
|
||||
}, 10*time.Second, 200*time.Millisecond, "collection %s should be deleted", collectionName)
|
||||
}
|
||||
|
||||
// TestFilerGroupCollectionNaming verifies that when a filer group is configured,
|
||||
// collections are created with the correct prefix ({filerGroup}_{bucketName})
|
||||
func TestFilerGroupCollectionNaming(t *testing.T) {
|
||||
if testConfig.FilerGroup == "" {
|
||||
t.Skip("Skipping test: FILER_GROUP not configured. Set FILER_GROUP environment variable or configure in test_config.json")
|
||||
}
|
||||
|
||||
s3Client := getS3Client(t)
|
||||
masterClient := getMasterClient(t)
|
||||
bucketName := getNewBucketName()
|
||||
expectedCollection := getExpectedCollectionName(bucketName)
|
||||
|
||||
t.Logf("Testing with filer group: %s", testConfig.FilerGroup)
|
||||
t.Logf("Bucket name: %s", bucketName)
|
||||
t.Logf("Expected collection name: %s", expectedCollection)
|
||||
|
||||
// Create bucket
|
||||
_, err := s3Client.CreateBucket(context.Background(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "CreateBucket should succeed")
|
||||
|
||||
// Upload an object to trigger collection creation
|
||||
_, err = s3Client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-object"),
|
||||
Body: strings.NewReader("test content"),
|
||||
})
|
||||
require.NoError(t, err, "PutObject should succeed")
|
||||
|
||||
// Wait for collection to be visible using polling
|
||||
waitForCollectionExists(t, masterClient, expectedCollection)
|
||||
|
||||
// Verify collection exists with correct name
|
||||
require.True(t, collectionExists(t, masterClient, expectedCollection),
|
||||
"Collection %s should exist (filer group prefix applied)", expectedCollection)
|
||||
|
||||
// Cleanup: delete object and bucket
|
||||
_, err = s3Client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-object"),
|
||||
})
|
||||
require.NoError(t, err, "DeleteObject should succeed")
|
||||
|
||||
_, err = s3Client.DeleteBucket(context.Background(), &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "DeleteBucket should succeed")
|
||||
|
||||
// Wait for collection to be deleted using polling
|
||||
waitForCollectionDeleted(t, masterClient, expectedCollection)
|
||||
|
||||
t.Log("SUCCESS: Collection naming with filer group works correctly")
|
||||
}
|
||||
|
||||
// TestBucketDeletionWithFilerGroup verifies that bucket deletion correctly
|
||||
// deletes the collection when filer group is configured
|
||||
func TestBucketDeletionWithFilerGroup(t *testing.T) {
|
||||
if testConfig.FilerGroup == "" {
|
||||
t.Skip("Skipping test: FILER_GROUP not configured")
|
||||
}
|
||||
|
||||
s3Client := getS3Client(t)
|
||||
masterClient := getMasterClient(t)
|
||||
bucketName := getNewBucketName()
|
||||
expectedCollection := getExpectedCollectionName(bucketName)
|
||||
|
||||
// Create bucket and add an object
|
||||
_, err := s3Client.CreateBucket(context.Background(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s3Client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-object"),
|
||||
Body: strings.NewReader("test content"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for collection to be created using polling
|
||||
waitForCollectionExists(t, masterClient, expectedCollection)
|
||||
|
||||
// Verify collection exists before deletion
|
||||
require.True(t, collectionExists(t, masterClient, expectedCollection),
|
||||
"Collection should exist before bucket deletion")
|
||||
|
||||
// Delete object first
|
||||
_, err = s3Client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-object"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete bucket
|
||||
_, err = s3Client.DeleteBucket(context.Background(), &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "DeleteBucket should succeed")
|
||||
|
||||
// Wait for collection to be deleted using polling
|
||||
waitForCollectionDeleted(t, masterClient, expectedCollection)
|
||||
|
||||
// Verify collection was deleted
|
||||
require.False(t, collectionExists(t, masterClient, expectedCollection),
|
||||
"Collection %s should be deleted after bucket deletion", expectedCollection)
|
||||
|
||||
t.Log("SUCCESS: Bucket deletion with filer group correctly deletes collection")
|
||||
}
|
||||
|
||||
// TestMultipleBucketsWithFilerGroup tests creating and deleting multiple buckets
|
||||
func TestMultipleBucketsWithFilerGroup(t *testing.T) {
|
||||
if testConfig.FilerGroup == "" {
|
||||
t.Skip("Skipping test: FILER_GROUP not configured")
|
||||
}
|
||||
|
||||
s3Client := getS3Client(t)
|
||||
masterClient := getMasterClient(t)
|
||||
|
||||
buckets := make([]string, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
buckets[i] = getNewBucketName()
|
||||
}
|
||||
|
||||
// Create all buckets and add objects
|
||||
for _, bucket := range buckets {
|
||||
_, err := s3Client.CreateBucket(context.Background(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s3Client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("test-object"),
|
||||
Body: strings.NewReader("test content"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for all collections to be created using polling
|
||||
for _, bucket := range buckets {
|
||||
expectedCollection := getExpectedCollectionName(bucket)
|
||||
waitForCollectionExists(t, masterClient, expectedCollection)
|
||||
}
|
||||
|
||||
// Verify all collections exist with correct naming
|
||||
for _, bucket := range buckets {
|
||||
expectedCollection := getExpectedCollectionName(bucket)
|
||||
require.True(t, collectionExists(t, masterClient, expectedCollection),
|
||||
"Collection %s should exist for bucket %s", expectedCollection, bucket)
|
||||
}
|
||||
|
||||
// Delete all buckets
|
||||
for _, bucket := range buckets {
|
||||
_, err := s3Client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("test-object"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s3Client.DeleteBucket(context.Background(), &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for all collections to be deleted using polling
|
||||
for _, bucket := range buckets {
|
||||
expectedCollection := getExpectedCollectionName(bucket)
|
||||
waitForCollectionDeleted(t, masterClient, expectedCollection)
|
||||
}
|
||||
|
||||
// Verify all collections are deleted
|
||||
for _, bucket := range buckets {
|
||||
expectedCollection := getExpectedCollectionName(bucket)
|
||||
require.False(t, collectionExists(t, masterClient, expectedCollection),
|
||||
"Collection %s should be deleted for bucket %s", expectedCollection, bucket)
|
||||
}
|
||||
|
||||
t.Log("SUCCESS: Multiple bucket operations with filer group work correctly")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"s3_endpoint": "http://localhost:8333",
|
||||
"master_address": "localhost:9333",
|
||||
"access_key": "some_access_key1",
|
||||
"secret_key": "some_secret_key1",
|
||||
"region": "us-east-1",
|
||||
"filer_group": "testgroup"
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestGitHub7562CopyFromEncryptedToTempToEncrypted reproduces the exact scenario from
|
||||
// GitHub issue #7562: copying from an encrypted bucket to a temp bucket, then to another
|
||||
// encrypted bucket fails with InternalError.
|
||||
//
|
||||
// Reproduction steps:
|
||||
// 1. Create source bucket with SSE-S3 encryption enabled
|
||||
// 2. Upload object (automatically encrypted)
|
||||
// 3. Create temp bucket (no encryption)
|
||||
// 4. Copy object from source to temp (decrypts)
|
||||
// 5. Delete source bucket
|
||||
// 6. Create destination bucket with SSE-S3 encryption
|
||||
// 7. Copy object from temp to dest (should re-encrypt) - THIS FAILS
|
||||
func TestGitHub7562CopyFromEncryptedToTempToEncrypted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client, err := createS3Client(ctx, defaultConfig)
|
||||
require.NoError(t, err, "Failed to create S3 client")
|
||||
|
||||
// Create three buckets
|
||||
srcBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-src-")
|
||||
require.NoError(t, err, "Failed to create source bucket")
|
||||
|
||||
tempBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-temp-")
|
||||
require.NoError(t, err, "Failed to create temp bucket")
|
||||
|
||||
destBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-dest-")
|
||||
require.NoError(t, err, "Failed to create destination bucket")
|
||||
|
||||
// Cleanup at the end
|
||||
defer func() {
|
||||
// Clean up in reverse order of creation
|
||||
cleanupTestBucket(ctx, client, destBucket)
|
||||
cleanupTestBucket(ctx, client, tempBucket)
|
||||
// Note: srcBucket is deleted during the test
|
||||
}()
|
||||
|
||||
testData := []byte("Test data for GitHub issue #7562 - copy from encrypted to temp to encrypted bucket")
|
||||
objectKey := "demo-file.txt"
|
||||
|
||||
t.Logf("[1] Creating source bucket with SSE-S3 default encryption: %s", srcBucket)
|
||||
|
||||
// Step 1: Enable SSE-S3 default encryption on source bucket
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set source bucket default encryption")
|
||||
|
||||
t.Log("[2] Uploading demo object to source bucket")
|
||||
|
||||
// Step 2: Upload 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(testData),
|
||||
// No encryption header - bucket default applies
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload 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")
|
||||
t.Logf("Source object encryption: %v", srcHead.ServerSideEncryption)
|
||||
|
||||
t.Logf("[3] Creating temp bucket (no encryption): %s", tempBucket)
|
||||
// Temp bucket already created without encryption
|
||||
|
||||
t.Log("[4] Copying object from source to temp (should decrypt)")
|
||||
|
||||
// Step 4: Copy to temp bucket (no encryption = decrypts)
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(tempBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", srcBucket, objectKey)),
|
||||
// No encryption header - data stored unencrypted
|
||||
})
|
||||
require.NoError(t, err, "Failed to copy to temp bucket")
|
||||
|
||||
// Verify temp object is NOT encrypted
|
||||
tempHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(tempBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD temp object")
|
||||
assert.Empty(t, tempHead.ServerSideEncryption, "Temp object should NOT be encrypted")
|
||||
t.Logf("Temp object encryption: %v (should be empty)", tempHead.ServerSideEncryption)
|
||||
|
||||
// Verify temp object content
|
||||
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, testData, tempData, "Temp object data should match original")
|
||||
|
||||
t.Log("[5] Deleting original source bucket")
|
||||
|
||||
// Step 5: Delete source bucket
|
||||
err = cleanupTestBucket(ctx, client, srcBucket)
|
||||
require.NoError(t, err, "Failed to delete source bucket")
|
||||
|
||||
t.Logf("[6] Creating destination bucket with SSE-S3 encryption: %s", destBucket)
|
||||
|
||||
// Step 6: Enable SSE-S3 default encryption on destination bucket
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set destination bucket default encryption")
|
||||
|
||||
t.Log("[7] Copying object from temp to dest (should re-encrypt) - THIS IS WHERE #7562 FAILS")
|
||||
|
||||
// Step 7: Copy from temp to dest bucket (should re-encrypt with SSE-S3)
|
||||
// THIS IS THE STEP THAT FAILS IN GITHUB ISSUE #7562
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", tempBucket, objectKey)),
|
||||
// No encryption header - bucket default should apply
|
||||
})
|
||||
require.NoError(t, err, "GitHub #7562: Failed to copy from temp to encrypted dest bucket")
|
||||
|
||||
// Verify destination object is encrypted
|
||||
destHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD destination object")
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, destHead.ServerSideEncryption,
|
||||
"Destination object should be SSE-S3 encrypted via bucket default")
|
||||
t.Logf("Destination object encryption: %v", destHead.ServerSideEncryption)
|
||||
|
||||
// Verify destination object content is correct
|
||||
destGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET destination object")
|
||||
destData, err := io.ReadAll(destGet.Body)
|
||||
destGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read destination object")
|
||||
assertDataEqual(t, testData, destData, "GitHub #7562: Destination object data mismatch after re-encryption")
|
||||
|
||||
t.Log("[done] GitHub #7562 reproduction test completed successfully!")
|
||||
}
|
||||
|
||||
// TestGitHub7562SimpleScenario tests the simpler variant: just copy unencrypted to encrypted bucket
|
||||
func TestGitHub7562SimpleScenario(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client, err := createS3Client(ctx, defaultConfig)
|
||||
require.NoError(t, err, "Failed to create S3 client")
|
||||
|
||||
// Create two buckets
|
||||
srcBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-simple-src-")
|
||||
require.NoError(t, err, "Failed to create source bucket")
|
||||
defer cleanupTestBucket(ctx, client, srcBucket)
|
||||
|
||||
destBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-simple-dest-")
|
||||
require.NoError(t, err, "Failed to create destination bucket")
|
||||
defer cleanupTestBucket(ctx, client, destBucket)
|
||||
|
||||
testData := []byte("Simple test for unencrypted to encrypted copy")
|
||||
objectKey := "test-object.txt"
|
||||
|
||||
t.Logf("Source bucket (no encryption): %s", srcBucket)
|
||||
t.Logf("Dest bucket (SSE-S3 default): %s", destBucket)
|
||||
|
||||
// Upload to unencrypted source bucket
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader(testData),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload to source bucket")
|
||||
|
||||
// Enable SSE-S3 on destination bucket
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set dest bucket encryption")
|
||||
|
||||
// Copy to encrypted bucket (should use bucket default encryption)
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", srcBucket, objectKey)),
|
||||
})
|
||||
require.NoError(t, err, "Failed to copy to encrypted bucket")
|
||||
|
||||
// Verify destination is encrypted
|
||||
destHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD dest object")
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, destHead.ServerSideEncryption,
|
||||
"Object should be encrypted via bucket default")
|
||||
|
||||
// Verify content
|
||||
destGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET dest object")
|
||||
destData, err := io.ReadAll(destGet.Body)
|
||||
destGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read dest object")
|
||||
assertDataEqual(t, testData, destData, "Data mismatch")
|
||||
}
|
||||
|
||||
// TestGitHub7562DebugMetadata helps debug what metadata is present on objects at each step
|
||||
func TestGitHub7562DebugMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client, err := createS3Client(ctx, defaultConfig)
|
||||
require.NoError(t, err, "Failed to create S3 client")
|
||||
|
||||
// Create three buckets
|
||||
srcBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-debug-src-")
|
||||
require.NoError(t, err, "Failed to create source bucket")
|
||||
|
||||
tempBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-debug-temp-")
|
||||
require.NoError(t, err, "Failed to create temp bucket")
|
||||
|
||||
destBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-debug-dest-")
|
||||
require.NoError(t, err, "Failed to create destination bucket")
|
||||
|
||||
defer func() {
|
||||
cleanupTestBucket(ctx, client, destBucket)
|
||||
cleanupTestBucket(ctx, client, tempBucket)
|
||||
}()
|
||||
|
||||
testData := []byte("Debug metadata test for GitHub #7562")
|
||||
objectKey := "debug-file.txt"
|
||||
|
||||
// Enable SSE-S3 on source
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set source bucket encryption")
|
||||
|
||||
// Upload
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader(testData),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload")
|
||||
|
||||
// Log source object headers
|
||||
srcHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD source")
|
||||
t.Logf("=== SOURCE OBJECT (encrypted) ===")
|
||||
t.Logf("ServerSideEncryption: %v", srcHead.ServerSideEncryption)
|
||||
t.Logf("Metadata: %v", srcHead.Metadata)
|
||||
t.Logf("ContentLength: %d", aws.ToInt64(srcHead.ContentLength))
|
||||
|
||||
// Copy to temp
|
||||
_, 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 to temp")
|
||||
|
||||
// Log temp object headers
|
||||
tempHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(tempBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD temp")
|
||||
t.Logf("=== TEMP OBJECT (should be unencrypted) ===")
|
||||
t.Logf("ServerSideEncryption: %v (should be empty)", tempHead.ServerSideEncryption)
|
||||
t.Logf("Metadata: %v", tempHead.Metadata)
|
||||
t.Logf("ContentLength: %d", aws.ToInt64(tempHead.ContentLength))
|
||||
|
||||
// Verify temp is NOT encrypted
|
||||
if tempHead.ServerSideEncryption != "" {
|
||||
t.Logf("WARNING: Temp object unexpectedly has encryption: %v", tempHead.ServerSideEncryption)
|
||||
}
|
||||
|
||||
// Delete source bucket
|
||||
cleanupTestBucket(ctx, client, srcBucket)
|
||||
|
||||
// Enable SSE-S3 on dest
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set dest bucket encryption")
|
||||
|
||||
// Copy to dest - THIS IS WHERE #7562 FAILS
|
||||
t.Log("=== COPYING TO ENCRYPTED DEST ===")
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", tempBucket, objectKey)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("!!! COPY FAILED (GitHub #7562): %v", err)
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
// Log dest object headers
|
||||
destHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD dest")
|
||||
t.Logf("=== DEST OBJECT (should be encrypted) ===")
|
||||
t.Logf("ServerSideEncryption: %v", destHead.ServerSideEncryption)
|
||||
t.Logf("Metadata: %v", destHead.Metadata)
|
||||
t.Logf("ContentLength: %d", aws.ToInt64(destHead.ContentLength))
|
||||
|
||||
// Verify dest IS encrypted
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, destHead.ServerSideEncryption,
|
||||
"Dest object should be encrypted")
|
||||
|
||||
// Verify content is readable
|
||||
destGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET dest")
|
||||
destData, err := io.ReadAll(destGet.Body)
|
||||
destGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read dest")
|
||||
assertDataEqual(t, testData, destData, "Data mismatch")
|
||||
|
||||
t.Log("=== DEBUG TEST PASSED ===")
|
||||
}
|
||||
|
||||
// TestGitHub7562LargeFile tests the issue with larger files that might trigger multipart handling
|
||||
func TestGitHub7562LargeFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client, err := createS3Client(ctx, defaultConfig)
|
||||
require.NoError(t, err, "Failed to create S3 client")
|
||||
|
||||
srcBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-large-src-")
|
||||
require.NoError(t, err, "Failed to create source bucket")
|
||||
|
||||
tempBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-large-temp-")
|
||||
require.NoError(t, err, "Failed to create temp bucket")
|
||||
|
||||
destBucket, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"7562-large-dest-")
|
||||
require.NoError(t, err, "Failed to create destination bucket")
|
||||
|
||||
defer func() {
|
||||
cleanupTestBucket(ctx, client, destBucket)
|
||||
cleanupTestBucket(ctx, client, tempBucket)
|
||||
}()
|
||||
|
||||
// Use larger file to potentially trigger different code paths
|
||||
testData := generateTestData(5 * 1024 * 1024) // 5MB
|
||||
objectKey := "large-file.bin"
|
||||
|
||||
t.Logf("Testing with %d byte file", len(testData))
|
||||
|
||||
// Enable SSE-S3 on source
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set source bucket encryption")
|
||||
|
||||
// Upload
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(srcBucket),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader(testData),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload")
|
||||
|
||||
// Copy to temp (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 to temp")
|
||||
|
||||
// Delete source
|
||||
cleanupTestBucket(ctx, client, srcBucket)
|
||||
|
||||
// Enable SSE-S3 on dest
|
||||
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
||||
Rules: []types.ServerSideEncryptionRule{
|
||||
{
|
||||
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
||||
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to set dest bucket encryption")
|
||||
|
||||
// Copy to dest (re-encrypt) - GitHub #7562
|
||||
_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", tempBucket, objectKey)),
|
||||
})
|
||||
require.NoError(t, err, "GitHub #7562: Large file copy to encrypted bucket failed")
|
||||
|
||||
// Verify
|
||||
destHead, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to HEAD dest")
|
||||
assert.Equal(t, types.ServerSideEncryptionAes256, destHead.ServerSideEncryption)
|
||||
assert.Equal(t, int64(len(testData)), aws.ToInt64(destHead.ContentLength))
|
||||
|
||||
// Verify content
|
||||
destGet, err := client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(destBucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err, "Failed to GET dest")
|
||||
destData, err := io.ReadAll(destGet.Body)
|
||||
destGet.Body.Close()
|
||||
require.NoError(t, err, "Failed to read dest")
|
||||
assertDataEqual(t, testData, destData, "Large file data mismatch")
|
||||
|
||||
t.Log("Large file test passed!")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVeleroKopiaVersionedObjectListing tests the exact access patterns used by Velero/Kopia
|
||||
// when backing up to a versioned S3 bucket. This test validates the fix for:
|
||||
// https://github.com/seaweedfs/seaweedfs/discussions/7573
|
||||
//
|
||||
// The bug was that when listing versioned objects with nested paths like
|
||||
// "kopia/logpaste/kopia.blobcfg", the returned Key would be doubled:
|
||||
// "kopia/logpaste/kopia/logpaste/kopia.blobcfg" instead of the correct path.
|
||||
//
|
||||
// This was caused by getLatestVersionEntryForListOperation setting the entry's
|
||||
// Name to the full path instead of just the base filename.
|
||||
func TestVeleroKopiaVersionedObjectListing(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := getNewBucketName()
|
||||
|
||||
// Step 1: Create bucket
|
||||
createBucket(t, client, bucketName)
|
||||
defer deleteBucket(t, client, bucketName)
|
||||
|
||||
// Step 2: Enable versioning (required to reproduce the bug)
|
||||
enableVersioning(t, client, bucketName)
|
||||
|
||||
// Step 3: Create objects matching Velero/Kopia's typical path structure
|
||||
// These are the actual paths Kopia uses when storing repository data
|
||||
testObjects := map[string]string{
|
||||
"kopia/logpaste/kopia.blobcfg": "blob-config-content",
|
||||
"kopia/logpaste/kopia.repository": "repository-content",
|
||||
"kopia/logpaste/.storageconfig": "storage-config-content",
|
||||
"backups/namespace1/backup.tar": "backup-content",
|
||||
"restic/locks/lock-001.json": "lock-content",
|
||||
"restic/index/index-001.json": "index-content",
|
||||
"data/a/b/c/deeply-nested.dat": "nested-content",
|
||||
"single-file.txt": "single-file-content",
|
||||
}
|
||||
|
||||
// Upload all test objects
|
||||
for key, content := range testObjects {
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader(content),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload object %s", key)
|
||||
}
|
||||
|
||||
// Step 4: Test listing with various prefixes (mimicking Kopia's ListObjects calls)
|
||||
testCases := []struct {
|
||||
name string
|
||||
prefix string
|
||||
expectedKeys []string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "kopia repository prefix",
|
||||
prefix: "kopia/logpaste/",
|
||||
expectedKeys: []string{
|
||||
"kopia/logpaste/.storageconfig",
|
||||
"kopia/logpaste/kopia.blobcfg",
|
||||
"kopia/logpaste/kopia.repository",
|
||||
},
|
||||
description: "Kopia lists its repository directory to find config files",
|
||||
},
|
||||
{
|
||||
name: "exact config file",
|
||||
prefix: "kopia/logpaste/kopia.blobcfg",
|
||||
expectedKeys: []string{"kopia/logpaste/kopia.blobcfg"},
|
||||
description: "Kopia checks for specific config files",
|
||||
},
|
||||
{
|
||||
name: "restic index directory",
|
||||
prefix: "restic/index/",
|
||||
expectedKeys: []string{
|
||||
"restic/index/index-001.json",
|
||||
},
|
||||
description: "Restic lists index files (similar pattern to Kopia)",
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
prefix: "data/a/b/c/",
|
||||
expectedKeys: []string{
|
||||
"data/a/b/c/deeply-nested.dat",
|
||||
},
|
||||
description: "Tests deeply nested paths don't get doubled",
|
||||
},
|
||||
{
|
||||
name: "backup directory",
|
||||
prefix: "backups/",
|
||||
expectedKeys: []string{
|
||||
"backups/namespace1/backup.tar",
|
||||
},
|
||||
description: "Velero lists backup directories",
|
||||
},
|
||||
{
|
||||
name: "root level single file",
|
||||
prefix: "single-file",
|
||||
expectedKeys: []string{
|
||||
"single-file.txt",
|
||||
},
|
||||
description: "Files at bucket root should work correctly",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// List objects with prefix
|
||||
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String(tc.prefix),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract returned keys
|
||||
var actualKeys []string
|
||||
for _, obj := range listResp.Contents {
|
||||
key := *obj.Key
|
||||
actualKeys = append(actualKeys, key)
|
||||
|
||||
// Critical assertion: verify no path doubling
|
||||
// The bug would cause "kopia/logpaste/kopia.blobcfg" to become
|
||||
// "kopia/logpaste/kopia/logpaste/kopia.blobcfg"
|
||||
assert.False(t, hasDoubledPath(key),
|
||||
"Path doubling detected! Key '%s' should not have repeated path segments", key)
|
||||
|
||||
// Also verify we can actually GET the object using the listed key
|
||||
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to GET object using listed key: %s", key)
|
||||
getResp.Body.Close()
|
||||
}
|
||||
|
||||
// Verify we got the expected keys
|
||||
assert.ElementsMatch(t, tc.expectedKeys, actualKeys,
|
||||
"%s: Listed keys should match expected", tc.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVeleroKopiaGetAfterList simulates Kopia's pattern of listing and then getting objects
|
||||
// This specifically tests the bug where listed paths couldn't be retrieved
|
||||
func TestVeleroKopiaGetAfterList(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := getNewBucketName()
|
||||
|
||||
createBucket(t, client, bucketName)
|
||||
defer deleteBucket(t, client, bucketName)
|
||||
enableVersioning(t, client, bucketName)
|
||||
|
||||
// Create a nested object like Kopia does
|
||||
objectKey := "kopia/appwrite/kopia.repository"
|
||||
objectContent := "kopia-repository-config-data"
|
||||
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(objectContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List objects (this is where the bug manifested - returned wrong Key)
|
||||
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("kopia/appwrite/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listResp.Contents, 1, "Should list exactly one object")
|
||||
|
||||
listedKey := *listResp.Contents[0].Key
|
||||
|
||||
// The bug caused listedKey to be "kopia/appwrite/kopia/appwrite/kopia.repository"
|
||||
// instead of "kopia/appwrite/kopia.repository"
|
||||
assert.Equal(t, objectKey, listedKey,
|
||||
"Listed key should match the original key without path doubling")
|
||||
|
||||
// Now try to GET the object using the listed key
|
||||
// With the bug, this would fail because the doubled path doesn't exist
|
||||
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(listedKey),
|
||||
})
|
||||
require.NoError(t, err, "Should be able to GET object using the key returned from ListObjects")
|
||||
defer getResp.Body.Close()
|
||||
|
||||
// Verify content matches
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, objectContent, string(body),
|
||||
"Retrieved content should match original")
|
||||
}
|
||||
|
||||
// TestVeleroMultipleVersionsWithNestedPaths tests listing when objects have multiple versions
|
||||
func TestVeleroMultipleVersionsWithNestedPaths(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := getNewBucketName()
|
||||
|
||||
createBucket(t, client, bucketName)
|
||||
defer deleteBucket(t, client, bucketName)
|
||||
enableVersioning(t, client, bucketName)
|
||||
|
||||
objectKey := "kopia/logpaste/kopia.blobcfg"
|
||||
|
||||
// Create multiple versions of the same object (Kopia updates config files)
|
||||
versions := []string{"version-1", "version-2", "version-3"}
|
||||
for _, content := range versions {
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(content),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// List objects - should return only the latest version in regular listing
|
||||
listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("kopia/logpaste/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only see one object (the latest version)
|
||||
require.Len(t, listResp.Contents, 1, "Should list only one object (latest version)")
|
||||
|
||||
listedKey := *listResp.Contents[0].Key
|
||||
assert.Equal(t, objectKey, listedKey,
|
||||
"Listed key should match original without path doubling")
|
||||
assert.False(t, hasDoubledPath(listedKey),
|
||||
"Path should not be doubled")
|
||||
|
||||
// Verify we can GET the latest version
|
||||
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(listedKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version-3", string(body),
|
||||
"Should get the latest version content")
|
||||
}
|
||||
|
||||
// TestVeleroListVersionsWithNestedPaths tests ListObjectVersions with nested paths
|
||||
func TestVeleroListVersionsWithNestedPaths(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucketName := getNewBucketName()
|
||||
|
||||
createBucket(t, client, bucketName)
|
||||
defer deleteBucket(t, client, bucketName)
|
||||
enableVersioning(t, client, bucketName)
|
||||
|
||||
objectKey := "backups/velero/test-backup/backup.json"
|
||||
|
||||
// Create multiple versions
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: strings.NewReader(strings.Repeat("x", i*100)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// List all versions
|
||||
listResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("backups/velero/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 3 versions
|
||||
require.Len(t, listResp.Versions, 3, "Should have 3 versions")
|
||||
|
||||
// Verify all version keys are correct (not doubled)
|
||||
for _, version := range listResp.Versions {
|
||||
key := *version.Key
|
||||
assert.Equal(t, objectKey, key,
|
||||
"Version key should match original")
|
||||
assert.False(t, hasDoubledPath(key),
|
||||
"Version key should not have doubled path: %s", key)
|
||||
|
||||
// Verify we can GET each version by its version ID
|
||||
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
VersionId: version.VersionId,
|
||||
})
|
||||
require.NoError(t, err, "Should be able to GET version %s", *version.VersionId)
|
||||
func() {
|
||||
defer getResp.Body.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// hasDoubledPath checks if a path has a repeated pair of segments, which indicates
|
||||
// the path doubling bug. For example: "kopia/logpaste/kopia/logpaste/file.txt" would return true.
|
||||
func hasDoubledPath(path string) bool {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for repeated pairs of segments, e.g., a/b/.../a/b.
|
||||
for i := 0; i < len(parts)-3; i++ {
|
||||
for j := i + 2; j < len(parts)-1; j++ {
|
||||
if parts[i] == parts[j] && parts[i+1] == parts[j+1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
# Makefile for TUS Protocol Integration Tests
|
||||
# This Makefile provides targets for running TUS (resumable upload) integration tests
|
||||
|
||||
# Default values
|
||||
SEAWEEDFS_BINARY ?= weed
|
||||
FILER_PORT ?= 18888
|
||||
VOLUME_PORT ?= 18080
|
||||
MASTER_PORT ?= 19333
|
||||
TEST_TIMEOUT ?= 10m
|
||||
VOLUME_MAX_SIZE_MB ?= 50
|
||||
VOLUME_MAX_COUNT ?= 100
|
||||
|
||||
# Test directory
|
||||
TEST_DIR := $(shell pwd)
|
||||
SEAWEEDFS_ROOT := $(shell cd ../.. && pwd)
|
||||
|
||||
# Colors for output
|
||||
RED := \033[0;31m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[1;33m
|
||||
NC := \033[0m # No Color
|
||||
|
||||
.PHONY: all test clean start-seaweedfs stop-seaweedfs check-binary build-weed help test-basic test-chunked test-resume test-errors test-with-server
|
||||
|
||||
all: test
|
||||
|
||||
# Build SeaweedFS binary
|
||||
build-weed:
|
||||
@echo "Building SeaweedFS binary..."
|
||||
@cd $(SEAWEEDFS_ROOT)/weed && go build -o weed
|
||||
@echo "$(GREEN)SeaweedFS binary built successfully$(NC)"
|
||||
|
||||
help:
|
||||
@echo "SeaweedFS TUS Protocol Integration Tests"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " test - Run all TUS integration tests"
|
||||
@echo " test-basic - Run basic TUS upload tests"
|
||||
@echo " test-chunked - Run chunked upload tests"
|
||||
@echo " test-resume - Run upload resume tests"
|
||||
@echo " test-errors - Run error handling tests"
|
||||
@echo " test-with-server - Run tests with automatic server management"
|
||||
@echo " start-seaweedfs - Start SeaweedFS server for testing"
|
||||
@echo " stop-seaweedfs - Stop SeaweedFS server"
|
||||
@echo " clean - Clean up test artifacts"
|
||||
@echo " check-binary - Check if SeaweedFS binary exists"
|
||||
@echo " build-weed - Build SeaweedFS binary"
|
||||
@echo ""
|
||||
@echo "Configuration:"
|
||||
@echo " SEAWEEDFS_BINARY=$(SEAWEEDFS_BINARY)"
|
||||
@echo " FILER_PORT=$(FILER_PORT)"
|
||||
@echo " VOLUME_PORT=$(VOLUME_PORT)"
|
||||
@echo " MASTER_PORT=$(MASTER_PORT)"
|
||||
@echo " TEST_TIMEOUT=$(TEST_TIMEOUT)"
|
||||
|
||||
check-binary:
|
||||
@if ! command -v $(SEAWEEDFS_BINARY) > /dev/null 2>&1 && [ ! -f "$(SEAWEEDFS_ROOT)/weed/weed" ]; then \
|
||||
echo "$(RED)Error: SeaweedFS binary not found$(NC)"; \
|
||||
echo "Please build SeaweedFS first: make build-weed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(GREEN)SeaweedFS binary found$(NC)"
|
||||
|
||||
start-seaweedfs: check-binary
|
||||
@echo "$(YELLOW)Starting SeaweedFS server for TUS testing...$(NC)"
|
||||
@# Clean up any existing processes on our test ports
|
||||
@lsof -ti :$(MASTER_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@lsof -ti :$(VOLUME_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@lsof -ti :$(FILER_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@sleep 2
|
||||
|
||||
# Create necessary directories
|
||||
@mkdir -p /tmp/seaweedfs-test-tus-master
|
||||
@mkdir -p /tmp/seaweedfs-test-tus-volume
|
||||
@mkdir -p /tmp/seaweedfs-test-tus-filer
|
||||
|
||||
# Start master server (use freshly built binary)
|
||||
@echo "Starting master server..."
|
||||
@nohup $(SEAWEEDFS_ROOT)/weed/weed master \
|
||||
-port=$(MASTER_PORT) \
|
||||
-mdir=/tmp/seaweedfs-test-tus-master \
|
||||
-volumeSizeLimitMB=$(VOLUME_MAX_SIZE_MB) \
|
||||
-ip=127.0.0.1 \
|
||||
> /tmp/seaweedfs-tus-master.log 2>&1 &
|
||||
@sleep 3
|
||||
|
||||
# Start volume server
|
||||
@echo "Starting volume server..."
|
||||
@nohup $(SEAWEEDFS_ROOT)/weed/weed volume \
|
||||
-port=$(VOLUME_PORT) \
|
||||
-mserver=127.0.0.1:$(MASTER_PORT) \
|
||||
-dir=/tmp/seaweedfs-test-tus-volume \
|
||||
-max=$(VOLUME_MAX_COUNT) \
|
||||
-ip=127.0.0.1 \
|
||||
> /tmp/seaweedfs-tus-volume.log 2>&1 &
|
||||
@sleep 3
|
||||
|
||||
# Start filer server with TUS enabled (default tusBasePath is .tus)
|
||||
@echo "Starting filer server..."
|
||||
@nohup $(SEAWEEDFS_ROOT)/weed/weed filer \
|
||||
-port=$(FILER_PORT) \
|
||||
-master=127.0.0.1:$(MASTER_PORT) \
|
||||
-ip=127.0.0.1 \
|
||||
> /tmp/seaweedfs-tus-filer.log 2>&1 &
|
||||
@sleep 5
|
||||
|
||||
# Wait for filer to be ready
|
||||
@echo "$(YELLOW)Waiting for filer to be ready...$(NC)"
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -s -f http://127.0.0.1:$(FILER_PORT)/ > /dev/null 2>&1; then \
|
||||
echo "$(GREEN)Filer is ready$(NC)"; \
|
||||
break; \
|
||||
fi; \
|
||||
if [ $$i -eq 30 ]; then \
|
||||
echo "$(RED)Filer failed to start within 30 seconds$(NC)"; \
|
||||
$(MAKE) debug-logs; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "Waiting for filer... ($$i/30)"; \
|
||||
sleep 1; \
|
||||
done
|
||||
|
||||
@echo "$(GREEN)SeaweedFS server started successfully for TUS testing$(NC)"
|
||||
@echo "Master: http://localhost:$(MASTER_PORT)"
|
||||
@echo "Volume: http://localhost:$(VOLUME_PORT)"
|
||||
@echo "Filer: http://localhost:$(FILER_PORT)"
|
||||
@echo "TUS Endpoint: http://localhost:$(FILER_PORT)/.tus/"
|
||||
|
||||
stop-seaweedfs:
|
||||
@echo "$(YELLOW)Stopping SeaweedFS server...$(NC)"
|
||||
@lsof -ti :$(MASTER_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@lsof -ti :$(VOLUME_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@lsof -ti :$(FILER_PORT) | grep -v '^$$' | xargs kill -TERM 2>/dev/null || true
|
||||
@sleep 2
|
||||
@echo "$(GREEN)SeaweedFS server stopped$(NC)"
|
||||
|
||||
clean:
|
||||
@echo "$(YELLOW)Cleaning up TUS test artifacts...$(NC)"
|
||||
@rm -rf /tmp/seaweedfs-test-tus-*
|
||||
@rm -f /tmp/seaweedfs-tus-*.log
|
||||
@echo "$(GREEN)TUS test cleanup completed$(NC)"
|
||||
|
||||
# Run all tests
|
||||
test: check-binary
|
||||
@echo "$(YELLOW)Running all TUS integration tests...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) ./test/tus/...
|
||||
@echo "$(GREEN)All TUS tests completed$(NC)"
|
||||
|
||||
# Run basic upload tests
|
||||
test-basic: check-binary
|
||||
@echo "$(YELLOW)Running basic TUS upload tests...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) -run "TestTusBasicUpload|TestTusOptionsHandler" ./test/tus/...
|
||||
@echo "$(GREEN)Basic TUS tests completed$(NC)"
|
||||
|
||||
# Run chunked upload tests
|
||||
test-chunked: check-binary
|
||||
@echo "$(YELLOW)Running chunked TUS upload tests...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) -run "TestTusChunkedUpload" ./test/tus/...
|
||||
@echo "$(GREEN)Chunked TUS tests completed$(NC)"
|
||||
|
||||
# Run resume tests
|
||||
test-resume: check-binary
|
||||
@echo "$(YELLOW)Running TUS upload resume tests...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) -run "TestTusResumeAfterInterruption|TestTusHeadRequest" ./test/tus/...
|
||||
@echo "$(GREEN)TUS resume tests completed$(NC)"
|
||||
|
||||
# Run error handling tests
|
||||
test-errors: check-binary
|
||||
@echo "$(YELLOW)Running TUS error handling tests...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) -run "TestTusInvalidOffset|TestTusUploadNotFound|TestTusDeleteUpload" ./test/tus/...
|
||||
@echo "$(GREEN)TUS error tests completed$(NC)"
|
||||
|
||||
# Run tests with automatic server management
|
||||
test-with-server: build-weed
|
||||
@echo "$(YELLOW)Running TUS tests with automatic server management...$(NC)"
|
||||
@$(MAKE) -C $(TEST_DIR) start-seaweedfs && \
|
||||
sleep 3 && \
|
||||
cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) ./test/tus/...; \
|
||||
TEST_RESULT=$$?; \
|
||||
$(MAKE) -C $(TEST_DIR) stop-seaweedfs; \
|
||||
$(MAKE) -C $(TEST_DIR) clean; \
|
||||
if [ $$TEST_RESULT -eq 0 ]; then echo "$(GREEN)All TUS tests passed!$(NC)"; fi; \
|
||||
exit $$TEST_RESULT
|
||||
|
||||
# Debug targets
|
||||
debug-logs:
|
||||
@echo "$(YELLOW)=== Master Log ===$(NC)"
|
||||
@tail -n 50 /tmp/seaweedfs-tus-master.log 2>/dev/null || echo "No master log found"
|
||||
@echo "$(YELLOW)=== Volume Log ===$(NC)"
|
||||
@tail -n 50 /tmp/seaweedfs-tus-volume.log 2>/dev/null || echo "No volume log found"
|
||||
@echo "$(YELLOW)=== Filer Log ===$(NC)"
|
||||
@tail -n 50 /tmp/seaweedfs-tus-filer.log 2>/dev/null || echo "No filer log found"
|
||||
|
||||
debug-status:
|
||||
@echo "$(YELLOW)=== Process Status ===$(NC)"
|
||||
@ps aux | grep -E "(weed|seaweedfs)" | grep -v grep || echo "No SeaweedFS processes found"
|
||||
@echo "$(YELLOW)=== Port Status ===$(NC)"
|
||||
@lsof -i :$(MASTER_PORT) -i :$(VOLUME_PORT) -i :$(FILER_PORT) 2>/dev/null || echo "No ports in use"
|
||||
|
||||
# Manual testing targets
|
||||
manual-start: start-seaweedfs
|
||||
@echo "$(GREEN)SeaweedFS is now running for manual TUS testing$(NC)"
|
||||
@echo ""
|
||||
@echo "TUS Endpoints:"
|
||||
@echo " OPTIONS /.tus/ - Capability discovery"
|
||||
@echo " POST /.tus/{path} - Create upload"
|
||||
@echo " HEAD /.tus/.uploads/{id} - Get offset"
|
||||
@echo " PATCH /.tus/.uploads/{id} - Upload data"
|
||||
@echo " DELETE /.tus/.uploads/{id} - Cancel upload"
|
||||
@echo ""
|
||||
@echo "Example curl commands:"
|
||||
@echo " curl -X OPTIONS http://localhost:$(FILER_PORT)/.tus/ -H 'Tus-Resumable: 1.0.0'"
|
||||
@echo ""
|
||||
@echo "Run 'make manual-stop' when finished"
|
||||
|
||||
manual-stop: stop-seaweedfs clean
|
||||
|
||||
# CI targets
|
||||
ci-test: test-with-server
|
||||
|
||||
# Skip integration tests (short mode)
|
||||
test-short:
|
||||
@echo "$(YELLOW)Running TUS tests in short mode (skipping integration tests)...$(NC)"
|
||||
@cd $(SEAWEEDFS_ROOT) && go test -v -short ./test/tus/...
|
||||
@echo "$(GREEN)Short tests completed$(NC)"
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# TUS Protocol Integration Tests
|
||||
|
||||
This directory contains integration tests for the TUS (resumable upload) protocol support in SeaweedFS Filer.
|
||||
|
||||
## Overview
|
||||
|
||||
TUS is an open protocol for resumable file uploads over HTTP. It allows clients to upload files in chunks and resume uploads after network failures or interruptions.
|
||||
|
||||
### Why TUS?
|
||||
|
||||
- **Resumable uploads**: Resume interrupted uploads without re-sending data
|
||||
- **Chunked uploads**: Upload large files in smaller pieces
|
||||
- **Simple protocol**: Standard HTTP methods with custom headers
|
||||
- **Wide client support**: Libraries available for JavaScript, Python, Go, and more
|
||||
|
||||
## TUS Protocol Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `OPTIONS` | `/.tus/` | Server capability discovery |
|
||||
| `POST` | `/.tus/{path}` | Create new upload session |
|
||||
| `HEAD` | `/.tus/.uploads/{id}` | Get current upload offset |
|
||||
| `PATCH` | `/.tus/.uploads/{id}` | Upload data at offset |
|
||||
| `DELETE` | `/.tus/.uploads/{id}` | Cancel upload |
|
||||
|
||||
### TUS Headers
|
||||
|
||||
**Request Headers:**
|
||||
- `Tus-Resumable: 1.0.0` - Protocol version (required)
|
||||
- `Upload-Length` - Total file size in bytes (required on POST)
|
||||
- `Upload-Offset` - Current byte offset (required on PATCH)
|
||||
- `Upload-Metadata` - Base64-encoded key-value pairs (optional)
|
||||
- `Content-Type: application/offset+octet-stream` (required on PATCH)
|
||||
|
||||
**Response Headers:**
|
||||
- `Tus-Resumable` - Protocol version
|
||||
- `Tus-Version` - Supported versions
|
||||
- `Tus-Extension` - Supported extensions
|
||||
- `Tus-Max-Size` - Maximum upload size
|
||||
- `Upload-Offset` - Current byte offset
|
||||
- `Location` - Upload URL (on POST)
|
||||
|
||||
## Enabling TUS
|
||||
|
||||
TUS protocol support is enabled by default at `/.tus` path. You can customize the path using the `-tusBasePath` flag:
|
||||
|
||||
```bash
|
||||
# Start filer with default TUS path (/.tus)
|
||||
weed filer -master=localhost:9333
|
||||
|
||||
# Use a custom path (leading slash added automatically if missing)
|
||||
weed filer -master=localhost:9333 -tusBasePath=/.uploads/tus
|
||||
|
||||
# Disable TUS by setting empty path
|
||||
weed filer -master=localhost:9333 -tusBasePath=
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Integration Tests
|
||||
|
||||
The tests cover:
|
||||
|
||||
1. **Basic Functionality**
|
||||
- `TestTusOptionsHandler` - Capability discovery
|
||||
- `TestTusBasicUpload` - Simple complete upload
|
||||
- `TestTusCreationWithUpload` - Creation-with-upload extension
|
||||
|
||||
2. **Chunked Uploads**
|
||||
- `TestTusChunkedUpload` - Upload in multiple chunks
|
||||
|
||||
3. **Resumable Uploads**
|
||||
- `TestTusHeadRequest` - Offset tracking
|
||||
- `TestTusResumeAfterInterruption` - Resume after failure
|
||||
|
||||
4. **Error Handling**
|
||||
- `TestTusInvalidOffset` - Offset mismatch (409 Conflict)
|
||||
- `TestTusUploadNotFound` - Missing upload (404 Not Found)
|
||||
- `TestTusDeleteUpload` - Upload cancellation
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Build SeaweedFS**:
|
||||
```bash
|
||||
make build-weed
|
||||
# or
|
||||
cd ../../weed && go build -o weed
|
||||
```
|
||||
|
||||
### Using Makefile
|
||||
|
||||
```bash
|
||||
# Show available targets
|
||||
make help
|
||||
|
||||
# Run all tests with automatic server management
|
||||
make test-with-server
|
||||
|
||||
# Run all tests (requires running server)
|
||||
make test
|
||||
|
||||
# Run specific test categories
|
||||
make test-basic # Basic upload tests
|
||||
make test-chunked # Chunked upload tests
|
||||
make test-resume # Resume/HEAD tests
|
||||
make test-errors # Error handling tests
|
||||
|
||||
# Manual testing
|
||||
make manual-start # Start SeaweedFS for manual testing
|
||||
make manual-stop # Stop and cleanup
|
||||
```
|
||||
|
||||
### Using Go Test Directly
|
||||
|
||||
```bash
|
||||
# Run all TUS tests
|
||||
go test -v ./test/tus/...
|
||||
|
||||
# Run specific test
|
||||
go test -v ./test/tus -run TestTusBasicUpload
|
||||
|
||||
# Skip integration tests (short mode)
|
||||
go test -v -short ./test/tus/...
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
# View server logs
|
||||
make debug-logs
|
||||
|
||||
# Check process and port status
|
||||
make debug-status
|
||||
```
|
||||
|
||||
## Test Environment
|
||||
|
||||
Each test run:
|
||||
1. Starts a SeaweedFS cluster (master, volume, filer)
|
||||
2. Creates uploads using TUS protocol
|
||||
3. Verifies files are stored correctly
|
||||
4. Cleans up test data
|
||||
|
||||
### Default Ports
|
||||
|
||||
| Service | Port |
|
||||
|---------|------|
|
||||
| Master | 19333 |
|
||||
| Volume | 18080 |
|
||||
| Filer | 18888 |
|
||||
|
||||
### Configuration
|
||||
|
||||
Override defaults via environment or Makefile variables:
|
||||
```bash
|
||||
FILER_PORT=8889 MASTER_PORT=9334 make test
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Create Upload
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18888/.tus/mydir/file.txt \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Length: 1000" \
|
||||
-H "Upload-Metadata: filename dGVzdC50eHQ="
|
||||
```
|
||||
|
||||
### Upload Data
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:18888/.tus/.uploads/{upload-id} \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Offset: 0" \
|
||||
-H "Content-Type: application/offset+octet-stream" \
|
||||
--data-binary @file.txt
|
||||
```
|
||||
|
||||
### Check Offset
|
||||
|
||||
```bash
|
||||
curl -I http://localhost:18888/.tus/.uploads/{upload-id} \
|
||||
-H "Tus-Resumable: 1.0.0"
|
||||
```
|
||||
|
||||
### Cancel Upload
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:18888/.tus/.uploads/{upload-id} \
|
||||
-H "Tus-Resumable: 1.0.0"
|
||||
```
|
||||
|
||||
## TUS Extensions Supported
|
||||
|
||||
- **creation**: Create new uploads with POST
|
||||
- **creation-with-upload**: Send data in creation request
|
||||
- **termination**: Cancel uploads with DELETE
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Client Filer Volume Servers
|
||||
| | |
|
||||
|-- POST /.tus/path/file.mp4 ->| |
|
||||
| |-- Create session dir ------->|
|
||||
|<-- 201 Location: /.../{id} --| |
|
||||
| | |
|
||||
|-- PATCH /.tus/.uploads/{id} >| |
|
||||
| Upload-Offset: 0 |-- Assign volume ------------>|
|
||||
| [chunk data] |-- Upload chunk ------------->|
|
||||
|<-- 204 Upload-Offset: N -----| |
|
||||
| | |
|
||||
| (network failure) | |
|
||||
| | |
|
||||
|-- HEAD /.tus/.uploads/{id} ->| |
|
||||
|<-- Upload-Offset: N ---------| |
|
||||
| | |
|
||||
|-- PATCH (resume) ----------->|-- Upload remaining -------->|
|
||||
|<-- 204 (complete) -----------|-- Assemble final file ----->|
|
||||
```
|
||||
|
||||
## Comparison with S3 Multipart
|
||||
|
||||
| Feature | TUS | S3 Multipart |
|
||||
|---------|-----|--------------|
|
||||
| Protocol | Custom HTTP headers | S3 API |
|
||||
| Session Init | POST with Upload-Length | CreateMultipartUpload |
|
||||
| Upload Data | PATCH with offset | UploadPart with partNumber |
|
||||
| Resume | HEAD to get offset | ListParts |
|
||||
| Complete | Automatic at final offset | CompleteMultipartUpload |
|
||||
| Ordering | Sequential (offset-based) | Parallel (part numbers) |
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [TUS Protocol Specification](https://tus.io/protocols/resumable-upload)
|
||||
- [tus-js-client](https://github.com/tus/tus-js-client) - JavaScript client
|
||||
- [go-tus](https://github.com/eventials/go-tus) - Go client
|
||||
- [SeaweedFS S3 API](../../weed/s3api) - Alternative multipart upload
|
||||
@@ -0,0 +1,775 @@
|
||||
package tus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
TusVersion = "1.0.0"
|
||||
testFilerPort = "18888"
|
||||
testMasterPort = "19333"
|
||||
testVolumePort = "18080"
|
||||
)
|
||||
|
||||
// TestCluster represents a running SeaweedFS cluster for testing
|
||||
type TestCluster struct {
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
filerCmd *exec.Cmd
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func (c *TestCluster) Stop() {
|
||||
if c.filerCmd != nil && c.filerCmd.Process != nil {
|
||||
c.filerCmd.Process.Signal(os.Interrupt)
|
||||
c.filerCmd.Wait()
|
||||
}
|
||||
if c.volumeCmd != nil && c.volumeCmd.Process != nil {
|
||||
c.volumeCmd.Process.Signal(os.Interrupt)
|
||||
c.volumeCmd.Wait()
|
||||
}
|
||||
if c.masterCmd != nil && c.masterCmd.Process != nil {
|
||||
c.masterCmd.Process.Signal(os.Interrupt)
|
||||
c.masterCmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TestCluster) FilerURL() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%s", testFilerPort)
|
||||
}
|
||||
|
||||
func (c *TestCluster) TusURL() string {
|
||||
return fmt.Sprintf("%s/.tus", c.FilerURL())
|
||||
}
|
||||
|
||||
// FullURL converts a relative path to a full URL
|
||||
func (c *TestCluster) FullURL(path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
return fmt.Sprintf("http://127.0.0.1:%s%s", testFilerPort, path)
|
||||
}
|
||||
|
||||
// startTestCluster starts a SeaweedFS cluster for testing
|
||||
func startTestCluster(t *testing.T, ctx context.Context) (*TestCluster, error) {
|
||||
weedBinary := findWeedBinary()
|
||||
if weedBinary == "" {
|
||||
return nil, fmt.Errorf("weed binary not found - please build it first: cd weed && go build")
|
||||
}
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "seaweedfs_tus_test_")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cluster := &TestCluster{dataDir: dataDir}
|
||||
|
||||
// Create subdirectories
|
||||
masterDir := filepath.Join(dataDir, "master")
|
||||
volumeDir := filepath.Join(dataDir, "volume")
|
||||
filerDir := filepath.Join(dataDir, "filer")
|
||||
os.MkdirAll(masterDir, 0755)
|
||||
os.MkdirAll(volumeDir, 0755)
|
||||
os.MkdirAll(filerDir, 0755)
|
||||
|
||||
// Start master
|
||||
masterCmd := exec.CommandContext(ctx, weedBinary, "master",
|
||||
"-port", testMasterPort,
|
||||
"-mdir", masterDir,
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
masterLogFile, err := os.Create(filepath.Join(masterDir, "master.log"))
|
||||
if err != nil {
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to create master log: %v", err)
|
||||
}
|
||||
masterCmd.Stdout = masterLogFile
|
||||
masterCmd.Stderr = masterLogFile
|
||||
if err := masterCmd.Start(); err != nil {
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to start master: %v", err)
|
||||
}
|
||||
cluster.masterCmd = masterCmd
|
||||
|
||||
// Wait for master to be ready
|
||||
if err := waitForHTTPServer("http://127.0.0.1:"+testMasterPort+"/dir/status", 30*time.Second); err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("master not ready: %v", err)
|
||||
}
|
||||
|
||||
// Start volume server
|
||||
volumeCmd := exec.CommandContext(ctx, weedBinary, "volume",
|
||||
"-port", testVolumePort,
|
||||
"-dir", volumeDir,
|
||||
"-mserver", "127.0.0.1:"+testMasterPort,
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
volumeLogFile, err := os.Create(filepath.Join(volumeDir, "volume.log"))
|
||||
if err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to create volume log: %v", err)
|
||||
}
|
||||
volumeCmd.Stdout = volumeLogFile
|
||||
volumeCmd.Stderr = volumeLogFile
|
||||
if err := volumeCmd.Start(); err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to start volume server: %v", err)
|
||||
}
|
||||
cluster.volumeCmd = volumeCmd
|
||||
|
||||
// Wait for volume server to register with master
|
||||
if err := waitForHTTPServer("http://127.0.0.1:"+testVolumePort+"/status", 30*time.Second); err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("volume server not ready: %v", err)
|
||||
}
|
||||
|
||||
// Start filer with TUS enabled
|
||||
filerCmd := exec.CommandContext(ctx, weedBinary, "filer",
|
||||
"-port", testFilerPort,
|
||||
"-master", "127.0.0.1:"+testMasterPort,
|
||||
"-ip", "127.0.0.1",
|
||||
"-defaultStoreDir", filerDir,
|
||||
"-tusBasePath", "/.tus",
|
||||
)
|
||||
filerLogFile, err := os.Create(filepath.Join(filerDir, "filer.log"))
|
||||
if err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to create filer log: %v", err)
|
||||
}
|
||||
filerCmd.Stdout = filerLogFile
|
||||
filerCmd.Stderr = filerLogFile
|
||||
if err := filerCmd.Start(); err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("failed to start filer: %v", err)
|
||||
}
|
||||
cluster.filerCmd = filerCmd
|
||||
|
||||
// Wait for filer
|
||||
if err := waitForHTTPServer("http://127.0.0.1:"+testFilerPort+"/", 30*time.Second); err != nil {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("filer not ready: %v", err)
|
||||
}
|
||||
|
||||
// Wait a bit more for the cluster to fully stabilize
|
||||
// Volumes are created lazily, and we need to ensure the master topology is ready
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func findWeedBinary() string {
|
||||
candidates := []string{
|
||||
"../../weed/weed",
|
||||
"../weed/weed",
|
||||
"./weed/weed",
|
||||
"weed",
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("weed"); err == nil {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForHTTPServer(url string, timeout time.Duration) error {
|
||||
start := time.Now()
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
for time.Since(start) < timeout {
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %s", url)
|
||||
}
|
||||
|
||||
// encodeTusMetadata encodes key-value pairs for Upload-Metadata header
|
||||
func encodeTusMetadata(metadata map[string]string) string {
|
||||
var parts []string
|
||||
for k, v := range metadata {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(v))
|
||||
parts = append(parts, fmt.Sprintf("%s %s", k, encoded))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// TestTusOptionsHandler tests the OPTIONS endpoint for capability discovery
|
||||
func TestTusOptionsHandler(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
// Test OPTIONS request
|
||||
req, err := http.NewRequest(http.MethodOptions, cluster.TusURL()+"/", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify TUS headers
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "OPTIONS should return 200 OK")
|
||||
assert.Equal(t, TusVersion, resp.Header.Get("Tus-Resumable"), "Should return Tus-Resumable header")
|
||||
assert.NotEmpty(t, resp.Header.Get("Tus-Version"), "Should return Tus-Version header")
|
||||
assert.NotEmpty(t, resp.Header.Get("Tus-Extension"), "Should return Tus-Extension header")
|
||||
assert.NotEmpty(t, resp.Header.Get("Tus-Max-Size"), "Should return Tus-Max-Size header")
|
||||
}
|
||||
|
||||
// TestTusBasicUpload tests a simple complete upload
|
||||
func TestTusBasicUpload(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
testData := []byte("Hello, TUS Protocol! This is a test file.")
|
||||
targetPath := "/testdir/testfile.txt"
|
||||
|
||||
// Step 1: Create upload (POST)
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
createReq.Header.Set("Upload-Metadata", encodeTusMetadata(map[string]string{
|
||||
"filename": "testfile.txt",
|
||||
"content-type": "text/plain",
|
||||
}))
|
||||
|
||||
client := &http.Client{}
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode, "POST should return 201 Created")
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
assert.NotEmpty(t, uploadLocation, "Should return Location header with upload URL")
|
||||
t.Logf("Upload location: %s", uploadLocation)
|
||||
|
||||
// Step 2: Upload data (PATCH)
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", "0")
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
patchReq.Header.Set("Content-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
defer patchResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, patchResp.StatusCode, "PATCH should return 204 No Content")
|
||||
newOffset := patchResp.Header.Get("Upload-Offset")
|
||||
assert.Equal(t, strconv.Itoa(len(testData)), newOffset, "Upload-Offset should equal total file size")
|
||||
|
||||
// Step 3: Verify the file was created
|
||||
getResp, err := client.Get(cluster.FilerURL() + targetPath)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode, "GET should return 200 OK")
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, body, "File content should match uploaded data")
|
||||
}
|
||||
|
||||
// TestTusChunkedUpload tests uploading a file in multiple chunks
|
||||
func TestTusChunkedUpload(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
// Create test data (100KB)
|
||||
testData := make([]byte, 100*1024)
|
||||
for i := range testData {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
chunkSize := 32 * 1024 // 32KB chunks
|
||||
targetPath := "/chunked/largefile.bin"
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Step 1: Create upload
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
require.NotEmpty(t, uploadLocation)
|
||||
t.Logf("Upload location: %s", uploadLocation)
|
||||
|
||||
// Step 2: Upload in chunks
|
||||
offset := 0
|
||||
for offset < len(testData) {
|
||||
end := offset + chunkSize
|
||||
if end > len(testData) {
|
||||
end = len(testData)
|
||||
}
|
||||
chunk := testData[offset:end]
|
||||
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(chunk))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", strconv.Itoa(offset))
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
patchReq.Header.Set("Content-Length", strconv.Itoa(len(chunk)))
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
patchResp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusNoContent, patchResp.StatusCode,
|
||||
"PATCH chunk at offset %d should return 204", offset)
|
||||
newOffset, err := strconv.Atoi(patchResp.Header.Get("Upload-Offset"))
|
||||
require.NoError(t, err, "Upload-Offset header should be a valid integer")
|
||||
require.Equal(t, end, newOffset, "New offset should be %d", end)
|
||||
|
||||
t.Logf("Uploaded chunk: offset=%d, size=%d, newOffset=%d", offset, len(chunk), newOffset)
|
||||
offset = end
|
||||
}
|
||||
|
||||
// Step 3: Verify the complete file
|
||||
getResp, err := client.Get(cluster.FilerURL() + targetPath)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, body, "File content should match uploaded data")
|
||||
}
|
||||
|
||||
// TestTusHeadRequest tests the HEAD endpoint to get upload offset
|
||||
func TestTusHeadRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
testData := []byte("Test data for HEAD request verification")
|
||||
targetPath := "/headtest/file.txt"
|
||||
client := &http.Client{}
|
||||
|
||||
// Create upload
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
|
||||
// HEAD before any data uploaded - offset should be 0
|
||||
headReq1, err := http.NewRequest(http.MethodHead, cluster.FullURL(uploadLocation), nil)
|
||||
require.NoError(t, err)
|
||||
headReq1.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
headResp1, err := client.Do(headReq1)
|
||||
require.NoError(t, err)
|
||||
defer headResp1.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, headResp1.StatusCode)
|
||||
assert.Equal(t, "0", headResp1.Header.Get("Upload-Offset"), "Initial offset should be 0")
|
||||
assert.Equal(t, strconv.Itoa(len(testData)), headResp1.Header.Get("Upload-Length"))
|
||||
|
||||
// Upload half the data
|
||||
halfLen := len(testData) / 2
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData[:halfLen]))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", "0")
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
patchResp.Body.Close()
|
||||
require.Equal(t, http.StatusNoContent, patchResp.StatusCode)
|
||||
|
||||
// HEAD after partial upload - offset should be halfLen
|
||||
headReq2, err := http.NewRequest(http.MethodHead, cluster.FullURL(uploadLocation), nil)
|
||||
require.NoError(t, err)
|
||||
headReq2.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
headResp2, err := client.Do(headReq2)
|
||||
require.NoError(t, err)
|
||||
defer headResp2.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, headResp2.StatusCode)
|
||||
assert.Equal(t, strconv.Itoa(halfLen), headResp2.Header.Get("Upload-Offset"),
|
||||
"Offset should be %d after partial upload", halfLen)
|
||||
}
|
||||
|
||||
// TestTusDeleteUpload tests canceling an in-progress upload
|
||||
func TestTusDeleteUpload(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
testData := []byte("Data to be deleted")
|
||||
targetPath := "/deletetest/file.txt"
|
||||
client := &http.Client{}
|
||||
|
||||
// Create upload
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
|
||||
// Upload some data
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData[:10]))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", "0")
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
patchResp.Body.Close()
|
||||
|
||||
// Delete the upload
|
||||
deleteReq, err := http.NewRequest(http.MethodDelete, cluster.FullURL(uploadLocation), nil)
|
||||
require.NoError(t, err)
|
||||
deleteReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
deleteResp, err := client.Do(deleteReq)
|
||||
require.NoError(t, err)
|
||||
defer deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode, "DELETE should return 204")
|
||||
|
||||
// Verify upload is gone - HEAD should return 404
|
||||
headReq, err := http.NewRequest(http.MethodHead, cluster.FullURL(uploadLocation), nil)
|
||||
require.NoError(t, err)
|
||||
headReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
headResp, err := client.Do(headReq)
|
||||
require.NoError(t, err)
|
||||
defer headResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, headResp.StatusCode, "HEAD after DELETE should return 404")
|
||||
}
|
||||
|
||||
// TestTusInvalidOffset tests error handling for mismatched offsets
|
||||
func TestTusInvalidOffset(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
testData := []byte("Test data for offset validation")
|
||||
targetPath := "/offsettest/file.txt"
|
||||
client := &http.Client{}
|
||||
|
||||
// Create upload
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
|
||||
// Try to upload with wrong offset (should be 0, but we send 100)
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", "100") // Wrong offset!
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
defer patchResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusConflict, patchResp.StatusCode,
|
||||
"PATCH with wrong offset should return 409 Conflict")
|
||||
}
|
||||
|
||||
// TestTusUploadNotFound tests accessing a non-existent upload
|
||||
func TestTusUploadNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
client := &http.Client{}
|
||||
fakeUploadURL := cluster.TusURL() + "/.uploads/nonexistent-upload-id"
|
||||
|
||||
// HEAD on non-existent upload
|
||||
headReq, err := http.NewRequest(http.MethodHead, fakeUploadURL, nil)
|
||||
require.NoError(t, err)
|
||||
headReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
headResp, err := client.Do(headReq)
|
||||
require.NoError(t, err)
|
||||
defer headResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, headResp.StatusCode,
|
||||
"HEAD on non-existent upload should return 404")
|
||||
|
||||
// PATCH on non-existent upload
|
||||
patchReq, err := http.NewRequest(http.MethodPatch, fakeUploadURL, bytes.NewReader([]byte("data")))
|
||||
require.NoError(t, err)
|
||||
patchReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq.Header.Set("Upload-Offset", "0")
|
||||
patchReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp, err := client.Do(patchReq)
|
||||
require.NoError(t, err)
|
||||
defer patchResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, patchResp.StatusCode,
|
||||
"PATCH on non-existent upload should return 404")
|
||||
}
|
||||
|
||||
// TestTusCreationWithUpload tests the creation-with-upload extension
|
||||
func TestTusCreationWithUpload(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
testData := []byte("Small file uploaded in creation request")
|
||||
targetPath := "/creationwithupload/smallfile.txt"
|
||||
client := &http.Client{}
|
||||
|
||||
// Create upload with data in the same request
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, bytes.NewReader(testData))
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
createReq.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
assert.NotEmpty(t, uploadLocation)
|
||||
|
||||
// Check Upload-Offset header - should indicate all data was received
|
||||
uploadOffset := createResp.Header.Get("Upload-Offset")
|
||||
assert.Equal(t, strconv.Itoa(len(testData)), uploadOffset,
|
||||
"Upload-Offset should equal file size for complete upload")
|
||||
|
||||
// Verify the file
|
||||
getResp, err := client.Get(cluster.FilerURL() + targetPath)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, body)
|
||||
}
|
||||
|
||||
// TestTusResumeAfterInterruption simulates resuming an upload after failure
|
||||
func TestTusResumeAfterInterruption(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startTestCluster(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
cluster.Stop()
|
||||
os.RemoveAll(cluster.dataDir)
|
||||
}()
|
||||
|
||||
// 50KB test data
|
||||
testData := make([]byte, 50*1024)
|
||||
for i := range testData {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
targetPath := "/resume/interrupted.bin"
|
||||
client := &http.Client{}
|
||||
|
||||
// Create upload
|
||||
createReq, err := http.NewRequest(http.MethodPost, cluster.TusURL()+targetPath, nil)
|
||||
require.NoError(t, err)
|
||||
createReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
createReq.Header.Set("Upload-Length", strconv.Itoa(len(testData)))
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
uploadLocation := createResp.Header.Get("Location")
|
||||
|
||||
// Upload first 20KB
|
||||
firstChunkSize := 20 * 1024
|
||||
patchReq1, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData[:firstChunkSize]))
|
||||
require.NoError(t, err)
|
||||
patchReq1.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq1.Header.Set("Upload-Offset", "0")
|
||||
patchReq1.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp1, err := client.Do(patchReq1)
|
||||
require.NoError(t, err)
|
||||
patchResp1.Body.Close()
|
||||
require.Equal(t, http.StatusNoContent, patchResp1.StatusCode)
|
||||
|
||||
t.Log("Simulating network interruption...")
|
||||
|
||||
// Simulate resumption: Query current offset with HEAD
|
||||
headReq, err := http.NewRequest(http.MethodHead, cluster.FullURL(uploadLocation), nil)
|
||||
require.NoError(t, err)
|
||||
headReq.Header.Set("Tus-Resumable", TusVersion)
|
||||
|
||||
headResp, err := client.Do(headReq)
|
||||
require.NoError(t, err)
|
||||
defer headResp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, headResp.StatusCode)
|
||||
currentOffset, err := strconv.Atoi(headResp.Header.Get("Upload-Offset"))
|
||||
require.NoError(t, err, "Upload-Offset header should be a valid integer")
|
||||
t.Logf("Resumed upload at offset: %d", currentOffset)
|
||||
require.Equal(t, firstChunkSize, currentOffset)
|
||||
|
||||
// Resume upload from current offset
|
||||
patchReq2, err := http.NewRequest(http.MethodPatch, cluster.FullURL(uploadLocation), bytes.NewReader(testData[currentOffset:]))
|
||||
require.NoError(t, err)
|
||||
patchReq2.Header.Set("Tus-Resumable", TusVersion)
|
||||
patchReq2.Header.Set("Upload-Offset", strconv.Itoa(currentOffset))
|
||||
patchReq2.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
|
||||
patchResp2, err := client.Do(patchReq2)
|
||||
require.NoError(t, err)
|
||||
patchResp2.Body.Close()
|
||||
require.Equal(t, http.StatusNoContent, patchResp2.StatusCode)
|
||||
|
||||
// Verify complete file
|
||||
getResp, err := client.Get(cluster.FilerURL() + targetPath)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, body, "Resumed upload should produce complete file")
|
||||
}
|
||||
+110
-37
@@ -26,9 +26,46 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
)
|
||||
|
||||
// FilerConfig holds filer configuration needed for bucket operations
|
||||
type FilerConfig struct {
|
||||
BucketsPath string
|
||||
FilerGroup string
|
||||
}
|
||||
|
||||
// getFilerConfig retrieves the filer configuration (buckets path and filer group)
|
||||
func (s *AdminServer) getFilerConfig() (*FilerConfig, error) {
|
||||
config := &FilerConfig{
|
||||
BucketsPath: s3_constants.DefaultBucketsPath,
|
||||
FilerGroup: "",
|
||||
}
|
||||
|
||||
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get filer configuration: %w", err)
|
||||
}
|
||||
if resp.DirBuckets != "" {
|
||||
config.BucketsPath = resp.DirBuckets
|
||||
}
|
||||
config.FilerGroup = resp.FilerGroup
|
||||
return nil
|
||||
})
|
||||
|
||||
return config, err
|
||||
}
|
||||
|
||||
// getCollectionName returns the collection name for a bucket, prefixed with filer group if configured
|
||||
func getCollectionName(filerGroup, bucketName string) string {
|
||||
if filerGroup != "" {
|
||||
return fmt.Sprintf("%s_%s", filerGroup, bucketName)
|
||||
}
|
||||
return bucketName
|
||||
}
|
||||
|
||||
type AdminServer struct {
|
||||
masterClient *wdclient.MasterClient
|
||||
templateFS http.FileSystem
|
||||
@@ -249,28 +286,17 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
return nil, fmt.Errorf("failed to get volume information: %w", err)
|
||||
}
|
||||
|
||||
// Get filer configuration to determine FilerGroup
|
||||
var filerGroup string
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
configResp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to get filer configuration: %v", err)
|
||||
// Continue without filer group
|
||||
return nil
|
||||
}
|
||||
filerGroup = configResp.FilerGroup
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get filer configuration (buckets path and filer group)
|
||||
filerConfig, err := s.getFilerConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get filer configuration: %w", err)
|
||||
glog.Warningf("Failed to get filer configuration, using defaults: %v", err)
|
||||
}
|
||||
|
||||
// Now list buckets from the filer and match with collection data
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// List buckets by looking at the /buckets directory
|
||||
// List buckets by looking at the buckets directory
|
||||
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
|
||||
Directory: "/buckets",
|
||||
Directory: filerConfig.BucketsPath,
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
@@ -293,12 +319,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
bucketName := resp.Entry.Name
|
||||
|
||||
// Determine collection name for this bucket
|
||||
var collectionName string
|
||||
if filerGroup != "" {
|
||||
collectionName = fmt.Sprintf("%s_%s", filerGroup, bucketName)
|
||||
} else {
|
||||
collectionName = bucketName
|
||||
}
|
||||
collectionName := getCollectionName(filerConfig.FilerGroup, bucketName)
|
||||
|
||||
// Get size and object count from collection data
|
||||
var size int64
|
||||
@@ -317,11 +338,12 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
quotaEnabled = false
|
||||
}
|
||||
|
||||
// Get versioning and object lock information from extended attributes
|
||||
// Get versioning, object lock, and owner information from extended attributes
|
||||
versioningEnabled := false
|
||||
objectLockEnabled := false
|
||||
objectLockMode := ""
|
||||
var objectLockDuration int32 = 0
|
||||
var owner string
|
||||
|
||||
if resp.Entry.Extended != nil {
|
||||
// Use shared utility to extract versioning information
|
||||
@@ -329,6 +351,11 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
|
||||
// Use shared utility to extract Object Lock information
|
||||
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry)
|
||||
|
||||
// Extract owner information
|
||||
if ownerBytes, ok := resp.Entry.Extended[s3_constants.AmzIdentityId]; ok {
|
||||
owner = string(ownerBytes)
|
||||
}
|
||||
}
|
||||
|
||||
bucket := S3Bucket{
|
||||
@@ -343,6 +370,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
ObjectLockEnabled: objectLockEnabled,
|
||||
ObjectLockMode: objectLockMode,
|
||||
ObjectLockDuration: objectLockDuration,
|
||||
Owner: owner,
|
||||
}
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
@@ -360,7 +388,13 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
|
||||
// GetBucketDetails retrieves detailed information about a specific bucket
|
||||
func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error) {
|
||||
bucketPath := fmt.Sprintf("/buckets/%s", bucketName)
|
||||
// Get filer configuration (buckets path)
|
||||
filerConfig, err := s.getFilerConfig()
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to get filer configuration, using defaults: %v", err)
|
||||
}
|
||||
|
||||
bucketPath := fmt.Sprintf("%s/%s", filerConfig.BucketsPath, bucketName)
|
||||
|
||||
details := &BucketDetails{
|
||||
Bucket: S3Bucket{
|
||||
@@ -370,10 +404,10 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Get bucket info
|
||||
bucketResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Directory: filerConfig.BucketsPath,
|
||||
Name: bucketName,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -394,11 +428,12 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
||||
details.Bucket.Quota = quota
|
||||
details.Bucket.QuotaEnabled = quotaEnabled
|
||||
|
||||
// Get versioning and object lock information from extended attributes
|
||||
// Get versioning, object lock, and owner information from extended attributes
|
||||
versioningEnabled := false
|
||||
objectLockEnabled := false
|
||||
objectLockMode := ""
|
||||
var objectLockDuration int32 = 0
|
||||
var owner string
|
||||
|
||||
if bucketResp.Entry.Extended != nil {
|
||||
// Use shared utility to extract versioning information
|
||||
@@ -406,15 +441,21 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
||||
|
||||
// Use shared utility to extract Object Lock information
|
||||
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry)
|
||||
|
||||
// Extract owner information
|
||||
if ownerBytes, ok := bucketResp.Entry.Extended[s3_constants.AmzIdentityId]; ok {
|
||||
owner = string(ownerBytes)
|
||||
}
|
||||
}
|
||||
|
||||
details.Bucket.VersioningEnabled = versioningEnabled
|
||||
details.Bucket.ObjectLockEnabled = objectLockEnabled
|
||||
details.Bucket.ObjectLockMode = objectLockMode
|
||||
details.Bucket.ObjectLockDuration = objectLockDuration
|
||||
details.Bucket.Owner = owner
|
||||
|
||||
// List objects in bucket (recursively)
|
||||
return s.listBucketObjects(client, bucketPath, "", details)
|
||||
return s.listBucketObjects(client, bucketPath, bucketPath, "", details)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -425,7 +466,8 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
||||
}
|
||||
|
||||
// listBucketObjects recursively lists all objects in a bucket
|
||||
func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, directory, prefix string, details *BucketDetails) error {
|
||||
// bucketBasePath is the full path to the bucket (e.g., /buckets/mybucket)
|
||||
func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, bucketBasePath, directory, prefix string, details *BucketDetails) error {
|
||||
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
|
||||
Directory: directory,
|
||||
Prefix: prefix,
|
||||
@@ -450,16 +492,16 @@ func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, dire
|
||||
if entry.IsDirectory {
|
||||
// Recursively list subdirectories
|
||||
subDir := fmt.Sprintf("%s/%s", directory, entry.Name)
|
||||
err := s.listBucketObjects(client, subDir, "", details)
|
||||
err := s.listBucketObjects(client, bucketBasePath, subDir, "", details)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Add file object
|
||||
objectKey := entry.Name
|
||||
if directory != fmt.Sprintf("/buckets/%s", details.Bucket.Name) {
|
||||
if directory != bucketBasePath {
|
||||
// Remove bucket prefix to get relative path
|
||||
relativePath := directory[len(fmt.Sprintf("/buckets/%s", details.Bucket.Name))+1:]
|
||||
relativePath := directory[len(bucketBasePath)+1:]
|
||||
objectKey = fmt.Sprintf("%s/%s", relativePath, entry.Name)
|
||||
}
|
||||
|
||||
@@ -491,14 +533,45 @@ func (s *AdminServer) CreateS3Bucket(bucketName string) error {
|
||||
|
||||
// DeleteS3Bucket deletes an S3 bucket and all its contents
|
||||
func (s *AdminServer) DeleteS3Bucket(bucketName string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get filer configuration (buckets path and filer group)
|
||||
filerConfig, err := s.getFilerConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get filer configuration: %w", err)
|
||||
}
|
||||
|
||||
// Check if bucket has Object Lock enabled and if there are locked objects
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
return s3api.CheckBucketForLockedObjects(ctx, client, filerConfig.BucketsPath, bucketName)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the collection first (same as s3.bucket.delete shell command)
|
||||
// This ensures volume data is cleaned up properly
|
||||
// Collection name must be prefixed with filer group if configured
|
||||
collectionName := getCollectionName(filerConfig.FilerGroup, bucketName)
|
||||
err = s.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
_, err := client.CollectionDelete(ctx, &master_pb.CollectionDeleteRequest{
|
||||
Name: collectionName,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection %s: %w", collectionName, err)
|
||||
}
|
||||
|
||||
// Then delete bucket directory recursively from filer
|
||||
// Use same parameters as s3.bucket.delete shell command and S3 API
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Delete bucket directory recursively
|
||||
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
|
||||
Directory: "/buckets",
|
||||
_, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: filerConfig.BucketsPath,
|
||||
Name: bucketName,
|
||||
IsDeleteData: true,
|
||||
IsDeleteData: false, // Collection already deleted, just remove metadata
|
||||
IsRecursive: true,
|
||||
IgnoreRecursiveError: false,
|
||||
IgnoreRecursiveError: true, // Same as S3 API and shell command
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket: %w", err)
|
||||
|
||||
@@ -11,8 +11,14 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// MaxOwnerNameLength is the maximum allowed length for bucket owner identity names.
|
||||
// This is a reasonable limit to prevent abuse; AWS IAM user names are limited to 64 chars,
|
||||
// but we use 256 to allow for more complex identity formats (e.g., email addresses).
|
||||
const MaxOwnerNameLength = 256
|
||||
|
||||
// S3 Bucket management data structures for templates
|
||||
type S3BucketsData struct {
|
||||
Username string `json:"username"`
|
||||
@@ -33,6 +39,7 @@ type CreateBucketRequest struct {
|
||||
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
|
||||
SetDefaultRetention bool `json:"set_default_retention"` // Whether to set default retention
|
||||
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
|
||||
Owner string `json:"owner"` // Bucket owner identity (for S3 IAM authentication)
|
||||
}
|
||||
|
||||
// S3 Bucket Management Handlers
|
||||
@@ -118,7 +125,14 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
|
||||
// Convert quota to bytes
|
||||
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
|
||||
|
||||
err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration)
|
||||
// Sanitize owner: trim whitespace and enforce max length
|
||||
owner := strings.TrimSpace(req.Owner)
|
||||
if len(owner) > MaxOwnerNameLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
|
||||
return
|
||||
@@ -134,6 +148,7 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
|
||||
"object_lock_enabled": req.ObjectLockEnabled,
|
||||
"object_lock_mode": req.ObjectLockMode,
|
||||
"object_lock_duration": req.ObjectLockDuration,
|
||||
"owner": owner,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,6 +208,88 @@ func (s *AdminServer) DeleteBucket(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBucketOwner updates the owner of an S3 bucket
|
||||
func (s *AdminServer) UpdateBucketOwner(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Use pointer to detect if owner field was explicitly provided
|
||||
var req struct {
|
||||
Owner *string `json:"owner"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Require owner field to be explicitly provided
|
||||
if req.Owner == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Owner field is required (use empty string to clear owner)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Trim and validate owner
|
||||
owner := strings.TrimSpace(*req.Owner)
|
||||
if len(owner) > MaxOwnerNameLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.SetBucketOwner(bucketName, owner)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket owner: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bucket owner updated successfully",
|
||||
"bucket": bucketName,
|
||||
"owner": owner,
|
||||
})
|
||||
}
|
||||
|
||||
// SetBucketOwner sets the owner of a bucket
|
||||
func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error {
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Get the current bucket entry
|
||||
lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Name: bucketName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
bucketEntry := lookupResp.Entry
|
||||
|
||||
// Initialize Extended map if nil
|
||||
if bucketEntry.Extended == nil {
|
||||
bucketEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// Set or remove the owner
|
||||
if owner == "" {
|
||||
delete(bucketEntry.Extended, s3_constants.AmzIdentityId)
|
||||
} else {
|
||||
bucketEntry.Extended[s3_constants.AmzIdentityId] = []byte(owner)
|
||||
}
|
||||
|
||||
// Update the entry
|
||||
_, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Entry: bucketEntry,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update bucket owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListBucketsAPI returns the list of buckets as JSON
|
||||
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
|
||||
buckets, err := s.GetS3Buckets()
|
||||
@@ -288,11 +385,11 @@ func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaE
|
||||
|
||||
// CreateS3BucketWithQuota creates a new S3 bucket with quota settings
|
||||
func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
|
||||
return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0)
|
||||
return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0, "")
|
||||
}
|
||||
|
||||
// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, and object lock settings
|
||||
func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32) error {
|
||||
// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, object lock settings, and owner
|
||||
func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32, owner string) error {
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// First ensure /buckets directory exists
|
||||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
@@ -344,9 +441,14 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes
|
||||
TtlSec: 0,
|
||||
}
|
||||
|
||||
// Create extended attributes map for versioning
|
||||
// Create extended attributes map for versioning and owner
|
||||
extended := make(map[string][]byte)
|
||||
|
||||
// Set bucket owner if specified
|
||||
if owner != "" {
|
||||
extended[s3_constants.AmzIdentityId] = []byte(owner)
|
||||
}
|
||||
|
||||
// Create bucket entry
|
||||
bucketEntry := &filer_pb.Entry{
|
||||
Name: bucketName,
|
||||
|
||||
@@ -82,6 +82,7 @@ type S3Bucket struct {
|
||||
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
|
||||
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
|
||||
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
|
||||
Owner string `json:"owner,omitempty"` // Bucket owner identity; empty means admin-only access
|
||||
}
|
||||
|
||||
type S3Object struct {
|
||||
|
||||
@@ -119,6 +119,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
|
||||
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
||||
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
|
||||
s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
|
||||
}
|
||||
|
||||
// User management API routes
|
||||
@@ -245,6 +246,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
|
||||
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
||||
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
|
||||
s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
|
||||
}
|
||||
|
||||
// User management API routes
|
||||
|
||||
@@ -113,6 +113,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Objects</th>
|
||||
<th>Size</th>
|
||||
@@ -132,6 +133,15 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
{bucket.Name}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
if bucket.Owner != "" {
|
||||
<span class="badge bg-info">
|
||||
<i class="fas fa-user me-1"></i>{bucket.Owner}
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-muted small">No owner</span>
|
||||
}
|
||||
</td>
|
||||
<td>{bucket.CreatedAt.Format("2006-01-02 15:04")}</td>
|
||||
<td>{fmt.Sprintf("%d", bucket.ObjectCount)}</td>
|
||||
<td>{formatBytes(bucket.Size)}</td>
|
||||
@@ -193,6 +203,13 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-info btn-sm owner-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
data-current-owner={bucket.Owner}
|
||||
title="Manage Owner">
|
||||
<i class="fas fa-user-edit"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-warning btn-sm quota-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
@@ -213,7 +230,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
}
|
||||
if len(data.Buckets) == 0 {
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<td colspan="9" class="text-center text-muted py-4">
|
||||
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
|
||||
<div>
|
||||
<h5>No Object Store buckets found</h5>
|
||||
@@ -268,6 +285,17 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="bucketOwner" class="form-label">Owner (Optional)</label>
|
||||
<select class="form-select" id="bucketOwner" name="owner">
|
||||
<option value="">No owner (admin-only access)</option>
|
||||
<!-- Options will be populated dynamically when modal opens -->
|
||||
</select>
|
||||
<div class="form-text">
|
||||
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
@@ -481,9 +509,68 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Owner Modal -->
|
||||
<div class="modal fade" id="manageOwnerModal" tabindex="-1" aria-labelledby="manageOwnerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="manageOwnerModalLabel">
|
||||
<i class="fas fa-user-edit me-2"></i>Manage Bucket Owner
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="ownerForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bucket Name</label>
|
||||
<input type="text" class="form-control" id="ownerBucketName" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="bucketOwnerSelect" class="form-label">Owner</label>
|
||||
<select class="form-select" id="bucketOwnerSelect" name="owner">
|
||||
<option value="">No owner (admin-only access)</option>
|
||||
<!-- Options will be populated dynamically -->
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ownerLoadingSpinner" class="text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading users...</span>
|
||||
</div>
|
||||
<span class="ms-2">Loading users...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-info">
|
||||
<i class="fas fa-save me-1"></i>Update Owner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for bucket management -->
|
||||
<script>
|
||||
// Global state (shared between DOMContentLoaded handlers and global functions)
|
||||
let deleteModalInstance = null;
|
||||
let quotaModalInstance = null;
|
||||
let ownerModalInstance = null;
|
||||
let detailsModalInstance = null;
|
||||
let cachedUsers = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize modal instances once (reuse with show/hide)
|
||||
deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
|
||||
quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
|
||||
ownerModalInstance = new bootstrap.Modal(document.getElementById('manageOwnerModal'));
|
||||
detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
|
||||
|
||||
const quotaCheckbox = document.getElementById('enableQuota');
|
||||
const quotaSettings = document.getElementById('quotaSettings');
|
||||
const versioningCheckbox = document.getElementById('enableVersioning');
|
||||
@@ -517,6 +604,32 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
defaultRetentionSettings.style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Populate owner dropdown when create bucket modal opens
|
||||
document.getElementById('createBucketModal').addEventListener('show.bs.modal', async function() {
|
||||
const ownerSelect = document.getElementById('bucketOwner');
|
||||
|
||||
// Only fetch if not already populated
|
||||
if (ownerSelect.options.length <= 1) {
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
const data = await response.json();
|
||||
const users = data.users || [];
|
||||
|
||||
users.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.username;
|
||||
option.textContent = user.username;
|
||||
ownerSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching users for owner dropdown:', error);
|
||||
// Reset to default state on error - user can still create bucket without owner
|
||||
ownerSelect.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
||||
ownerSelect.selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
createBucketForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -524,6 +637,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
const formData = new FormData(this);
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
owner: formData.get('owner') || '',
|
||||
region: formData.get('region') || '',
|
||||
quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,
|
||||
quota_unit: formData.get('quota_unit') || 'MB',
|
||||
@@ -569,41 +683,19 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
});
|
||||
|
||||
// Handle delete bucket
|
||||
let deleteModalInstance = null;
|
||||
const deleteForm = document.getElementById('deleteBucketModal');
|
||||
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
document.getElementById('deleteBucketName').textContent = bucketName;
|
||||
window.currentBucketToDelete = bucketName;
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (deleteModalInstance) {
|
||||
deleteModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
|
||||
// Store bucket name on modal element instead of global window property
|
||||
deleteForm.dataset.bucketName = bucketName;
|
||||
deleteModalInstance.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener to properly dispose of delete modal when hidden
|
||||
document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (deleteModalInstance) {
|
||||
deleteModalInstance.dispose();
|
||||
deleteModalInstance = null;
|
||||
}
|
||||
// Force remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Ensure body classes are removed
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
});
|
||||
|
||||
// Handle quota management
|
||||
let quotaModalInstance = null;
|
||||
const quotaForm = document.getElementById('quotaForm');
|
||||
document.querySelectorAll('.quota-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
@@ -617,15 +709,8 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
// Toggle quota size settings
|
||||
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
|
||||
|
||||
window.currentBucketToUpdate = bucketName;
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (quotaModalInstance) {
|
||||
quotaModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
|
||||
// Store bucket name on form element instead of global window property
|
||||
quotaForm.dataset.bucketName = bucketName;
|
||||
quotaModalInstance.show();
|
||||
});
|
||||
});
|
||||
@@ -649,6 +734,9 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
document.getElementById('quotaForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const bucketName = this.dataset.bucketName;
|
||||
if (!bucketName) return;
|
||||
|
||||
const formData = new FormData(this);
|
||||
const enabled = document.getElementById('quotaEnabled').checked;
|
||||
const data = {
|
||||
@@ -657,7 +745,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
quota_enabled: enabled
|
||||
};
|
||||
|
||||
fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {
|
||||
fetch(`/api/s3/buckets/${bucketName}/quota`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -688,8 +776,97 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Handle owner management
|
||||
const ownerForm = document.getElementById('ownerForm');
|
||||
document.querySelectorAll('.owner-btn').forEach(button => {
|
||||
button.addEventListener('click', async function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
const currentOwner = this.dataset.currentOwner || '';
|
||||
|
||||
document.getElementById('ownerBucketName').value = bucketName;
|
||||
// Store bucket name on form element instead of global window property
|
||||
ownerForm.dataset.bucketName = bucketName;
|
||||
|
||||
// Show loading spinner
|
||||
document.getElementById('ownerLoadingSpinner').style.display = 'block';
|
||||
document.getElementById('bucketOwnerSelect').disabled = true;
|
||||
|
||||
ownerModalInstance.show();
|
||||
|
||||
// Fetch users if not cached
|
||||
try {
|
||||
if (!cachedUsers) {
|
||||
const response = await fetch('/api/users');
|
||||
const data = await response.json();
|
||||
cachedUsers = data.users || [];
|
||||
}
|
||||
|
||||
// Populate the select dropdown
|
||||
const select = document.getElementById('bucketOwnerSelect');
|
||||
select.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
||||
|
||||
cachedUsers.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.username;
|
||||
option.textContent = user.username;
|
||||
if (user.username === currentOwner) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.disabled = false;
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
alert('Error loading users: ' + error.message);
|
||||
// Re-enable select and reset to default on error
|
||||
const select = document.getElementById('bucketOwnerSelect');
|
||||
select.innerHTML = '<option value="">No owner (admin-only access)</option>';
|
||||
select.selectedIndex = 0;
|
||||
select.disabled = false;
|
||||
} finally {
|
||||
document.getElementById('ownerLoadingSpinner').style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle owner form submission
|
||||
document.getElementById('ownerForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const bucketName = this.dataset.bucketName;
|
||||
if (!bucketName) return;
|
||||
|
||||
const owner = document.getElementById('bucketOwnerSelect').value;
|
||||
const data = { owner: owner };
|
||||
|
||||
fetch(`/api/s3/buckets/${bucketName}/owner`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
alert('Error updating owner: ' + data.error);
|
||||
} else {
|
||||
alert('Bucket owner updated successfully!');
|
||||
// Properly close the modal before reloading
|
||||
if (ownerModalInstance) {
|
||||
ownerModalInstance.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating owner: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle view details button
|
||||
let detailsModalInstance = null;
|
||||
document.querySelectorAll('.view-details-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
@@ -707,13 +884,6 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
'<div class="mt-2">Loading bucket details...</div>' +
|
||||
'</div>';
|
||||
|
||||
// Dispose of existing modal instance if it exists
|
||||
if (detailsModalInstance) {
|
||||
detailsModalInstance.dispose();
|
||||
}
|
||||
|
||||
// Create new modal instance
|
||||
detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
|
||||
detailsModalInstance.show();
|
||||
|
||||
// Fetch bucket details
|
||||
@@ -740,25 +910,10 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener to properly dispose of details modal when hidden
|
||||
document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (detailsModalInstance) {
|
||||
detailsModalInstance.dispose();
|
||||
detailsModalInstance = null;
|
||||
}
|
||||
// Force remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Ensure body classes are removed
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
});
|
||||
});
|
||||
|
||||
function deleteBucket() {
|
||||
const bucketName = window.currentBucketToDelete;
|
||||
const bucketName = document.getElementById('deleteBucketModal').dataset.bucketName;
|
||||
if (!bucketName) return;
|
||||
|
||||
fetch(`/api/s3/buckets/${bucketName}`, {
|
||||
@@ -786,6 +941,16 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
function displayBucketDetails(data) {
|
||||
const bucket = data.bucket;
|
||||
const objects = data.objects || [];
|
||||
|
||||
// Helper function to escape HTML to prevent XSS
|
||||
function escapeHtml(v) {
|
||||
return String(v ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes) {
|
||||
@@ -818,10 +983,10 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
'<tbody>' +
|
||||
objects.map(obj =>
|
||||
'<tr>' +
|
||||
'<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' +
|
||||
'<td><i class="fas fa-file me-1"></i>' + escapeHtml(obj.key) + '</td>' +
|
||||
'<td>' + formatBytes(obj.size) + '</td>' +
|
||||
'<td>' + formatDate(obj.last_modified) + '</td>' +
|
||||
'<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' +
|
||||
'<td><span class="badge bg-primary">' + escapeHtml(obj.storage_class) + '</span></td>' +
|
||||
'</tr>'
|
||||
).join('') +
|
||||
'</tbody>' +
|
||||
@@ -840,7 +1005,11 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
'<table class="table table-sm">' +
|
||||
'<tr>' +
|
||||
'<td><strong>Name:</strong></td>' +
|
||||
'<td>' + bucket.name + '</td>' +
|
||||
'<td>' + escapeHtml(bucket.name) + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Owner:</strong></td>' +
|
||||
'<td>' + (bucket.owner ? '<span class="badge bg-info"><i class="fas fa-user me-1"></i>' + escapeHtml(bucket.owner) + '</span>' : '<span class="text-muted">No owner (admin-only)</span>') + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
'<td><strong>Created:</strong></td>' +
|
||||
@@ -886,7 +1055,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
'<td>' +
|
||||
(bucket.object_lock_enabled ?
|
||||
'<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
|
||||
'<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' :
|
||||
'<br><small class="text-muted">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days</small>' :
|
||||
'<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
|
||||
) +
|
||||
'</td>' +
|
||||
@@ -906,26 +1075,45 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
}
|
||||
|
||||
function exportBucketList() {
|
||||
// Simple CSV export
|
||||
// RFC 4180 compliant CSV escaping: escape double quotes by doubling them
|
||||
function escapeCsvField(value) {
|
||||
const str = String(value ?? '');
|
||||
// If the field contains comma, double quote, or newline, wrap in quotes and escape internal quotes
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return '"' + str + '"';
|
||||
}
|
||||
|
||||
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
return {
|
||||
name: cells[0].textContent.trim(),
|
||||
created: cells[1].textContent.trim(),
|
||||
objects: cells[2].textContent.trim(),
|
||||
size: cells[3].textContent.trim(),
|
||||
quota: cells[4].textContent.trim(),
|
||||
versioning: cells[5].textContent.trim(),
|
||||
objectLock: cells[6].textContent.trim()
|
||||
owner: cells[1].textContent.trim(),
|
||||
created: cells[2].textContent.trim(),
|
||||
objects: cells[3].textContent.trim(),
|
||||
size: cells[4].textContent.trim(),
|
||||
quota: cells[5].textContent.trim(),
|
||||
versioning: cells[6].textContent.trim(),
|
||||
objectLock: cells[7].textContent.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(bucket => bucket !== null);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
"Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
|
||||
buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n");
|
||||
"Name,Owner,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
|
||||
buckets.map(b => [
|
||||
escapeCsvField(b.name),
|
||||
escapeCsvField(b.owner),
|
||||
escapeCsvField(b.created),
|
||||
escapeCsvField(b.objects),
|
||||
escapeCsvField(b.size),
|
||||
escapeCsvField(b.quota),
|
||||
escapeCsvField(b.versioning),
|
||||
escapeCsvField(b.objectLock)
|
||||
].join(',')).join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
|
||||
File diff suppressed because one or more lines are too long
+39
-35
@@ -42,39 +42,40 @@ var (
|
||||
)
|
||||
|
||||
type FilerOptions struct {
|
||||
masters *pb.ServerDiscovery
|
||||
mastersString *string
|
||||
ip *string
|
||||
bindIp *string
|
||||
port *int
|
||||
portGrpc *int
|
||||
publicPort *int
|
||||
filerGroup *string
|
||||
collection *string
|
||||
defaultReplicaPlacement *string
|
||||
disableDirListing *bool
|
||||
maxMB *int
|
||||
dirListingLimit *int
|
||||
dataCenter *string
|
||||
rack *string
|
||||
enableNotification *bool
|
||||
disableHttp *bool
|
||||
cipher *bool
|
||||
metricsHttpPort *int
|
||||
metricsHttpIp *string
|
||||
saveToFilerLimit *int
|
||||
defaultLevelDbDirectory *string
|
||||
concurrentUploadLimitMB *int
|
||||
concurrentFileUploadLimit *int
|
||||
debug *bool
|
||||
debugPort *int
|
||||
localSocket *string
|
||||
showUIDirectoryDelete *bool
|
||||
downloadMaxMBps *int
|
||||
diskType *string
|
||||
allowedOrigins *string
|
||||
exposeDirectoryData *bool
|
||||
certProvider certprovider.Provider
|
||||
masters *pb.ServerDiscovery
|
||||
mastersString *string
|
||||
ip *string
|
||||
bindIp *string
|
||||
port *int
|
||||
portGrpc *int
|
||||
publicPort *int
|
||||
filerGroup *string
|
||||
collection *string
|
||||
defaultReplicaPlacement *string
|
||||
disableDirListing *bool
|
||||
maxMB *int
|
||||
dirListingLimit *int
|
||||
dataCenter *string
|
||||
rack *string
|
||||
enableNotification *bool
|
||||
disableHttp *bool
|
||||
cipher *bool
|
||||
metricsHttpPort *int
|
||||
metricsHttpIp *string
|
||||
saveToFilerLimit *int
|
||||
defaultLevelDbDirectory *string
|
||||
concurrentUploadLimitMB *int
|
||||
concurrentFileUploadLimit *int
|
||||
debug *bool
|
||||
debugPort *int
|
||||
localSocket *string
|
||||
showUIDirectoryDelete *bool
|
||||
downloadMaxMBps *int
|
||||
diskType *string
|
||||
allowedOrigins *string
|
||||
exposeDirectoryData *bool
|
||||
tusBasePath *string
|
||||
certProvider certprovider.Provider
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -99,7 +100,7 @@ func init() {
|
||||
f.metricsHttpIp = cmdFiler.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
f.saveToFilerLimit = cmdFiler.Flag.Int("saveToFilerLimit", 0, "files smaller than this limit will be saved in filer store")
|
||||
f.defaultLevelDbDirectory = cmdFiler.Flag.String("defaultStoreDir", ".", "if filer.toml is empty, use an embedded filer store in the directory")
|
||||
f.concurrentUploadLimitMB = cmdFiler.Flag.Int("concurrentUploadLimitMB", 128, "limit total concurrent upload size")
|
||||
f.concurrentUploadLimitMB = cmdFiler.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
f.concurrentFileUploadLimit = cmdFiler.Flag.Int("concurrentFileUploadLimit", 0, "limit number of concurrent file uploads, 0 means unlimited")
|
||||
f.debug = cmdFiler.Flag.Bool("debug", false, "serves runtime profiling data, e.g., http://localhost:<debug.port>/debug/pprof/goroutine?debug=2")
|
||||
f.debugPort = cmdFiler.Flag.Int("debug.port", 6060, "http port for debugging")
|
||||
@@ -109,6 +110,7 @@ func init() {
|
||||
f.diskType = cmdFiler.Flag.String("disk", "", "[hdd|ssd|<tag>] hard drive or solid state drive or any tag")
|
||||
f.allowedOrigins = cmdFiler.Flag.String("allowedOrigins", "*", "comma separated list of allowed origins")
|
||||
f.exposeDirectoryData = cmdFiler.Flag.Bool("exposeDirectoryData", true, "whether to return directory metadata and content in Filer UI")
|
||||
f.tusBasePath = cmdFiler.Flag.String("tusBasePath", "/.tus", "TUS resumable upload endpoint base path (e.g., /.tus)")
|
||||
|
||||
// start s3 on filer
|
||||
filerStartS3 = cmdFiler.Flag.Bool("s3", false, "whether to start S3 gateway")
|
||||
@@ -132,8 +134,9 @@ func init() {
|
||||
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", 120, "connection idle seconds")
|
||||
filerS3Options.concurrentUploadLimitMB = cmdFiler.Flag.Int("s3.concurrentUploadLimitMB", 128, "limit total concurrent upload size for S3")
|
||||
filerS3Options.concurrentUploadLimitMB = cmdFiler.Flag.Int("s3.concurrentUploadLimitMB", 0, "limit total concurrent upload size for S3, 0 means unlimited")
|
||||
filerS3Options.concurrentFileUploadLimit = cmdFiler.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
|
||||
filerS3Options.enableIam = cmdFiler.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port")
|
||||
|
||||
// start webdav on filer
|
||||
filerStartWebDav = cmdFiler.Flag.Bool("webdav", false, "whether to start webdav gateway")
|
||||
@@ -341,6 +344,7 @@ func (fo *FilerOptions) startFiler() {
|
||||
DownloadMaxBytesPs: int64(*fo.downloadMaxMBps) * 1024 * 1024,
|
||||
DiskType: *fo.diskType,
|
||||
AllowedOrigins: strings.Split(*fo.allowedOrigins, ","),
|
||||
TusBasePath: *fo.tusBasePath,
|
||||
})
|
||||
if nfs_err != nil {
|
||||
glog.Fatalf("Filer startup error: %v", nfs_err)
|
||||
|
||||
@@ -60,10 +60,22 @@ const (
|
||||
DefaultConcurrencyLimit = 32
|
||||
)
|
||||
|
||||
// syncState tracks the current sync state for graceful shutdown checkpoint saving
|
||||
type syncState struct {
|
||||
processor *MetadataProcessor
|
||||
grpcDialOption grpc.DialOption
|
||||
targetFiler pb.ServerAddress
|
||||
sourcePath string
|
||||
sourceFilerSignature int32
|
||||
}
|
||||
|
||||
var (
|
||||
syncOptions SyncOptions
|
||||
syncCpuProfile *string
|
||||
syncMemProfile *string
|
||||
// atomic pointers to current sync states for graceful shutdown
|
||||
syncStateA2B atomic.Pointer[syncState]
|
||||
syncStateB2A atomic.Pointer[syncState]
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -143,6 +155,27 @@ func runFilerSynchronize(cmd *Command, args []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// register graceful shutdown hook to save checkpoints
|
||||
grace.OnInterrupt(func() {
|
||||
saveCheckpoint := func(name string, state *syncState) {
|
||||
if state == nil || state.processor == nil {
|
||||
return
|
||||
}
|
||||
offsetTsNs := state.processor.processedTsWatermark.Load()
|
||||
if offsetTsNs == 0 {
|
||||
return
|
||||
}
|
||||
if err := setOffset(state.grpcDialOption, state.targetFiler, getSignaturePrefixByPath(state.sourcePath), state.sourceFilerSignature, offsetTsNs); err != nil {
|
||||
glog.Errorf("failed to save checkpoint for %s on shutdown: %v", name, err)
|
||||
} else {
|
||||
glog.V(0).Infof("saved checkpoint for %s on shutdown: %v", name, time.Unix(0, offsetTsNs))
|
||||
}
|
||||
}
|
||||
|
||||
saveCheckpoint("A->B", syncStateA2B.Load())
|
||||
saveCheckpoint("B->A", syncStateB2A.Load())
|
||||
})
|
||||
|
||||
go func() {
|
||||
// a->b
|
||||
// set synchronization start timestamp to offset
|
||||
@@ -172,7 +205,8 @@ func runFilerSynchronize(cmd *Command, args []string) bool {
|
||||
*syncOptions.concurrency,
|
||||
*syncOptions.bDoDeleteFiles,
|
||||
aFilerSignature,
|
||||
bFilerSignature)
|
||||
bFilerSignature,
|
||||
&syncStateA2B)
|
||||
if err != nil {
|
||||
glog.Errorf("sync from %s to %s: %v", *syncOptions.filerA, *syncOptions.filerB, err)
|
||||
time.Sleep(1747 * time.Millisecond)
|
||||
@@ -210,7 +244,8 @@ func runFilerSynchronize(cmd *Command, args []string) bool {
|
||||
*syncOptions.concurrency,
|
||||
*syncOptions.aDoDeleteFiles,
|
||||
bFilerSignature,
|
||||
aFilerSignature)
|
||||
aFilerSignature,
|
||||
&syncStateB2A)
|
||||
if err != nil {
|
||||
glog.Errorf("sync from %s to %s: %v", *syncOptions.filerB, *syncOptions.filerA, err)
|
||||
time.Sleep(2147 * time.Millisecond)
|
||||
@@ -241,7 +276,7 @@ func initOffsetFromTsMs(grpcDialOption grpc.DialOption, targetFiler pb.ServerAdd
|
||||
}
|
||||
|
||||
func doSubscribeFilerMetaChanges(clientId int32, clientEpoch int32, grpcDialOption grpc.DialOption, sourceFiler pb.ServerAddress, sourcePath string, sourceExcludePaths []string, sourceReadChunkFromFiler bool, targetFiler pb.ServerAddress, targetPath string,
|
||||
replicationStr, collection string, ttlSec int, sinkWriteChunkByFiler bool, diskType string, debug bool, concurrency int, doDeleteFiles bool, sourceFilerSignature int32, targetFilerSignature int32) error {
|
||||
replicationStr, collection string, ttlSec int, sinkWriteChunkByFiler bool, diskType string, debug bool, concurrency int, doDeleteFiles bool, sourceFilerSignature int32, targetFilerSignature int32, statePtr *atomic.Pointer[syncState]) error {
|
||||
|
||||
// if first time, start from now
|
||||
// if has previously synced, resume from that point of time
|
||||
@@ -278,6 +313,17 @@ func doSubscribeFilerMetaChanges(clientId int32, clientEpoch int32, grpcDialOpti
|
||||
}
|
||||
processor := NewMetadataProcessor(processEventFn, concurrency, sourceFilerOffsetTsNs)
|
||||
|
||||
// update sync state for graceful shutdown checkpoint saving
|
||||
if statePtr != nil {
|
||||
statePtr.Store(&syncState{
|
||||
processor: processor,
|
||||
grpcDialOption: grpcDialOption,
|
||||
targetFiler: targetFiler,
|
||||
sourcePath: sourcePath,
|
||||
sourceFilerSignature: sourceFilerSignature,
|
||||
})
|
||||
}
|
||||
|
||||
var lastLogTsNs = time.Now().UnixNano()
|
||||
var clientName = fmt.Sprintf("syncFrom_%s_To_%s", string(sourceFiler), string(targetFiler))
|
||||
processEventFnWithOffset := pb.AddOffsetFunc(func(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
|
||||
+24
-3
@@ -44,13 +44,34 @@ func init() {
|
||||
|
||||
var cmdIam = &Command{
|
||||
UsageLine: "iam [-port=8111] [-filer=<ip:port>[,<ip:port>]...] [-master=<ip:port>,<ip:port>]",
|
||||
Short: "start a iam API compatible server",
|
||||
Long: `start a iam API compatible server.
|
||||
Short: "[DEPRECATED] start a standalone iam API compatible server",
|
||||
Long: `[DEPRECATED] start a standalone iam API compatible server.
|
||||
|
||||
DEPRECATION NOTICE:
|
||||
The standalone 'weed iam' command is deprecated and will be removed in a future release.
|
||||
|
||||
The IAM API is now embedded in the S3 server by default. Simply use 'weed s3' instead,
|
||||
which provides both S3 and IAM APIs on the same port (enabled by default with -iam=true).
|
||||
|
||||
This simplifies deployment by running a single server instead of two separate servers,
|
||||
following the pattern used by MinIO and Ceph RGW.
|
||||
|
||||
To use the embedded IAM API:
|
||||
weed s3 -port=8333 # IAM API is available on the same port
|
||||
|
||||
To disable the embedded IAM API (if you prefer the old behavior):
|
||||
weed s3 -iam=false # Run S3 without IAM
|
||||
weed iam -port=8111 # Run IAM separately (deprecated)
|
||||
|
||||
Multiple filer addresses can be specified for high availability, separated by commas.`,
|
||||
}
|
||||
|
||||
func runIam(cmd *Command, args []string) bool {
|
||||
glog.Warningf("================================================================================")
|
||||
glog.Warningf("DEPRECATION WARNING: 'weed iam' is deprecated and will be removed in a future release.")
|
||||
glog.Warningf("The IAM API is now embedded in 'weed s3' by default (use -iam=true, which is the default).")
|
||||
glog.Warningf("Please migrate to using 'weed s3' which provides both S3 and IAM APIs on the same port.")
|
||||
glog.Warningf("================================================================================")
|
||||
return iamStandaloneOptions.startIamServer()
|
||||
}
|
||||
|
||||
@@ -89,7 +110,7 @@ func (iamopt *IamOptions) startIamServer() bool {
|
||||
if iamApiServer_err != nil {
|
||||
glog.Fatalf("IAM API Server startup error: %v", iamApiServer_err)
|
||||
}
|
||||
|
||||
|
||||
// Register shutdown handler to prevent goroutine leak
|
||||
grace.OnInterrupt(func() {
|
||||
iamApiServer.Shutdown()
|
||||
|
||||
@@ -37,6 +37,9 @@ type MountOptions struct {
|
||||
extraOptions []string
|
||||
fuseCommandPid int
|
||||
|
||||
// Periodic metadata flush to protect against orphan chunk cleanup
|
||||
metadataFlushSeconds *int
|
||||
|
||||
// RDMA acceleration options
|
||||
rdmaEnabled *bool
|
||||
rdmaSidecarAddr *string
|
||||
@@ -85,6 +88,9 @@ func init() {
|
||||
mountOptions.disableXAttr = cmdMount.Flag.Bool("disableXAttr", false, "disable xattr")
|
||||
mountOptions.fuseCommandPid = 0
|
||||
|
||||
// Periodic metadata flush to protect against orphan chunk cleanup
|
||||
mountOptions.metadataFlushSeconds = cmdMount.Flag.Int("metadataFlushSeconds", 120, "periodically flush file metadata to filer in seconds (0 to disable). This protects chunks from being purged by volume.fsck for long-running writes")
|
||||
|
||||
// RDMA acceleration flags
|
||||
mountOptions.rdmaEnabled = cmdMount.Flag.Bool("rdma.enabled", false, "enable RDMA acceleration for reads")
|
||||
mountOptions.rdmaSidecarAddr = cmdMount.Flag.String("rdma.sidecar", "", "RDMA sidecar address (e.g., localhost:8081)")
|
||||
|
||||
@@ -254,6 +254,7 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
|
||||
UidGidMapper: uidGidMapper,
|
||||
DisableXAttr: *option.disableXAttr,
|
||||
IsMacOs: runtime.GOOS == "darwin",
|
||||
MetadataFlushSeconds: *option.metadataFlushSeconds,
|
||||
// RDMA acceleration options
|
||||
RdmaEnabled: *option.rdmaEnabled,
|
||||
RdmaSidecarAddr: *option.rdmaSidecarAddr,
|
||||
|
||||
+4
-1
@@ -58,6 +58,7 @@ type S3Options struct {
|
||||
idleTimeout *int
|
||||
concurrentUploadLimitMB *int
|
||||
concurrentFileUploadLimit *int
|
||||
enableIam *bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -84,8 +85,9 @@ func init() {
|
||||
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", 120, "connection idle seconds")
|
||||
s3StandaloneOptions.concurrentUploadLimitMB = cmdS3.Flag.Int("concurrentUploadLimitMB", 128, "limit total concurrent upload size")
|
||||
s3StandaloneOptions.concurrentUploadLimitMB = cmdS3.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
s3StandaloneOptions.concurrentFileUploadLimit = cmdS3.Flag.Int("concurrentFileUploadLimit", 0, "limit number of concurrent file uploads, 0 means unlimited")
|
||||
s3StandaloneOptions.enableIam = cmdS3.Flag.Bool("iam", true, "enable embedded IAM API on the same port")
|
||||
}
|
||||
|
||||
var cmdS3 = &Command{
|
||||
@@ -279,6 +281,7 @@ func (s3opt *S3Options) startS3Server() bool {
|
||||
IamConfig: iamConfigPath, // Advanced IAM config (optional)
|
||||
ConcurrentUploadLimit: int64(*s3opt.concurrentUploadLimitMB) * 1024 * 1024,
|
||||
ConcurrentFileUploadLimit: int64(*s3opt.concurrentFileUploadLimit),
|
||||
EnableIam: *s3opt.enableIam, // Embedded IAM API (enabled by default)
|
||||
})
|
||||
if s3ApiServer_err != nil {
|
||||
glog.Fatalf("S3 API Server startup error: %v", s3ApiServer_err)
|
||||
|
||||
@@ -399,6 +399,8 @@ enabled = false
|
||||
# If you have many pd address, use ',' split then:
|
||||
# pdaddrs = "pdhost1:2379, pdhost2:2379, pdhost3:2379"
|
||||
pdaddrs = "localhost:2379"
|
||||
# prefix for filer TiKV keys, useful for sharing a TiKV cluster with multiple seaweedfs clusters
|
||||
keyPrefix = ""
|
||||
# Enable 1PC
|
||||
enable_1pc = false
|
||||
# batch delete count, default 10000 in code
|
||||
|
||||
@@ -122,13 +122,14 @@ func init() {
|
||||
filerOptions.dirListingLimit = cmdServer.Flag.Int("filer.dirListLimit", 1000, "limit sub dir listing size")
|
||||
filerOptions.cipher = cmdServer.Flag.Bool("filer.encryptVolumeData", false, "encrypt data on volume servers")
|
||||
filerOptions.saveToFilerLimit = cmdServer.Flag.Int("filer.saveToFilerLimit", 0, "Small files smaller than this limit can be cached in filer store.")
|
||||
filerOptions.concurrentUploadLimitMB = cmdServer.Flag.Int("filer.concurrentUploadLimitMB", 64, "limit total concurrent upload size")
|
||||
filerOptions.concurrentUploadLimitMB = cmdServer.Flag.Int("filer.concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
filerOptions.concurrentFileUploadLimit = cmdServer.Flag.Int("filer.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads, 0 means unlimited")
|
||||
filerOptions.localSocket = cmdServer.Flag.String("filer.localSocket", "", "default to /tmp/seaweedfs-filer-<port>.sock")
|
||||
filerOptions.showUIDirectoryDelete = cmdServer.Flag.Bool("filer.ui.deleteDir", true, "enable filer UI show delete directory button")
|
||||
filerOptions.downloadMaxMBps = cmdServer.Flag.Int("filer.downloadMaxMBps", 0, "download max speed for each download request, in MB per second")
|
||||
filerOptions.diskType = cmdServer.Flag.String("filer.disk", "", "[hdd|ssd|<tag>] hard drive or solid state drive or any tag")
|
||||
filerOptions.exposeDirectoryData = cmdServer.Flag.Bool("filer.exposeDirectoryData", true, "expose directory data via filer. If false, filer UI will be innaccessible.")
|
||||
filerOptions.tusBasePath = cmdServer.Flag.String("filer.tusBasePath", "/.tus", "TUS resumable upload endpoint base path (e.g., /.tus)")
|
||||
|
||||
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")
|
||||
@@ -142,8 +143,8 @@ func init() {
|
||||
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")
|
||||
serverOptions.v.concurrentDownloadLimitMB = cmdServer.Flag.Int("volume.concurrentDownloadLimitMB", 64, "limit total concurrent download size")
|
||||
serverOptions.v.concurrentUploadLimitMB = cmdServer.Flag.Int("volume.concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
serverOptions.v.concurrentDownloadLimitMB = cmdServer.Flag.Int("volume.concurrentDownloadLimitMB", 0, "limit total concurrent download size, 0 means unlimited")
|
||||
serverOptions.v.publicUrl = cmdServer.Flag.String("volume.publicUrl", "", "publicly accessible address")
|
||||
serverOptions.v.preStopSeconds = cmdServer.Flag.Int("volume.preStopSeconds", 10, "number of seconds between stop send heartbeats and stop volume server")
|
||||
serverOptions.v.pprof = cmdServer.Flag.Bool("volume.pprof", false, "enable pprof http handlers. precludes -memprofile and -cpuprofile")
|
||||
@@ -171,8 +172,9 @@ func init() {
|
||||
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", 120, "connection idle seconds")
|
||||
s3Options.concurrentUploadLimitMB = cmdServer.Flag.Int("s3.concurrentUploadLimitMB", 128, "limit total concurrent upload size for S3")
|
||||
s3Options.concurrentUploadLimitMB = cmdServer.Flag.Int("s3.concurrentUploadLimitMB", 0, "limit total concurrent upload size for S3, 0 means unlimited")
|
||||
s3Options.concurrentFileUploadLimit = cmdServer.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
|
||||
s3Options.enableIam = cmdServer.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port")
|
||||
|
||||
sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port")
|
||||
sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||
|
||||
@@ -100,8 +100,8 @@ func init() {
|
||||
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")
|
||||
v.concurrentDownloadLimitMB = cmdVolume.Flag.Int("concurrentDownloadLimitMB", 256, "limit total concurrent download size")
|
||||
v.concurrentUploadLimitMB = cmdVolume.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
v.concurrentDownloadLimitMB = cmdVolume.Flag.Int("concurrentDownloadLimitMB", 0, "limit total concurrent download size, 0 means unlimited")
|
||||
v.pprof = cmdVolume.Flag.Bool("pprof", false, "enable pprof http handlers. precludes -memprofile and -cpuprofile")
|
||||
v.metricsHttpPort = cmdVolume.Flag.Int("metricsPort", 0, "Prometheus metrics listen port")
|
||||
v.metricsHttpIp = cmdVolume.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
|
||||
@@ -19,6 +19,9 @@ import (
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
|
||||
// TODO: Implement additional task packages (add to default capabilities when ready):
|
||||
// _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/remote" - for uploading volumes to remote/cloud storage
|
||||
// _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/replication" - for fixing replication issues and maintaining data consistency
|
||||
)
|
||||
|
||||
var cmdWorker = &Command{
|
||||
@@ -41,7 +44,7 @@ Examples:
|
||||
|
||||
var (
|
||||
workerAdminServer = cmdWorker.Flag.String("admin", "localhost:23646", "admin server address")
|
||||
workerCapabilities = cmdWorker.Flag.String("capabilities", "vacuum,ec,remote,replication,balance", "comma-separated list of task types this worker can handle")
|
||||
workerCapabilities = cmdWorker.Flag.String("capabilities", "vacuum,ec,balance", "comma-separated list of task types this worker can handle")
|
||||
workerMaxConcurrent = cmdWorker.Flag.Int("maxConcurrent", 2, "maximum number of concurrent tasks")
|
||||
workerHeartbeatInterval = cmdWorker.Flag.Duration("heartbeat", 30*time.Second, "heartbeat interval")
|
||||
workerTaskRequestInterval = cmdWorker.Flag.Duration("taskInterval", 5*time.Second, "task request interval")
|
||||
|
||||
+6
-1
@@ -92,7 +92,12 @@ func (entry *Entry) ToExistingProtoEntry(message *filer_pb.Entry) {
|
||||
return
|
||||
}
|
||||
message.IsDirectory = entry.IsDirectory()
|
||||
message.Attributes = EntryAttributeToPb(entry)
|
||||
// Reuse pre-allocated attributes if available, otherwise allocate
|
||||
if message.Attributes != nil {
|
||||
EntryAttributeToExistingPb(entry, message.Attributes)
|
||||
} else {
|
||||
message.Attributes = EntryAttributeToPb(entry)
|
||||
}
|
||||
message.Chunks = entry.GetChunks()
|
||||
message.Extended = entry.Extended
|
||||
message.HardLinkId = entry.HardLinkId
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -11,15 +12,61 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
// pbEntryPool reduces allocations in EncodeAttributesAndChunks and DecodeAttributesAndChunks
|
||||
// which are called on every filer store operation
|
||||
var pbEntryPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &filer_pb.Entry{
|
||||
Attributes: &filer_pb.FuseAttributes{}, // Pre-allocate attributes
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// resetPbEntry clears a protobuf Entry for reuse
|
||||
func resetPbEntry(e *filer_pb.Entry) {
|
||||
// Use struct assignment to clear all fields including protobuf internal fields
|
||||
// (unknownFields, sizeCache) that field-by-field reset would miss
|
||||
attrs := e.Attributes
|
||||
*e = filer_pb.Entry{}
|
||||
if attrs == nil {
|
||||
attrs = &filer_pb.FuseAttributes{}
|
||||
} else {
|
||||
resetFuseAttributes(attrs)
|
||||
}
|
||||
e.Attributes = attrs
|
||||
}
|
||||
|
||||
// resetFuseAttributes clears FuseAttributes for reuse
|
||||
func resetFuseAttributes(a *filer_pb.FuseAttributes) {
|
||||
// Use struct assignment to clear all fields including protobuf internal fields
|
||||
*a = filer_pb.FuseAttributes{}
|
||||
}
|
||||
|
||||
func (entry *Entry) EncodeAttributesAndChunks() ([]byte, error) {
|
||||
message := &filer_pb.Entry{}
|
||||
message := pbEntryPool.Get().(*filer_pb.Entry)
|
||||
defer func() {
|
||||
resetPbEntry(message)
|
||||
pbEntryPool.Put(message)
|
||||
}()
|
||||
|
||||
entry.ToExistingProtoEntry(message)
|
||||
return proto.Marshal(message)
|
||||
|
||||
data, err := proto.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Copy the data to a new slice since proto.Marshal may return a slice
|
||||
// that shares memory with the message (not guaranteed to be a copy)
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func (entry *Entry) DecodeAttributesAndChunks(blob []byte) error {
|
||||
|
||||
message := &filer_pb.Entry{}
|
||||
message := pbEntryPool.Get().(*filer_pb.Entry)
|
||||
defer func() {
|
||||
resetPbEntry(message)
|
||||
pbEntryPool.Put(message)
|
||||
}()
|
||||
|
||||
if err := proto.Unmarshal(blob, message); err != nil {
|
||||
return fmt.Errorf("decoding value blob for %s: %v", entry.FullPath, err)
|
||||
@@ -50,6 +97,28 @@ func EntryAttributeToPb(entry *Entry) *filer_pb.FuseAttributes {
|
||||
}
|
||||
}
|
||||
|
||||
// EntryAttributeToExistingPb fills an existing FuseAttributes to avoid allocation.
|
||||
// Safe to call with nil attr (will return early without populating).
|
||||
func EntryAttributeToExistingPb(entry *Entry, attr *filer_pb.FuseAttributes) {
|
||||
if attr == nil {
|
||||
return
|
||||
}
|
||||
attr.Crtime = entry.Attr.Crtime.Unix()
|
||||
attr.Mtime = entry.Attr.Mtime.Unix()
|
||||
attr.FileMode = uint32(entry.Attr.Mode)
|
||||
attr.Uid = entry.Uid
|
||||
attr.Gid = entry.Gid
|
||||
attr.Mime = entry.Mime
|
||||
attr.TtlSec = entry.Attr.TtlSec
|
||||
attr.UserName = entry.Attr.UserName
|
||||
attr.GroupName = entry.Attr.GroupNames
|
||||
attr.SymlinkTarget = entry.Attr.SymlinkTarget
|
||||
attr.Md5 = entry.Attr.Md5
|
||||
attr.FileSize = entry.Attr.FileSize
|
||||
attr.Rdev = entry.Attr.Rdev
|
||||
attr.Inode = entry.Attr.Inode
|
||||
}
|
||||
|
||||
func PbToEntryAttribute(attr *filer_pb.FuseAttributes) Attr {
|
||||
|
||||
t := Attr{}
|
||||
|
||||
@@ -160,18 +160,79 @@ func (fc *FilerConf) DeleteLocationConf(locationPrefix string) {
|
||||
return true
|
||||
})
|
||||
fc.rules = rules
|
||||
return
|
||||
}
|
||||
|
||||
// emptyPathConf is a singleton for paths with no matching rules
|
||||
// Callers must NOT mutate the returned value
|
||||
var emptyPathConf = &filer_pb.FilerConf_PathConf{}
|
||||
|
||||
func (fc *FilerConf) MatchStorageRule(path string) (pathConf *filer_pb.FilerConf_PathConf) {
|
||||
// Convert once to avoid allocation in multi-match case
|
||||
pathBytes := []byte(path)
|
||||
|
||||
// Fast path: check if any rules match before allocating
|
||||
// This avoids allocation for paths with no configured rules (common case)
|
||||
var firstMatch *filer_pb.FilerConf_PathConf
|
||||
matchCount := 0
|
||||
|
||||
fc.rules.MatchPrefix(pathBytes, func(key []byte, value *filer_pb.FilerConf_PathConf) bool {
|
||||
matchCount++
|
||||
if matchCount == 1 {
|
||||
firstMatch = value
|
||||
return true // continue to check for more matches
|
||||
}
|
||||
// Stop after 2 matches - we only need to know if there are multiple
|
||||
return false
|
||||
})
|
||||
|
||||
// No rules match - return singleton (callers must NOT mutate)
|
||||
if matchCount == 0 {
|
||||
return emptyPathConf
|
||||
}
|
||||
|
||||
// Single rule matches - return directly (callers must NOT mutate)
|
||||
if matchCount == 1 {
|
||||
return firstMatch
|
||||
}
|
||||
|
||||
// Multiple rules match - need to merge (allocate new)
|
||||
pathConf = &filer_pb.FilerConf_PathConf{}
|
||||
fc.rules.MatchPrefix([]byte(path), func(key []byte, value *filer_pb.FilerConf_PathConf) bool {
|
||||
fc.rules.MatchPrefix(pathBytes, func(key []byte, value *filer_pb.FilerConf_PathConf) bool {
|
||||
mergePathConf(pathConf, value)
|
||||
return true
|
||||
})
|
||||
return pathConf
|
||||
}
|
||||
|
||||
// ClonePathConf creates a mutable copy of an existing PathConf.
|
||||
// Use this when you need to modify a config (e.g., before calling SetLocationConf).
|
||||
//
|
||||
// IMPORTANT: Keep in sync with filer_pb.FilerConf_PathConf fields.
|
||||
// When adding new fields to the protobuf, update this function accordingly.
|
||||
func ClonePathConf(src *filer_pb.FilerConf_PathConf) *filer_pb.FilerConf_PathConf {
|
||||
if src == nil {
|
||||
return &filer_pb.FilerConf_PathConf{}
|
||||
}
|
||||
return &filer_pb.FilerConf_PathConf{
|
||||
LocationPrefix: src.LocationPrefix,
|
||||
Collection: src.Collection,
|
||||
Replication: src.Replication,
|
||||
Ttl: src.Ttl,
|
||||
DiskType: src.DiskType,
|
||||
Fsync: src.Fsync,
|
||||
VolumeGrowthCount: src.VolumeGrowthCount,
|
||||
ReadOnly: src.ReadOnly,
|
||||
MaxFileNameLength: src.MaxFileNameLength,
|
||||
DataCenter: src.DataCenter,
|
||||
Rack: src.Rack,
|
||||
DataNode: src.DataNode,
|
||||
DisableChunkDeletion: src.DisableChunkDeletion,
|
||||
Worm: src.Worm,
|
||||
WormGracePeriodSeconds: src.WormGracePeriodSeconds,
|
||||
WormRetentionTimeSeconds: src.WormRetentionTimeSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (fc *FilerConf) GetCollectionTtls(collection string) (ttls map[string]string) {
|
||||
ttls = make(map[string]string)
|
||||
fc.rules.Walk(func(key []byte, value *filer_pb.FilerConf_PathConf) bool {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -47,3 +48,83 @@ func TestFilerConf(t *testing.T) {
|
||||
assert.Equal(t, false, fc.MatchStorageRule("/buckets/other").ReadOnly)
|
||||
|
||||
}
|
||||
|
||||
// TestClonePathConf verifies that ClonePathConf copies all exported fields.
|
||||
// Uses reflection to automatically detect new fields added to the protobuf,
|
||||
// ensuring the test fails if ClonePathConf is not updated for new fields.
|
||||
func TestClonePathConf(t *testing.T) {
|
||||
// Create a fully-populated PathConf with non-zero values for all fields
|
||||
src := &filer_pb.FilerConf_PathConf{
|
||||
LocationPrefix: "/test/path",
|
||||
Collection: "test_collection",
|
||||
Replication: "001",
|
||||
Ttl: "7d",
|
||||
DiskType: "ssd",
|
||||
Fsync: true,
|
||||
VolumeGrowthCount: 5,
|
||||
ReadOnly: true,
|
||||
MaxFileNameLength: 255,
|
||||
DataCenter: "dc1",
|
||||
Rack: "rack1",
|
||||
DataNode: "node1",
|
||||
DisableChunkDeletion: true,
|
||||
Worm: true,
|
||||
WormGracePeriodSeconds: 3600,
|
||||
WormRetentionTimeSeconds: 86400,
|
||||
}
|
||||
|
||||
clone := ClonePathConf(src)
|
||||
|
||||
// Verify it's a different object
|
||||
assert.NotSame(t, src, clone, "ClonePathConf should return a new object, not the same pointer")
|
||||
|
||||
// Use reflection to compare all exported fields
|
||||
// This will automatically catch any new fields added to the protobuf
|
||||
srcVal := reflect.ValueOf(src).Elem()
|
||||
cloneVal := reflect.ValueOf(clone).Elem()
|
||||
srcType := srcVal.Type()
|
||||
|
||||
for i := 0; i < srcType.NumField(); i++ {
|
||||
field := srcType.Field(i)
|
||||
|
||||
// Skip unexported fields (protobuf internal fields like sizeCache, unknownFields)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
srcField := srcVal.Field(i)
|
||||
cloneField := cloneVal.Field(i)
|
||||
|
||||
// Compare field values
|
||||
if !reflect.DeepEqual(srcField.Interface(), cloneField.Interface()) {
|
||||
t.Errorf("Field %s not copied correctly: src=%v, clone=%v",
|
||||
field.Name, srcField.Interface(), cloneField.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
// Additionally verify that all exported fields in src are non-zero
|
||||
// This ensures we're testing with fully populated data
|
||||
for i := 0; i < srcType.NumField(); i++ {
|
||||
field := srcType.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
srcField := srcVal.Field(i)
|
||||
if srcField.IsZero() {
|
||||
t.Errorf("Test setup error: field %s has zero value, update test to set a non-zero value", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify mutation of clone doesn't affect source
|
||||
clone.Collection = "modified"
|
||||
clone.ReadOnly = false
|
||||
assert.Equal(t, "test_collection", src.Collection, "Modifying clone should not affect source Collection")
|
||||
assert.Equal(t, true, src.ReadOnly, "Modifying clone should not affect source ReadOnly")
|
||||
}
|
||||
|
||||
func TestClonePathConfNil(t *testing.T) {
|
||||
clone := ClonePathConf(nil)
|
||||
assert.NotNil(t, clone, "ClonePathConf(nil) should return a non-nil empty PathConf")
|
||||
assert.Equal(t, "", clone.LocationPrefix, "ClonePathConf(nil) should return empty PathConf")
|
||||
}
|
||||
|
||||
@@ -152,9 +152,21 @@ func (f *Filer) logFlushFunc(logBuffer *log_buffer.LogBuffer, startTime, stopTim
|
||||
}
|
||||
|
||||
var (
|
||||
VolumeNotFoundPattern = regexp.MustCompile(`volume \d+? not found`)
|
||||
volumeNotFoundPattern = regexp.MustCompile(`volume \d+? not found`)
|
||||
chunkNotFoundPattern = regexp.MustCompile(`(urls not found|File Not Found)`)
|
||||
)
|
||||
|
||||
// isChunkNotFoundError checks if the error indicates that a volume or chunk
|
||||
// has been deleted and is no longer available. These errors can be skipped
|
||||
// when reading persisted log files since the data is unrecoverable.
|
||||
func isChunkNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := err.Error()
|
||||
return volumeNotFoundPattern.MatchString(errMsg) || chunkNotFoundPattern.MatchString(errMsg)
|
||||
}
|
||||
|
||||
func (f *Filer) ReadPersistedLogBuffer(startPosition log_buffer.MessagePosition, stopTsNs int64, eachLogEntryFn log_buffer.EachLogEntryFuncType) (lastTsNs int64, isDone bool, err error) {
|
||||
|
||||
visitor, visitErr := f.collectPersistedLogBuffer(startPosition, stopTsNs)
|
||||
|
||||
@@ -265,11 +265,12 @@ func (c *LogFileEntryCollector) collectMore(v *OrderedLogVisitor) (err error) {
|
||||
// ----------
|
||||
|
||||
type LogFileQueueIterator struct {
|
||||
q *util.Queue[*LogFileEntry]
|
||||
masterClient *wdclient.MasterClient
|
||||
startTsNs int64
|
||||
stopTsNs int64
|
||||
currentFileIterator *LogFileIterator
|
||||
q *util.Queue[*LogFileEntry]
|
||||
masterClient *wdclient.MasterClient
|
||||
startTsNs int64
|
||||
stopTsNs int64
|
||||
pendingEntries []*filer_pb.LogEntry
|
||||
pendingIndex int
|
||||
}
|
||||
|
||||
func newLogFileQueueIterator(masterClient *wdclient.MasterClient, q *util.Queue[*LogFileEntry], startTsNs, stopTsNs int64) *LogFileQueueIterator {
|
||||
@@ -284,13 +285,17 @@ func newLogFileQueueIterator(masterClient *wdclient.MasterClient, q *util.Queue[
|
||||
// getNext will return io.EOF when done
|
||||
func (iter *LogFileQueueIterator) getNext(v *OrderedLogVisitor) (logEntry *filer_pb.LogEntry, err error) {
|
||||
for {
|
||||
if iter.currentFileIterator != nil {
|
||||
logEntry, err = iter.currentFileIterator.getNext()
|
||||
if err != io.EOF {
|
||||
return
|
||||
}
|
||||
// return pending entries first
|
||||
if iter.pendingIndex < len(iter.pendingEntries) {
|
||||
logEntry = iter.pendingEntries[iter.pendingIndex]
|
||||
iter.pendingIndex++
|
||||
return logEntry, nil
|
||||
}
|
||||
// now either iter.currentFileIterator is nil or err is io.EOF
|
||||
// reset for next file
|
||||
iter.pendingEntries = nil
|
||||
iter.pendingIndex = 0
|
||||
|
||||
// read entries from next file
|
||||
if iter.q.Len() == 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
@@ -313,7 +318,38 @@ func (iter *LogFileQueueIterator) getNext(v *OrderedLogVisitor) (logEntry *filer
|
||||
if next != nil && next.TsNs <= iter.startTsNs {
|
||||
continue
|
||||
}
|
||||
iter.currentFileIterator = newLogFileIterator(iter.masterClient, t.FileEntry, iter.startTsNs, iter.stopTsNs)
|
||||
|
||||
// read all entries from this file
|
||||
iter.pendingEntries, err = iter.readFileEntries(t.FileEntry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readFileEntries reads all log entries from a single file
|
||||
func (iter *LogFileQueueIterator) readFileEntries(fileEntry *Entry) (entries []*filer_pb.LogEntry, err error) {
|
||||
fileIterator := newLogFileIterator(iter.masterClient, fileEntry, iter.startTsNs, iter.stopTsNs)
|
||||
defer func() {
|
||||
if closeErr := fileIterator.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
logEntry, err := fileIterator.getNext()
|
||||
if err == io.EOF {
|
||||
return entries, nil
|
||||
}
|
||||
if err != nil {
|
||||
if isChunkNotFoundError(err) {
|
||||
// Volume or chunk was deleted, skip the rest of this log file
|
||||
glog.Warningf("skipping rest of %s: %v", fileIterator.filePath, err)
|
||||
return entries, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, logEntry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +360,7 @@ type LogFileIterator struct {
|
||||
sizeBuf []byte
|
||||
startTsNs int64
|
||||
stopTsNs int64
|
||||
filePath string
|
||||
}
|
||||
|
||||
func newLogFileIterator(masterClient *wdclient.MasterClient, fileEntry *Entry, startTsNs, stopTsNs int64) *LogFileIterator {
|
||||
@@ -332,9 +369,17 @@ func newLogFileIterator(masterClient *wdclient.MasterClient, fileEntry *Entry, s
|
||||
sizeBuf: make([]byte, 4),
|
||||
startTsNs: startTsNs,
|
||||
stopTsNs: stopTsNs,
|
||||
filePath: string(fileEntry.FullPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (iter *LogFileIterator) Close() error {
|
||||
if r, ok := iter.r.(io.Closer); ok {
|
||||
return r.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getNext will return io.EOF when done
|
||||
func (iter *LogFileIterator) getNext() (logEntry *filer_pb.LogEntry, err error) {
|
||||
var n int
|
||||
|
||||
@@ -32,9 +32,10 @@ type VirtualFilerStore interface {
|
||||
}
|
||||
|
||||
type FilerStoreWrapper struct {
|
||||
defaultStore FilerStore
|
||||
pathToStore ptrie.Trie[string]
|
||||
storeIdToStore map[string]FilerStore
|
||||
defaultStore FilerStore
|
||||
pathToStore ptrie.Trie[string]
|
||||
storeIdToStore map[string]FilerStore
|
||||
hasPathSpecificStore bool // fast check to skip MatchPrefix when no path-specific stores
|
||||
}
|
||||
|
||||
func NewFilerStoreWrapper(store FilerStore) *FilerStoreWrapper {
|
||||
@@ -82,10 +83,15 @@ func (fsw *FilerStoreWrapper) AddPathSpecificStore(path string, storeId string,
|
||||
if err != nil {
|
||||
glog.Fatalf("put path specific store: %v", err)
|
||||
}
|
||||
fsw.hasPathSpecificStore = true
|
||||
}
|
||||
|
||||
func (fsw *FilerStoreWrapper) getActualStore(path util.FullPath) (store FilerStore) {
|
||||
store = fsw.defaultStore
|
||||
// Fast path: skip MatchPrefix if no path-specific stores are configured (common case)
|
||||
if !fsw.hasPathSpecificStore {
|
||||
return
|
||||
}
|
||||
if path == "/" || path == "//" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/apple/foundationdb/bindings/go/src/fdb"
|
||||
@@ -37,15 +38,146 @@ import (
|
||||
const (
|
||||
// FoundationDB transaction size limit is 10MB
|
||||
FDB_TRANSACTION_SIZE_LIMIT = 10 * 1024 * 1024
|
||||
// Safe limit for batch size (leave margin for FDB overhead)
|
||||
FDB_BATCH_SIZE_LIMIT = 8 * 1024 * 1024
|
||||
// Maximum number of entries to return in a single directory listing
|
||||
// Large batches can cause transaction timeouts and increase memory pressure
|
||||
MAX_DIRECTORY_LIST_LIMIT = 1000
|
||||
|
||||
// Write batching defaults
|
||||
DEFAULT_BATCH_SIZE = 100
|
||||
DEFAULT_BATCH_INTERVAL = 5 * time.Millisecond
|
||||
)
|
||||
|
||||
func init() {
|
||||
filer.Stores = append(filer.Stores, &FoundationDBStore{})
|
||||
}
|
||||
|
||||
// writeOp represents a pending write operation
|
||||
type writeOp struct {
|
||||
key fdb.Key
|
||||
value []byte // nil for delete
|
||||
done chan error
|
||||
}
|
||||
|
||||
// opSize returns the approximate size of an operation in bytes
|
||||
func (op *writeOp) size() int {
|
||||
return len(op.key) + len(op.value)
|
||||
}
|
||||
|
||||
// writeBatcher batches multiple writes into single transactions
|
||||
type writeBatcher struct {
|
||||
store *FoundationDBStore
|
||||
ops chan *writeOp
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
size int
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func newWriteBatcher(store *FoundationDBStore, size int, interval time.Duration) *writeBatcher {
|
||||
b := &writeBatcher{
|
||||
store: store,
|
||||
ops: make(chan *writeOp, size*10),
|
||||
stop: make(chan struct{}),
|
||||
size: size,
|
||||
interval: interval,
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.run()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *writeBatcher) run() {
|
||||
defer b.wg.Done()
|
||||
batch := make([]*writeOp, 0, b.size)
|
||||
batchBytes := 0 // Track cumulative size of batch
|
||||
timer := time.NewTimer(b.interval)
|
||||
defer timer.Stop()
|
||||
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
_, err := b.store.database.Transact(func(tr fdb.Transaction) (interface{}, error) {
|
||||
for _, op := range batch {
|
||||
if op.value != nil {
|
||||
tr.Set(op.key, op.value)
|
||||
} else {
|
||||
tr.Clear(op.key)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
for _, op := range batch {
|
||||
if op.done != nil {
|
||||
op.done <- err
|
||||
close(op.done)
|
||||
}
|
||||
}
|
||||
batch = batch[:0]
|
||||
batchBytes = 0
|
||||
}
|
||||
|
||||
resetTimer := func() {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(b.interval)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case op := <-b.ops:
|
||||
batch = append(batch, op)
|
||||
batchBytes += op.size()
|
||||
// Flush when batch count or size limit is reached
|
||||
if len(batch) >= b.size || batchBytes >= FDB_BATCH_SIZE_LIMIT {
|
||||
flush()
|
||||
resetTimer()
|
||||
}
|
||||
case <-timer.C:
|
||||
flush()
|
||||
// Timer already fired, safe to reset directly
|
||||
timer.Reset(b.interval)
|
||||
case <-b.stop:
|
||||
for {
|
||||
select {
|
||||
case op := <-b.ops:
|
||||
batch = append(batch, op)
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *writeBatcher) submit(key fdb.Key, value []byte, wait bool) error {
|
||||
op := &writeOp{key: key, value: value}
|
||||
if wait {
|
||||
op.done = make(chan error, 1)
|
||||
}
|
||||
select {
|
||||
case b.ops <- op:
|
||||
if wait {
|
||||
return <-op.done
|
||||
}
|
||||
return nil
|
||||
case <-b.stop:
|
||||
return fmt.Errorf("batcher stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *writeBatcher) shutdown() {
|
||||
close(b.stop)
|
||||
b.wg.Wait()
|
||||
}
|
||||
|
||||
type FoundationDBStore struct {
|
||||
database fdb.Database
|
||||
seaweedfsDir directory.DirectorySubspace
|
||||
@@ -53,6 +185,10 @@ type FoundationDBStore struct {
|
||||
directoryPrefix string
|
||||
timeout time.Duration
|
||||
maxRetryDelay time.Duration
|
||||
// Write batching
|
||||
batcher *writeBatcher
|
||||
batchSize int
|
||||
batchInterval time.Duration
|
||||
}
|
||||
|
||||
// Context key type for storing transactions
|
||||
@@ -89,6 +225,8 @@ func (store *FoundationDBStore) Initialize(configuration util.Configuration, pre
|
||||
configuration.SetDefault(prefix+"timeout", "5s")
|
||||
configuration.SetDefault(prefix+"max_retry_delay", "1s")
|
||||
configuration.SetDefault(prefix+"directory_prefix", "seaweedfs")
|
||||
configuration.SetDefault(prefix+"batch_size", DEFAULT_BATCH_SIZE)
|
||||
configuration.SetDefault(prefix+"batch_interval", DEFAULT_BATCH_INTERVAL.String())
|
||||
|
||||
clusterFile := configuration.GetString(prefix + "cluster_file")
|
||||
apiVersion := configuration.GetInt(prefix + "api_version")
|
||||
@@ -108,6 +246,18 @@ func (store *FoundationDBStore) Initialize(configuration util.Configuration, pre
|
||||
return fmt.Errorf("invalid max_retry_delay duration %s: %w", maxRetryDelayStr, err)
|
||||
}
|
||||
|
||||
// Parse batch configuration
|
||||
store.batchSize = configuration.GetInt(prefix + "batch_size")
|
||||
if store.batchSize <= 0 {
|
||||
store.batchSize = DEFAULT_BATCH_SIZE
|
||||
}
|
||||
batchIntervalStr := configuration.GetString(prefix + "batch_interval")
|
||||
store.batchInterval, err = time.ParseDuration(batchIntervalStr)
|
||||
if err != nil {
|
||||
glog.Warningf("invalid %sbatch_interval duration %q, using default %v: %v", prefix, batchIntervalStr, DEFAULT_BATCH_INTERVAL, err)
|
||||
store.batchInterval = DEFAULT_BATCH_INTERVAL
|
||||
}
|
||||
|
||||
return store.initialize(clusterFile, apiVersion)
|
||||
}
|
||||
|
||||
@@ -138,6 +288,11 @@ func (store *FoundationDBStore) initialize(clusterFile string, apiVersion int) e
|
||||
return fmt.Errorf("failed to create/open kv directory: %w", err)
|
||||
}
|
||||
|
||||
// Start write batcher for improved throughput
|
||||
store.batcher = newWriteBatcher(store, store.batchSize, store.batchInterval)
|
||||
glog.V(0).Infof("FoundationDB: write batching enabled (batch_size=%d, batch_interval=%v)",
|
||||
store.batchSize, store.batchInterval)
|
||||
|
||||
glog.V(0).Infof("FoundationDB store initialized successfully with directory prefix: %s", store.directoryPrefix)
|
||||
return nil
|
||||
}
|
||||
@@ -215,7 +370,12 @@ func (store *FoundationDBStore) UpdateEntry(ctx context.Context, entry *filer.En
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute in a new transaction if not in an existing one
|
||||
// Use write batcher for better throughput
|
||||
if store.batcher != nil {
|
||||
return store.batcher.submit(key, value, true)
|
||||
}
|
||||
|
||||
// Fallback: execute in a new transaction
|
||||
_, err = store.database.Transact(func(tr fdb.Transaction) (interface{}, error) {
|
||||
tr.Set(key, value)
|
||||
return nil, nil
|
||||
@@ -276,7 +436,12 @@ func (store *FoundationDBStore) DeleteEntry(ctx context.Context, fullpath util.F
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute in a new transaction if not in an existing one
|
||||
// Use write batcher for better throughput (nil value = delete)
|
||||
if store.batcher != nil {
|
||||
return store.batcher.submit(key, nil, true)
|
||||
}
|
||||
|
||||
// Fallback: execute in a new transaction
|
||||
_, err := store.database.Transact(func(tr fdb.Transaction) (interface{}, error) {
|
||||
tr.Clear(key)
|
||||
return nil, nil
|
||||
@@ -556,13 +721,37 @@ func (store *FoundationDBStore) KvDelete(ctx context.Context, key []byte) error
|
||||
}
|
||||
|
||||
func (store *FoundationDBStore) Shutdown() {
|
||||
// Stop write batcher
|
||||
if store.batcher != nil {
|
||||
store.batcher.shutdown()
|
||||
store.batcher = nil
|
||||
}
|
||||
// FoundationDB doesn't have an explicit close method for Database
|
||||
glog.V(0).Infof("FoundationDB store shutdown")
|
||||
}
|
||||
|
||||
// tuplePool reduces allocations in genKey which is called on every FDB operation
|
||||
var tuplePool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
// Pre-allocate slice with capacity 2 for (dirPath, fileName)
|
||||
t := make(tuple.Tuple, 2)
|
||||
return &t
|
||||
},
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func (store *FoundationDBStore) genKey(dirPath, fileName string) fdb.Key {
|
||||
return store.seaweedfsDir.Pack(tuple.Tuple{dirPath, fileName})
|
||||
// Get a tuple from pool to avoid slice allocation
|
||||
tp := tuplePool.Get().(*tuple.Tuple)
|
||||
defer func() {
|
||||
// Clear references before returning to pool to avoid memory leaks
|
||||
(*tp)[0] = nil
|
||||
(*tp)[1] = nil
|
||||
tuplePool.Put(tp)
|
||||
}()
|
||||
(*tp)[0] = dirPath
|
||||
(*tp)[1] = fileName
|
||||
return store.seaweedfsDir.Pack(*tp)
|
||||
}
|
||||
|
||||
func (store *FoundationDBStore) extractFileName(key fdb.Key) (string, error) {
|
||||
|
||||
@@ -107,6 +107,39 @@ func (store *LevelDBStore) UpdateEntry(ctx context.Context, entry *filer.Entry)
|
||||
return store.InsertEntry(ctx, entry)
|
||||
}
|
||||
|
||||
// BatchInsertEntries inserts multiple entries in a single LevelDB batch write.
|
||||
// This is more efficient than inserting entries one by one as it reduces
|
||||
// the number of write operations and syncs to disk.
|
||||
func (store *LevelDBStore) BatchInsertEntries(ctx context.Context, entries []*filer.Entry) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
batch := new(leveldb.Batch)
|
||||
|
||||
for _, entry := range entries {
|
||||
key := genKey(entry.DirAndName())
|
||||
|
||||
value, err := entry.EncodeAttributesAndChunks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding %s %+v: %w", entry.FullPath, entry.Attr, err)
|
||||
}
|
||||
|
||||
if len(entry.GetChunks()) > filer.CountEntryChunksForGzip {
|
||||
value = weed_util.MaybeGzipData(value)
|
||||
}
|
||||
|
||||
batch.Put(key, value)
|
||||
}
|
||||
|
||||
err := store.db.Write(batch, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch write: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *LevelDBStore) FindEntry(ctx context.Context, fullpath weed_util.FullPath) (entry *filer.Entry, err error) {
|
||||
key := genKey(fullpath.DirAndName())
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ type ChunkStreamReader struct {
|
||||
|
||||
var _ = io.ReadSeeker(&ChunkStreamReader{})
|
||||
var _ = io.ReaderAt(&ChunkStreamReader{})
|
||||
var _ = io.Closer(&ChunkStreamReader{})
|
||||
|
||||
func doNewChunkStreamReader(ctx context.Context, lookupFileIdFn wdclient.LookupFileIdFunctionType, chunks []*filer_pb.FileChunk) *ChunkStreamReader {
|
||||
|
||||
@@ -403,8 +404,13 @@ func (c *ChunkStreamReader) fetchChunkToBuffer(chunkView *ChunkView) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ChunkStreamReader) Close() {
|
||||
// TODO try to release and reuse buffer
|
||||
func (c *ChunkStreamReader) Close() error {
|
||||
c.bufferLock.Lock()
|
||||
defer c.bufferLock.Unlock()
|
||||
c.buffer = nil
|
||||
c.head = nil
|
||||
c.chunkView = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func VolumeId(fileId string) string {
|
||||
|
||||
@@ -34,6 +34,7 @@ type TikvStore struct {
|
||||
client *txnkv.Client
|
||||
onePC bool
|
||||
batchCommitSize int
|
||||
keyPrefix []byte
|
||||
}
|
||||
|
||||
// Basic APIs
|
||||
@@ -47,6 +48,7 @@ func (store *TikvStore) Initialize(config util.Configuration, prefix string) err
|
||||
key := config.GetString(prefix + "key_path")
|
||||
verify_cn := strings.Split(config.GetString(prefix+"verify_cn"), ",")
|
||||
pdAddrs := strings.Split(config.GetString(prefix+"pdaddrs"), ",")
|
||||
keyPrefix := config.GetString(prefix + "keyPrefix")
|
||||
|
||||
bdc := config.GetInt(prefix + "batchdelete_count")
|
||||
if bdc <= 0 {
|
||||
@@ -55,6 +57,7 @@ func (store *TikvStore) Initialize(config util.Configuration, prefix string) err
|
||||
|
||||
store.onePC = config.GetBool(prefix + "enable_1pc")
|
||||
store.batchCommitSize = bdc
|
||||
store.keyPrefix = []byte(keyPrefix)
|
||||
return store.initialize(ca, cert, key, verify_cn, pdAddrs)
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ func (store *TikvStore) Shutdown() {
|
||||
// Entry APIs
|
||||
func (store *TikvStore) InsertEntry(ctx context.Context, entry *filer.Entry) error {
|
||||
dir, name := entry.DirAndName()
|
||||
key := generateKey(dir, name)
|
||||
key := store.generateKey(dir, name)
|
||||
|
||||
value, err := entry.EncodeAttributesAndChunks()
|
||||
if err != nil {
|
||||
@@ -104,7 +107,7 @@ func (store *TikvStore) UpdateEntry(ctx context.Context, entry *filer.Entry) err
|
||||
|
||||
func (store *TikvStore) FindEntry(ctx context.Context, path util.FullPath) (*filer.Entry, error) {
|
||||
dir, name := path.DirAndName()
|
||||
key := generateKey(dir, name)
|
||||
key := store.generateKey(dir, name)
|
||||
|
||||
txn, err := store.getTxn(ctx)
|
||||
if err != nil {
|
||||
@@ -139,7 +142,7 @@ func (store *TikvStore) FindEntry(ctx context.Context, path util.FullPath) (*fil
|
||||
|
||||
func (store *TikvStore) DeleteEntry(ctx context.Context, path util.FullPath) error {
|
||||
dir, name := path.DirAndName()
|
||||
key := generateKey(dir, name)
|
||||
key := store.generateKey(dir, name)
|
||||
|
||||
txn, err := store.getTxn(ctx)
|
||||
if err != nil {
|
||||
@@ -159,7 +162,7 @@ func (store *TikvStore) DeleteEntry(ctx context.Context, path util.FullPath) err
|
||||
|
||||
// Directory APIs
|
||||
func (store *TikvStore) DeleteFolderChildren(ctx context.Context, path util.FullPath) error {
|
||||
directoryPrefix := genDirectoryKeyPrefix(path, "")
|
||||
directoryPrefix := store.genDirectoryKeyPrefix(path, "")
|
||||
|
||||
iterTxn, err := store.getTxn(ctx)
|
||||
if err != nil {
|
||||
@@ -242,10 +245,10 @@ func (store *TikvStore) ListDirectoryEntries(ctx context.Context, dirPath util.F
|
||||
|
||||
func (store *TikvStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc filer.ListEachEntryFunc) (string, error) {
|
||||
lastFileName := ""
|
||||
directoryPrefix := genDirectoryKeyPrefix(dirPath, prefix)
|
||||
directoryPrefix := store.genDirectoryKeyPrefix(dirPath, prefix)
|
||||
lastFileStart := directoryPrefix
|
||||
if startFileName != "" {
|
||||
lastFileStart = genDirectoryKeyPrefix(dirPath, startFileName)
|
||||
lastFileStart = store.genDirectoryKeyPrefix(dirPath, startFileName)
|
||||
}
|
||||
|
||||
txn, err := store.getTxn(ctx)
|
||||
@@ -265,7 +268,7 @@ func (store *TikvStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPat
|
||||
if !bytes.HasPrefix(key, directoryPrefix) {
|
||||
break
|
||||
}
|
||||
fileName := getNameFromKey(key)
|
||||
fileName := store.getNameFromKey(key)
|
||||
if fileName == "" {
|
||||
if err := iter.Next(); err != nil {
|
||||
break
|
||||
@@ -418,22 +421,37 @@ func hashToBytes(dir string) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func generateKey(dirPath, fileName string) []byte {
|
||||
func (store *TikvStore) getKey(key []byte) []byte {
|
||||
if len(store.keyPrefix) == 0 {
|
||||
return key
|
||||
}
|
||||
result := make([]byte, len(store.keyPrefix)+len(key))
|
||||
copy(result, store.keyPrefix)
|
||||
copy(result[len(store.keyPrefix):], key)
|
||||
return result
|
||||
}
|
||||
|
||||
func (store *TikvStore) generateKey(dirPath, fileName string) []byte {
|
||||
key := hashToBytes(dirPath)
|
||||
key = append(key, []byte(fileName)...)
|
||||
return key
|
||||
return store.getKey(key)
|
||||
}
|
||||
|
||||
func getNameFromKey(key []byte) string {
|
||||
return string(key[sha1.Size:])
|
||||
func (store *TikvStore) getNameFromKey(key []byte) string {
|
||||
minKeyLen := len(store.keyPrefix) + sha1.Size
|
||||
if len(key) < minKeyLen {
|
||||
glog.Warningf("malformed key: length %d is less than minimum %d", len(key), minKeyLen)
|
||||
return ""
|
||||
}
|
||||
return string(key[minKeyLen:])
|
||||
}
|
||||
|
||||
func genDirectoryKeyPrefix(fullpath util.FullPath, startFileName string) (keyPrefix []byte) {
|
||||
func (store *TikvStore) genDirectoryKeyPrefix(fullpath util.FullPath, startFileName string) (keyPrefix []byte) {
|
||||
keyPrefix = hashToBytes(string(fullpath))
|
||||
if len(startFileName) > 0 {
|
||||
keyPrefix = append(keyPrefix, []byte(startFileName)...)
|
||||
}
|
||||
return keyPrefix
|
||||
return store.getKey(keyPrefix)
|
||||
}
|
||||
|
||||
func isNotExists(err error) bool {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package iam
|
||||
|
||||
// Character sets for credential generation
|
||||
const (
|
||||
CharsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
Charset = CharsetUpper + "abcdefghijklmnopqrstuvwxyz/"
|
||||
)
|
||||
|
||||
// Policy document version
|
||||
const PolicyDocumentVersion = "2012-10-17"
|
||||
|
||||
// Error message templates
|
||||
const UserDoesNotExist = "the user with name %s cannot be found."
|
||||
|
||||
// Statement action constants - these map to IAM policy actions
|
||||
const (
|
||||
StatementActionAdmin = "*"
|
||||
StatementActionWrite = "Put*"
|
||||
StatementActionWriteAcp = "PutBucketAcl"
|
||||
StatementActionRead = "Get*"
|
||||
StatementActionReadAcp = "GetBucketAcl"
|
||||
StatementActionList = "List*"
|
||||
StatementActionTagging = "Tagging*"
|
||||
StatementActionDelete = "DeleteBucket*"
|
||||
)
|
||||
|
||||
// Access key lengths
|
||||
const (
|
||||
AccessKeyIdLength = 21
|
||||
SecretAccessKeyLength = 42
|
||||
)
|
||||
|
||||
// Access key status values (AWS IAM compatible)
|
||||
const (
|
||||
AccessKeyStatusActive = "Active"
|
||||
AccessKeyStatusInactive = "Inactive"
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// Hash computes a SHA1 hash of the input string.
|
||||
func Hash(s *string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(*s))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically secure random string.
|
||||
// Uses crypto/rand for security-sensitive credential generation.
|
||||
func GenerateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive, got %d", length)
|
||||
}
|
||||
if charset == "" {
|
||||
return "", fmt.Errorf("charset must not be empty")
|
||||
}
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random index: %w", err)
|
||||
}
|
||||
b[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// GenerateAccessKeyId generates a new access key ID.
|
||||
func GenerateAccessKeyId() (string, error) {
|
||||
return GenerateRandomString(AccessKeyIdLength, CharsetUpper)
|
||||
}
|
||||
|
||||
// GenerateSecretAccessKey generates a new secret access key.
|
||||
func GenerateSecretAccessKey() (string, error) {
|
||||
return GenerateRandomString(SecretAccessKeyLength, Charset)
|
||||
}
|
||||
|
||||
// StringSlicesEqual compares two string slices for equality, ignoring order.
|
||||
// This is used instead of reflect.DeepEqual to avoid order-dependent comparisons.
|
||||
func StringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// Make copies to avoid modifying the originals
|
||||
aCopy := make([]string, len(a))
|
||||
bCopy := make([]string, len(b))
|
||||
copy(aCopy, a)
|
||||
copy(bCopy, b)
|
||||
sort.Strings(aCopy)
|
||||
sort.Strings(bCopy)
|
||||
for i := range aCopy {
|
||||
if aCopy[i] != bCopy[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MapToStatementAction converts a policy statement action to an S3 action constant.
|
||||
func MapToStatementAction(action string) string {
|
||||
switch action {
|
||||
case StatementActionAdmin:
|
||||
return s3_constants.ACTION_ADMIN
|
||||
case StatementActionWrite:
|
||||
return s3_constants.ACTION_WRITE
|
||||
case StatementActionWriteAcp:
|
||||
return s3_constants.ACTION_WRITE_ACP
|
||||
case StatementActionRead:
|
||||
return s3_constants.ACTION_READ
|
||||
case StatementActionReadAcp:
|
||||
return s3_constants.ACTION_READ_ACP
|
||||
case StatementActionList:
|
||||
return s3_constants.ACTION_LIST
|
||||
case StatementActionTagging:
|
||||
return s3_constants.ACTION_TAGGING
|
||||
case StatementActionDelete:
|
||||
return s3_constants.ACTION_DELETE_BUCKET
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MapToIdentitiesAction converts an S3 action constant to a policy statement action.
|
||||
func MapToIdentitiesAction(action string) string {
|
||||
switch action {
|
||||
case s3_constants.ACTION_ADMIN:
|
||||
return StatementActionAdmin
|
||||
case s3_constants.ACTION_WRITE:
|
||||
return StatementActionWrite
|
||||
case s3_constants.ACTION_WRITE_ACP:
|
||||
return StatementActionWriteAcp
|
||||
case s3_constants.ACTION_READ:
|
||||
return StatementActionRead
|
||||
case s3_constants.ACTION_READ_ACP:
|
||||
return StatementActionReadAcp
|
||||
case s3_constants.ACTION_LIST:
|
||||
return StatementActionList
|
||||
case s3_constants.ACTION_TAGGING:
|
||||
return StatementActionTagging
|
||||
case s3_constants.ACTION_DELETE_BUCKET:
|
||||
return StatementActionDelete
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MaskAccessKey masks an access key for logging, showing only the first 4 characters.
|
||||
func MaskAccessKey(accessKeyId string) string {
|
||||
if len(accessKeyId) > 4 {
|
||||
return accessKeyId[:4] + "***"
|
||||
}
|
||||
return accessKeyId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
input := "test"
|
||||
result := Hash(&input)
|
||||
assert.NotEmpty(t, result)
|
||||
assert.Len(t, result, 40) // SHA1 hex is 40 chars
|
||||
|
||||
// Same input should produce same hash
|
||||
result2 := Hash(&input)
|
||||
assert.Equal(t, result, result2)
|
||||
|
||||
// Different input should produce different hash
|
||||
different := "different"
|
||||
result3 := Hash(&different)
|
||||
assert.NotEqual(t, result, result3)
|
||||
}
|
||||
|
||||
func TestGenerateRandomString(t *testing.T) {
|
||||
// Valid generation
|
||||
result, err := GenerateRandomString(10, CharsetUpper)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result, 10)
|
||||
|
||||
// Different calls should produce different results (with high probability)
|
||||
result2, err := GenerateRandomString(10, CharsetUpper)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, result, result2)
|
||||
|
||||
// Invalid length
|
||||
_, err = GenerateRandomString(0, CharsetUpper)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = GenerateRandomString(-1, CharsetUpper)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Empty charset
|
||||
_, err = GenerateRandomString(10, "")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGenerateAccessKeyId(t *testing.T) {
|
||||
keyId, err := GenerateAccessKeyId()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, keyId, AccessKeyIdLength)
|
||||
}
|
||||
|
||||
func TestGenerateSecretAccessKey(t *testing.T) {
|
||||
secretKey, err := GenerateSecretAccessKey()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, secretKey, SecretAccessKeyLength)
|
||||
}
|
||||
|
||||
func TestStringSlicesEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
a []string
|
||||
b []string
|
||||
expected bool
|
||||
}{
|
||||
{[]string{"a", "b", "c"}, []string{"a", "b", "c"}, true},
|
||||
{[]string{"c", "b", "a"}, []string{"a", "b", "c"}, true}, // Order independent
|
||||
{[]string{"a", "b"}, []string{"a", "b", "c"}, false},
|
||||
{[]string{}, []string{}, true},
|
||||
{nil, nil, true},
|
||||
{[]string{"a"}, []string{"b"}, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := StringSlicesEqual(test.a, test.b)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToStatementAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{StatementActionAdmin, s3_constants.ACTION_ADMIN},
|
||||
{StatementActionWrite, s3_constants.ACTION_WRITE},
|
||||
{StatementActionRead, s3_constants.ACTION_READ},
|
||||
{StatementActionList, s3_constants.ACTION_LIST},
|
||||
{StatementActionDelete, s3_constants.ACTION_DELETE_BUCKET},
|
||||
{"unknown", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := MapToStatementAction(test.input)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToIdentitiesAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{s3_constants.ACTION_ADMIN, StatementActionAdmin},
|
||||
{s3_constants.ACTION_WRITE, StatementActionWrite},
|
||||
{s3_constants.ACTION_READ, StatementActionRead},
|
||||
{s3_constants.ACTION_LIST, StatementActionList},
|
||||
{s3_constants.ACTION_DELETE_BUCKET, StatementActionDelete},
|
||||
{"unknown", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := MapToIdentitiesAction(test.input)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskAccessKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"AKIAIOSFODNN7EXAMPLE", "AKIA***"},
|
||||
{"AKIA", "AKIA"},
|
||||
{"AKI", "AKI"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := MaskAccessKey(test.input)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
)
|
||||
|
||||
// CommonResponse is embedded in all IAM response types to provide RequestId.
|
||||
type CommonResponse struct {
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId"`
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
// SetRequestId sets a unique request ID based on current timestamp.
|
||||
func (r *CommonResponse) SetRequestId() {
|
||||
r.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ListUsersResponse is the response for ListUsers action.
|
||||
type ListUsersResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
|
||||
ListUsersResult struct {
|
||||
Users []*iam.User `xml:"Users>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListUsersResult"`
|
||||
}
|
||||
|
||||
// ListAccessKeysResponse is the response for ListAccessKeys action.
|
||||
type ListAccessKeysResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"`
|
||||
ListAccessKeysResult struct {
|
||||
AccessKeyMetadata []*iam.AccessKeyMetadata `xml:"AccessKeyMetadata>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListAccessKeysResult"`
|
||||
}
|
||||
|
||||
// DeleteAccessKeyResponse is the response for DeleteAccessKey action.
|
||||
type DeleteAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"`
|
||||
}
|
||||
|
||||
// CreatePolicyResponse is the response for CreatePolicy action.
|
||||
type CreatePolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"`
|
||||
CreatePolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"CreatePolicyResult"`
|
||||
}
|
||||
|
||||
// CreateUserResponse is the response for CreateUser action.
|
||||
type CreateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
|
||||
CreateUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"CreateUserResult"`
|
||||
}
|
||||
|
||||
// DeleteUserResponse is the response for DeleteUser action.
|
||||
type DeleteUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
|
||||
}
|
||||
|
||||
// GetUserResponse is the response for GetUser action.
|
||||
type GetUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
|
||||
GetUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"GetUserResult"`
|
||||
}
|
||||
|
||||
// UpdateUserResponse is the response for UpdateUser action.
|
||||
type UpdateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
|
||||
}
|
||||
|
||||
// CreateAccessKeyResponse is the response for CreateAccessKey action.
|
||||
type CreateAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"`
|
||||
CreateAccessKeyResult struct {
|
||||
AccessKey iam.AccessKey `xml:"AccessKey"`
|
||||
} `xml:"CreateAccessKeyResult"`
|
||||
}
|
||||
|
||||
// PutUserPolicyResponse is the response for PutUserPolicy action.
|
||||
type PutUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
|
||||
}
|
||||
|
||||
// DeleteUserPolicyResponse is the response for DeleteUserPolicy action.
|
||||
type DeleteUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
|
||||
}
|
||||
|
||||
// GetUserPolicyResponse is the response for GetUserPolicy action.
|
||||
type GetUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
|
||||
GetUserPolicyResult struct {
|
||||
UserName string `xml:"UserName"`
|
||||
PolicyName string `xml:"PolicyName"`
|
||||
PolicyDocument string `xml:"PolicyDocument"`
|
||||
} `xml:"GetUserPolicyResult"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the IAM error response format.
|
||||
type ErrorResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"`
|
||||
Error struct {
|
||||
iam.ErrorDetails
|
||||
Type string `xml:"Type"`
|
||||
} `xml:"Error"`
|
||||
}
|
||||
|
||||
// Error represents an IAM API error with code and underlying error.
|
||||
type Error struct {
|
||||
Code string
|
||||
Error error
|
||||
}
|
||||
|
||||
// Policies stores IAM policies (used for managed policy storage).
|
||||
type Policies struct {
|
||||
Policies map[string]interface{} `json:"policies"`
|
||||
}
|
||||
|
||||
// SetUserStatusResponse is the response for SetUserStatus action.
|
||||
// This is a SeaweedFS extension to enable/disable users without deleting them.
|
||||
type SetUserStatusResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ SetUserStatusResponse"`
|
||||
}
|
||||
|
||||
// UpdateAccessKeyResponse is the response for UpdateAccessKey action.
|
||||
type UpdateAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"`
|
||||
}
|
||||
@@ -1,115 +1,70 @@
|
||||
package iamapi
|
||||
|
||||
// This file provides IAM API handlers for the standalone IAM server.
|
||||
// Common IAM types and helpers are imported from the shared weed/iam package.
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
)
|
||||
|
||||
// Constants from shared package
|
||||
const (
|
||||
charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
charset = charsetUpper + "abcdefghijklmnopqrstuvwxyz/"
|
||||
policyDocumentVersion = "2012-10-17"
|
||||
StatementActionAdmin = "*"
|
||||
StatementActionWrite = "Put*"
|
||||
StatementActionWriteAcp = "PutBucketAcl"
|
||||
StatementActionRead = "Get*"
|
||||
StatementActionReadAcp = "GetBucketAcl"
|
||||
StatementActionList = "List*"
|
||||
StatementActionTagging = "Tagging*"
|
||||
StatementActionDelete = "DeleteBucket*"
|
||||
charsetUpper = iamlib.CharsetUpper
|
||||
charset = iamlib.Charset
|
||||
policyDocumentVersion = iamlib.PolicyDocumentVersion
|
||||
StatementActionAdmin = iamlib.StatementActionAdmin
|
||||
StatementActionWrite = iamlib.StatementActionWrite
|
||||
StatementActionWriteAcp = iamlib.StatementActionWriteAcp
|
||||
StatementActionRead = iamlib.StatementActionRead
|
||||
StatementActionReadAcp = iamlib.StatementActionReadAcp
|
||||
StatementActionList = iamlib.StatementActionList
|
||||
StatementActionTagging = iamlib.StatementActionTagging
|
||||
StatementActionDelete = iamlib.StatementActionDelete
|
||||
USER_DOES_NOT_EXIST = iamlib.UserDoesNotExist
|
||||
)
|
||||
|
||||
var (
|
||||
seededRand *rand.Rand = rand.New(
|
||||
rand.NewSource(time.Now().UnixNano()))
|
||||
policyDocuments = map[string]*policy_engine.PolicyDocument{}
|
||||
policyLock = sync.RWMutex{}
|
||||
)
|
||||
|
||||
// Helper function wrappers using shared package
|
||||
func MapToStatementAction(action string) string {
|
||||
switch action {
|
||||
case StatementActionAdmin:
|
||||
return s3_constants.ACTION_ADMIN
|
||||
case StatementActionWrite:
|
||||
return s3_constants.ACTION_WRITE
|
||||
case StatementActionWriteAcp:
|
||||
return s3_constants.ACTION_WRITE_ACP
|
||||
case StatementActionRead:
|
||||
return s3_constants.ACTION_READ
|
||||
case StatementActionReadAcp:
|
||||
return s3_constants.ACTION_READ_ACP
|
||||
case StatementActionList:
|
||||
return s3_constants.ACTION_LIST
|
||||
case StatementActionTagging:
|
||||
return s3_constants.ACTION_TAGGING
|
||||
case StatementActionDelete:
|
||||
return s3_constants.ACTION_DELETE_BUCKET
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return iamlib.MapToStatementAction(action)
|
||||
}
|
||||
|
||||
func MapToIdentitiesAction(action string) string {
|
||||
switch action {
|
||||
case s3_constants.ACTION_ADMIN:
|
||||
return StatementActionAdmin
|
||||
case s3_constants.ACTION_WRITE:
|
||||
return StatementActionWrite
|
||||
case s3_constants.ACTION_WRITE_ACP:
|
||||
return StatementActionWriteAcp
|
||||
case s3_constants.ACTION_READ:
|
||||
return StatementActionRead
|
||||
case s3_constants.ACTION_READ_ACP:
|
||||
return StatementActionReadAcp
|
||||
case s3_constants.ACTION_LIST:
|
||||
return StatementActionList
|
||||
case s3_constants.ACTION_TAGGING:
|
||||
return StatementActionTagging
|
||||
case s3_constants.ACTION_DELETE_BUCKET:
|
||||
return StatementActionDelete
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return iamlib.MapToIdentitiesAction(action)
|
||||
}
|
||||
|
||||
const (
|
||||
USER_DOES_NOT_EXIST = "the user with name %s cannot be found."
|
||||
)
|
||||
|
||||
type Policies struct {
|
||||
Policies map[string]policy_engine.PolicyDocument `json:"policies"`
|
||||
}
|
||||
|
||||
func Hash(s *string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(*s))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
return iamlib.Hash(s)
|
||||
}
|
||||
|
||||
func StringWithCharset(length int, charset string) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
func StringWithCharset(length int, charset string) (string, error) {
|
||||
return iamlib.GenerateRandomString(length, charset)
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
return iamlib.StringSlicesEqual(a, b)
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListUsersResponse) {
|
||||
@@ -199,8 +154,7 @@ func (iama *IamApiServer) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
resp.CreatePolicyResult.Policy.Arn = &arn
|
||||
resp.CreatePolicyResult.Policy.PolicyId = &policyId
|
||||
policies := Policies{}
|
||||
policyLock.Lock()
|
||||
defer policyLock.Unlock()
|
||||
// Note: Lock is already held by DoActions, no need to acquire here
|
||||
if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
@@ -273,7 +227,8 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
for resource, actions := range statements {
|
||||
isEqAction := false
|
||||
for i, statement := range policyDocument.Statement {
|
||||
if reflect.DeepEqual(statement.Action.Strings(), actions) {
|
||||
// Use order-independent comparison to avoid duplicates from different action orderings
|
||||
if stringSlicesEqual(statement.Action.Strings(), actions) {
|
||||
policyDocument.Statement[i].Resource = policy_engine.NewStringOrStringSlice(append(
|
||||
policyDocument.Statement[i].Resource.Strings(), resource)...)
|
||||
isEqAction = true
|
||||
@@ -300,11 +255,12 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp PutUserPolicyResponse, err *IamError) {
|
||||
// DeleteUserPolicy removes the inline policy from a user (clears their actions).
|
||||
func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteUserPolicyResponse, err *IamError) {
|
||||
userName := values.Get("UserName")
|
||||
for i, ident := range s3cfg.Identities {
|
||||
for _, ident := range s3cfg.Identities {
|
||||
if ident.Name == userName {
|
||||
s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
|
||||
ident.Actions = nil
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
@@ -348,11 +304,19 @@ func GetActions(policy *policy_engine.PolicyDocument) ([]string, error) {
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateAccessKeyResponse) {
|
||||
func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateAccessKeyResponse, iamErr *IamError) {
|
||||
userName := values.Get("UserName")
|
||||
status := iam.StatusTypeActive
|
||||
accessKeyId := StringWithCharset(21, charsetUpper)
|
||||
secretAccessKey := StringWithCharset(42, charset)
|
||||
|
||||
accessKeyId, err := StringWithCharset(21, charsetUpper)
|
||||
if err != nil {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
|
||||
}
|
||||
secretAccessKey, err := StringWithCharset(42, charset)
|
||||
if err != nil {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
|
||||
}
|
||||
|
||||
resp.CreateAccessKeyResult.AccessKey.AccessKeyId = &accessKeyId
|
||||
resp.CreateAccessKeyResult.AccessKey.SecretAccessKey = &secretAccessKey
|
||||
resp.CreateAccessKeyResult.AccessKey.UserName = &userName
|
||||
@@ -379,7 +343,7 @@ func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
|
||||
},
|
||||
)
|
||||
}
|
||||
return resp
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteAccessKeyResponse) {
|
||||
@@ -399,36 +363,60 @@ func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
|
||||
return resp
|
||||
}
|
||||
|
||||
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified
|
||||
// According to https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/create-access-key.html/
|
||||
// "If you do not specify a user name, IAM determines the user name implicitly based on the Amazon Web
|
||||
// Services access key ID signing the request."
|
||||
func handleImplicitUsername(r *http.Request, values url.Values) {
|
||||
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
|
||||
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
|
||||
// implicitly based on the Amazon Web Services access key ID signing the request."
|
||||
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
|
||||
// identity name in the credential store.
|
||||
func (iama *IamApiServer) handleImplicitUsername(r *http.Request, values url.Values) {
|
||||
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
|
||||
return
|
||||
}
|
||||
// get username who signs the request. For a typical Authorization:
|
||||
// "AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;
|
||||
// host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8",
|
||||
// the "test1" will be extracted as the username
|
||||
glog.V(4).Infof("Authorization field: %v", r.Header["Authorization"][0])
|
||||
// Log presence of auth header without exposing sensitive signature material
|
||||
glog.V(4).Infof("Authorization header present, extracting access key")
|
||||
// Parse AWS SigV4 Authorization header format:
|
||||
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
|
||||
s := strings.Split(r.Header["Authorization"][0], "Credential=")
|
||||
if len(s) < 2 {
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[1], ",")
|
||||
if len(s) < 2 {
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[0], "/")
|
||||
if len(s) < 5 {
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
userName := s[2]
|
||||
values.Set("UserName", userName)
|
||||
// s[0] is the AccessKeyId
|
||||
accessKeyId := s[0]
|
||||
if accessKeyId == "" {
|
||||
return
|
||||
}
|
||||
// Nil-guard: ensure iam is initialized before lookup
|
||||
if iama.iam == nil {
|
||||
glog.V(4).Infof("IAM not initialized, cannot look up access key")
|
||||
return
|
||||
}
|
||||
// Look up the identity by access key to get the username
|
||||
identity, _, found := iama.iam.LookupByAccessKey(accessKeyId)
|
||||
if !found {
|
||||
// Mask access key in logs - show only first 4 chars
|
||||
maskedKey := accessKeyId
|
||||
if len(accessKeyId) > 4 {
|
||||
maskedKey = accessKeyId[:4] + "***"
|
||||
}
|
||||
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
|
||||
return
|
||||
}
|
||||
values.Set("UserName", identity.Name)
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
// Lock to prevent concurrent read-modify-write race conditions
|
||||
policyLock.Lock()
|
||||
defer policyLock.Unlock()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
||||
return
|
||||
@@ -449,7 +437,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
response = iama.ListUsers(s3cfg, values)
|
||||
changed = false
|
||||
case "ListAccessKeys":
|
||||
handleImplicitUsername(r, values)
|
||||
iama.handleImplicitUsername(r, values)
|
||||
response = iama.ListAccessKeys(s3cfg, values)
|
||||
changed = false
|
||||
case "CreateUser":
|
||||
@@ -477,10 +465,15 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
case "CreateAccessKey":
|
||||
handleImplicitUsername(r, values)
|
||||
response = iama.CreateAccessKey(s3cfg, values)
|
||||
iama.handleImplicitUsername(r, values)
|
||||
response, iamError = iama.CreateAccessKey(s3cfg, values)
|
||||
if iamError != nil {
|
||||
glog.Errorf("CreateAccessKey: %+v", iamError.Error)
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
return
|
||||
}
|
||||
case "DeleteAccessKey":
|
||||
handleImplicitUsername(r, values)
|
||||
iama.handleImplicitUsername(r, values)
|
||||
response = iama.DeleteAccessKey(s3cfg, values)
|
||||
case "CreatePolicy":
|
||||
response, iamError = iama.CreatePolicy(s3cfg, values)
|
||||
@@ -489,6 +482,9 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
||||
return
|
||||
}
|
||||
// CreatePolicy persists the policy document via iama.s3ApiConfig.PutPolicies().
|
||||
// The `changed` flag is false because this does not modify the main s3cfg.Identities configuration.
|
||||
changed = false
|
||||
case "PutUserPolicy":
|
||||
var iamError *IamError
|
||||
response, iamError = iama.PutUserPolicy(s3cfg, values)
|
||||
@@ -525,6 +521,14 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
writeIamErrorResponse(w, r, &iamError)
|
||||
return
|
||||
}
|
||||
// Reload in-memory identity maps so subsequent LookupByAccessKey calls
|
||||
// can see newly created or deleted keys immediately
|
||||
if iama.iam != nil {
|
||||
if err := iama.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
|
||||
glog.Warningf("Failed to reload IAM configuration after mutation: %v", err)
|
||||
// Don't fail the request since the persistent save succeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, response)
|
||||
}
|
||||
|
||||
+21
-103
@@ -1,108 +1,26 @@
|
||||
package iamapi
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
// This file re-exports IAM response types from the shared weed/iam package
|
||||
// for backwards compatibility with existing code.
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
import (
|
||||
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
)
|
||||
|
||||
type CommonResponse struct {
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId"`
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
type ListUsersResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
|
||||
ListUsersResult struct {
|
||||
Users []*iam.User `xml:"Users>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListUsersResult"`
|
||||
}
|
||||
|
||||
type ListAccessKeysResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"`
|
||||
ListAccessKeysResult struct {
|
||||
AccessKeyMetadata []*iam.AccessKeyMetadata `xml:"AccessKeyMetadata>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListAccessKeysResult"`
|
||||
}
|
||||
|
||||
type DeleteAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"`
|
||||
}
|
||||
|
||||
type CreatePolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"`
|
||||
CreatePolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"CreatePolicyResult"`
|
||||
}
|
||||
|
||||
type CreateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
|
||||
CreateUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"CreateUserResult"`
|
||||
}
|
||||
|
||||
type DeleteUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
|
||||
}
|
||||
|
||||
type GetUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
|
||||
GetUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"GetUserResult"`
|
||||
}
|
||||
|
||||
type UpdateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
|
||||
}
|
||||
|
||||
type CreateAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"`
|
||||
CreateAccessKeyResult struct {
|
||||
AccessKey iam.AccessKey `xml:"AccessKey"`
|
||||
} `xml:"CreateAccessKeyResult"`
|
||||
}
|
||||
|
||||
type PutUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
|
||||
}
|
||||
|
||||
type GetUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
|
||||
GetUserPolicyResult struct {
|
||||
UserName string `xml:"UserName"`
|
||||
PolicyName string `xml:"PolicyName"`
|
||||
PolicyDocument string `xml:"PolicyDocument"`
|
||||
} `xml:"GetUserPolicyResult"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"`
|
||||
Error struct {
|
||||
iam.ErrorDetails
|
||||
Type string `xml:"Type"`
|
||||
} `xml:"Error"`
|
||||
}
|
||||
|
||||
func (r *CommonResponse) SetRequestId() {
|
||||
r.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
// Type aliases for IAM response types from shared package
|
||||
type (
|
||||
CommonResponse = iamlib.CommonResponse
|
||||
ListUsersResponse = iamlib.ListUsersResponse
|
||||
ListAccessKeysResponse = iamlib.ListAccessKeysResponse
|
||||
DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse
|
||||
CreatePolicyResponse = iamlib.CreatePolicyResponse
|
||||
CreateUserResponse = iamlib.CreateUserResponse
|
||||
DeleteUserResponse = iamlib.DeleteUserResponse
|
||||
GetUserResponse = iamlib.GetUserResponse
|
||||
UpdateUserResponse = iamlib.UpdateUserResponse
|
||||
CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse
|
||||
PutUserPolicyResponse = iamlib.PutUserPolicyResponse
|
||||
DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
|
||||
GetUserPolicyResponse = iamlib.GetUserPolicyResponse
|
||||
ErrorResponse = iamlib.ErrorResponse
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iamapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -244,22 +246,62 @@ func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorde
|
||||
}
|
||||
|
||||
func TestHandleImplicitUsername(t *testing.T) {
|
||||
// Create a mock IamApiServer with credential store
|
||||
// The handleImplicitUsername function now looks up the username from the
|
||||
// credential store based on AccessKeyId, not from the region field in the auth header.
|
||||
// Note: Using obviously fake access keys to avoid CI secret scanner false positives
|
||||
|
||||
// Create IAM directly as struct literal (same pattern as other tests)
|
||||
iam := &s3api.IdentityAccessManagement{}
|
||||
|
||||
// Load test credentials - map access key to identity name
|
||||
testConfig := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "testuser1",
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test config: %v", err)
|
||||
}
|
||||
|
||||
iama := &IamApiServer{
|
||||
iam: iam,
|
||||
}
|
||||
|
||||
var tests = []struct {
|
||||
r *http.Request
|
||||
values url.Values
|
||||
userName string
|
||||
}{
|
||||
// No authorization header - should not set username
|
||||
{&http.Request{}, url.Values{}, ""},
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, "test1"},
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request SignedHeaders=content-type;host;x-amz-date Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
|
||||
// Valid auth header with known access key - should look up and find "testuser1"
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
|
||||
// Malformed auth header (no Credential=) - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// Unknown access key - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
handleImplicitUsername(test.r, test.values)
|
||||
iama.handleImplicitUsername(test.r, test.values)
|
||||
if un := test.values.Get("UserName"); un != test.userName {
|
||||
t.Errorf("No.%d: Got: %v, Expected: %v", i, un, test.userName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalJSON(t *testing.T, v interface{}) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal JSON: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
+30
-17
@@ -1,11 +1,12 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type InodeToPath struct {
|
||||
@@ -41,7 +42,6 @@ func (ie *InodeEntry) removeOnePath(p util.FullPath) bool {
|
||||
ie.paths[x] = ie.paths[x+1]
|
||||
}
|
||||
ie.paths = ie.paths[0 : len(ie.paths)-1]
|
||||
ie.nlookup--
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -160,6 +160,10 @@ func (i *InodeToPath) MarkChildrenCached(fullpath util.FullPath) {
|
||||
return
|
||||
}
|
||||
path, found := i.inode2path[inode]
|
||||
if !found {
|
||||
glog.Warningf("MarkChildrenCached inode %d not found in inode2path for %v", inode, fullpath)
|
||||
return
|
||||
}
|
||||
path.isChildrenCached = true
|
||||
if i.cacheMetaTtlSec > 0 {
|
||||
path.cachedExpiresTime = time.Now().Add(i.cacheMetaTtlSec)
|
||||
@@ -230,9 +234,6 @@ func (i *InodeToPath) removePathFromInode2Path(inode uint64, path util.FullPath)
|
||||
if !ie.removeOnePath(path) {
|
||||
return
|
||||
}
|
||||
if len(ie.paths) == 0 {
|
||||
delete(i.inode2path, inode)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *InodeToPath) MovePath(sourcePath, targetPath util.FullPath) (sourceInode, targetInode uint64) {
|
||||
@@ -259,9 +260,6 @@ func (i *InodeToPath) MovePath(sourcePath, targetPath util.FullPath) (sourceInod
|
||||
}
|
||||
}
|
||||
entry.isChildrenCached = false
|
||||
if !targetFound {
|
||||
entry.nlookup++
|
||||
}
|
||||
} else {
|
||||
glog.Errorf("MovePath %s to %s: sourceInode %d not found", sourcePath, targetPath, sourceInode)
|
||||
}
|
||||
@@ -269,24 +267,39 @@ func (i *InodeToPath) MovePath(sourcePath, targetPath util.FullPath) (sourceInod
|
||||
}
|
||||
|
||||
func (i *InodeToPath) Forget(inode, nlookup uint64, onForgetDir func(dir util.FullPath)) {
|
||||
var dirPaths []util.FullPath
|
||||
callOnForgetDir := false
|
||||
|
||||
i.Lock()
|
||||
path, found := i.inode2path[inode]
|
||||
if found {
|
||||
path.nlookup -= nlookup
|
||||
if path.nlookup <= 0 {
|
||||
if nlookup > path.nlookup {
|
||||
glog.Errorf("kernel forget over-decrement: inode %d paths %v current %d forget %d", inode, path.paths, path.nlookup, nlookup)
|
||||
path.nlookup = 0
|
||||
} else {
|
||||
path.nlookup -= nlookup
|
||||
}
|
||||
glog.V(4).Infof("kernel forget: inode %d paths %v nlookup %d", inode, path.paths, path.nlookup)
|
||||
if path.nlookup == 0 {
|
||||
if path.isDirectory && onForgetDir != nil {
|
||||
dirPaths = append([]util.FullPath(nil), path.paths...)
|
||||
callOnForgetDir = true
|
||||
}
|
||||
for _, p := range path.paths {
|
||||
delete(i.path2inode, p)
|
||||
}
|
||||
delete(i.inode2path, inode)
|
||||
} else {
|
||||
glog.V(4).Infof("kernel forget but nlookup not zero: inode %d paths %v nlookup %d", inode, path.paths, path.nlookup)
|
||||
}
|
||||
} else {
|
||||
glog.Warningf("kernel forget but inode not found: inode %d", inode)
|
||||
}
|
||||
i.Unlock()
|
||||
if found {
|
||||
if path.isDirectory && path.nlookup <= 0 && onForgetDir != nil {
|
||||
path.isChildrenCached = false
|
||||
for _, p := range path.paths {
|
||||
onForgetDir(p)
|
||||
}
|
||||
|
||||
if callOnForgetDir {
|
||||
for _, p := range dirPaths {
|
||||
onForgetDir(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer/leveldb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -17,20 +19,24 @@ import (
|
||||
// e.g. fill fileId field for chunks
|
||||
|
||||
type MetaCache struct {
|
||||
root util.FullPath
|
||||
localStore filer.VirtualFilerStore
|
||||
root util.FullPath
|
||||
localStore filer.VirtualFilerStore
|
||||
leveldbStore *leveldb.LevelDBStore // direct reference for batch operations
|
||||
sync.RWMutex
|
||||
uidGidMapper *UidGidMapper
|
||||
markCachedFn func(fullpath util.FullPath)
|
||||
isCachedFn func(fullpath util.FullPath) bool
|
||||
invalidateFunc func(fullpath util.FullPath, entry *filer_pb.Entry)
|
||||
visitGroup singleflight.Group // deduplicates concurrent EnsureVisited calls for the same path
|
||||
}
|
||||
|
||||
func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPath,
|
||||
markCachedFn func(path util.FullPath), isCachedFn func(path util.FullPath) bool, invalidateFunc func(util.FullPath, *filer_pb.Entry)) *MetaCache {
|
||||
leveldbStore, virtualStore := openMetaStore(dbFolder)
|
||||
return &MetaCache{
|
||||
root: root,
|
||||
localStore: openMetaStore(dbFolder),
|
||||
localStore: virtualStore,
|
||||
leveldbStore: leveldbStore,
|
||||
markCachedFn: markCachedFn,
|
||||
isCachedFn: isCachedFn,
|
||||
uidGidMapper: uidGidMapper,
|
||||
@@ -40,7 +46,7 @@ func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPat
|
||||
}
|
||||
}
|
||||
|
||||
func openMetaStore(dbFolder string) filer.VirtualFilerStore {
|
||||
func openMetaStore(dbFolder string) (*leveldb.LevelDBStore, filer.VirtualFilerStore) {
|
||||
|
||||
os.RemoveAll(dbFolder)
|
||||
os.MkdirAll(dbFolder, 0755)
|
||||
@@ -54,7 +60,7 @@ func openMetaStore(dbFolder string) filer.VirtualFilerStore {
|
||||
glog.Fatalf("Failed to initialize metadata cache store for %s: %+v", store.GetName(), err)
|
||||
}
|
||||
|
||||
return filer.NewFilerStoreWrapper(store)
|
||||
return store, filer.NewFilerStoreWrapper(store)
|
||||
|
||||
}
|
||||
|
||||
@@ -68,6 +74,12 @@ func (mc *MetaCache) doInsertEntry(ctx context.Context, entry *filer.Entry) erro
|
||||
return mc.localStore.InsertEntry(ctx, entry)
|
||||
}
|
||||
|
||||
// doBatchInsertEntries inserts multiple entries using LevelDB's batch write.
|
||||
// This is more efficient than inserting entries one by one.
|
||||
func (mc *MetaCache) doBatchInsertEntries(ctx context.Context, entries []*filer.Entry) error {
|
||||
return mc.leveldbStore.BatchInsertEntries(ctx, entries)
|
||||
}
|
||||
|
||||
func (mc *MetaCache) AtomicUpdateEntryFromFiler(ctx context.Context, oldPath util.FullPath, newEntry *filer.Entry) error {
|
||||
mc.Lock()
|
||||
defer mc.Unlock()
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -11,22 +13,18 @@ import (
|
||||
)
|
||||
|
||||
func EnsureVisited(mc *MetaCache, client filer_pb.FilerClient, dirPath util.FullPath) error {
|
||||
|
||||
// Collect all uncached paths from target directory up to root
|
||||
var uncachedPaths []util.FullPath
|
||||
currentPath := dirPath
|
||||
|
||||
for {
|
||||
|
||||
// the directory children are already cached
|
||||
// so no need for this and upper directories
|
||||
// If this path is cached, all ancestors are also cached
|
||||
if mc.isCachedFn(currentPath) {
|
||||
return nil
|
||||
break
|
||||
}
|
||||
uncachedPaths = append(uncachedPaths, currentPath)
|
||||
|
||||
if err := doEnsureVisited(mc, client, currentPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// continue to parent directory
|
||||
// Continue to parent directory
|
||||
if currentPath != mc.root {
|
||||
parent, _ := currentPath.DirAndName()
|
||||
currentPath = util.FullPath(parent)
|
||||
@@ -35,33 +33,82 @@ func EnsureVisited(mc *MetaCache, client filer_pb.FilerClient, dirPath util.Full
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if len(uncachedPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch all uncached directories in parallel with context for cancellation
|
||||
// If one fetch fails, cancel the others to avoid unnecessary work
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
for _, p := range uncachedPaths {
|
||||
path := p // capture for closure
|
||||
g.Go(func() error {
|
||||
return doEnsureVisited(ctx, mc, client, path)
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func doEnsureVisited(mc *MetaCache, client filer_pb.FilerClient, path util.FullPath) error {
|
||||
// batchInsertSize is the number of entries to accumulate before flushing to LevelDB.
|
||||
// 100 provides a balance between memory usage (~100 Entry pointers) and write efficiency
|
||||
// (fewer disk syncs). Larger values reduce I/O overhead but increase memory and latency.
|
||||
const batchInsertSize = 100
|
||||
|
||||
glog.V(4).Infof("ReadDirAllEntries %s ...", path)
|
||||
func doEnsureVisited(ctx context.Context, mc *MetaCache, client filer_pb.FilerClient, path util.FullPath) error {
|
||||
// Use singleflight to deduplicate concurrent requests for the same path
|
||||
_, err, _ := mc.visitGroup.Do(string(path), func() (interface{}, error) {
|
||||
// Check for cancellation before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
err := util.Retry("ReadDirAllEntries", func() error {
|
||||
return filer_pb.ReadDirAllEntries(context.Background(), client, path, "", func(pbEntry *filer_pb.Entry, isLast bool) error {
|
||||
entry := filer.FromPbEntry(string(path), pbEntry)
|
||||
if IsHiddenSystemEntry(string(path), entry.Name()) {
|
||||
// Double-check if already cached (another goroutine may have completed)
|
||||
if mc.isCachedFn(path) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
glog.V(4).Infof("ReadDirAllEntries %s ...", path)
|
||||
|
||||
// Collect entries in batches for efficient LevelDB writes
|
||||
var batch []*filer.Entry
|
||||
|
||||
fetchErr := util.Retry("ReadDirAllEntries", func() error {
|
||||
batch = nil // Reset batch on retry, allow GC of previous entries
|
||||
return filer_pb.ReadDirAllEntries(ctx, client, path, "", func(pbEntry *filer_pb.Entry, isLast bool) error {
|
||||
entry := filer.FromPbEntry(string(path), pbEntry)
|
||||
if IsHiddenSystemEntry(string(path), entry.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
batch = append(batch, entry)
|
||||
|
||||
// Flush batch when it reaches the threshold
|
||||
// Don't rely on isLast here - hidden entries may cause early return
|
||||
if len(batch) >= batchInsertSize {
|
||||
// No lock needed - LevelDB Write() is thread-safe
|
||||
if err := mc.doBatchInsertEntries(ctx, batch); err != nil {
|
||||
return fmt.Errorf("batch insert for %s: %w", path, err)
|
||||
}
|
||||
// Create new slice to allow GC of flushed entries
|
||||
batch = make([]*filer.Entry, 0, batchInsertSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := mc.doInsertEntry(context.Background(), entry); err != nil {
|
||||
glog.V(0).Infof("read %s: %v", entry.FullPath, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("list %s: %v", path, err)
|
||||
} else {
|
||||
if fetchErr != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", path, fetchErr)
|
||||
}
|
||||
|
||||
// Flush any remaining entries in the batch
|
||||
if len(batch) > 0 {
|
||||
if err := mc.doBatchInsertEntries(ctx, batch); err != nil {
|
||||
return nil, fmt.Errorf("batch insert remaining for %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
mc.markCachedFn(path)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ type Option struct {
|
||||
Cipher bool // whether encrypt data on volume server
|
||||
UidGidMapper *meta_cache.UidGidMapper
|
||||
|
||||
// Periodic metadata flush interval in seconds (0 to disable)
|
||||
// This protects chunks from being purged by volume.fsck for long-running writes
|
||||
MetadataFlushSeconds int
|
||||
|
||||
// RDMA acceleration options
|
||||
RdmaEnabled bool
|
||||
RdmaSidecarAddr string
|
||||
@@ -214,6 +218,7 @@ func (wfs *WFS) StartBackgroundTasks() error {
|
||||
startTime := time.Now()
|
||||
go meta_cache.SubscribeMetaEvents(wfs.metaCache, wfs.signature, wfs, wfs.option.FilerMountRootPath, startTime.UnixNano(), follower)
|
||||
go wfs.loopCheckQuota()
|
||||
go wfs.loopFlushDirtyMetadata()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
@@ -16,6 +15,7 @@ type DirectoryHandleId uint64
|
||||
|
||||
const (
|
||||
directoryStreamBaseOffset = 2 // . & ..
|
||||
batchSize = 1000
|
||||
)
|
||||
|
||||
// DirectoryHandle represents an open directory handle.
|
||||
@@ -159,27 +159,30 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
}
|
||||
}
|
||||
|
||||
isEarlyTerminated := false
|
||||
dirPath, code := wfs.inodeToPath.GetPath(input.NodeId)
|
||||
if code != fuse.OK {
|
||||
return code
|
||||
}
|
||||
|
||||
var dirEntry fuse.DirEntry
|
||||
processEachEntryFn := func(entry *filer.Entry) bool {
|
||||
|
||||
// index is the position in entryStream, used to calculate the offset for next readdir
|
||||
processEachEntryFn := func(entry *filer.Entry, index int64) bool {
|
||||
dirEntry.Name = entry.Name()
|
||||
dirEntry.Mode = toSyscallMode(entry.Mode)
|
||||
inode := wfs.inodeToPath.Lookup(dirPath.Child(dirEntry.Name), entry.Crtime.Unix(), entry.IsDirectory(), len(entry.HardLinkId) > 0, entry.Inode, isPlusMode)
|
||||
inode := wfs.inodeToPath.Lookup(dirPath.Child(dirEntry.Name), entry.Crtime.Unix(), entry.IsDirectory(), len(entry.HardLinkId) > 0, entry.Inode, false)
|
||||
dirEntry.Ino = inode
|
||||
|
||||
// Set Off to the next offset so client can resume from correct position
|
||||
dirEntry.Off = dh.entryStreamOffset + uint64(index) + 1
|
||||
|
||||
if !isPlusMode {
|
||||
if !out.AddDirEntry(dirEntry) {
|
||||
isEarlyTerminated = true
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
entryOut := out.AddDirLookupEntry(dirEntry)
|
||||
if entryOut == nil {
|
||||
isEarlyTerminated = true
|
||||
return false
|
||||
}
|
||||
if fh, found := wfs.fhMap.FindFileHandle(inode); found {
|
||||
@@ -187,6 +190,7 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
entry = filer.FromPbEntry(string(dirPath), fh.GetEntry().GetEntry())
|
||||
}
|
||||
wfs.outputFilerEntry(entryOut, inode, entry)
|
||||
wfs.inodeToPath.Lookup(dirPath.Child(dirEntry.Name), entry.Crtime.Unix(), entry.IsDirectory(), len(entry.HardLinkId) > 0, entry.Inode, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -194,59 +198,90 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
if input.Offset < directoryStreamBaseOffset {
|
||||
if !isPlusMode {
|
||||
if input.Offset == 0 {
|
||||
out.AddDirEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: "."})
|
||||
out.AddDirEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: ".", Off: 1})
|
||||
}
|
||||
out.AddDirEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: ".."})
|
||||
out.AddDirEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: "..", Off: 2})
|
||||
} else {
|
||||
if input.Offset == 0 {
|
||||
out.AddDirLookupEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: "."})
|
||||
out.AddDirLookupEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: ".", Off: 1})
|
||||
}
|
||||
out.AddDirLookupEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: ".."})
|
||||
out.AddDirLookupEntry(fuse.DirEntry{Mode: fuse.S_IFDIR, Name: "..", Off: 2})
|
||||
}
|
||||
input.Offset = directoryStreamBaseOffset
|
||||
}
|
||||
|
||||
var lastEntryName string
|
||||
|
||||
// Read from cache first, then load next batch if needed
|
||||
if input.Offset >= dh.entryStreamOffset {
|
||||
// Handle case: new handle with non-zero offset but empty cache
|
||||
// This happens when NFS-Ganesha opens multiple directory handles
|
||||
if len(dh.entryStream) == 0 && input.Offset > dh.entryStreamOffset {
|
||||
skipCount := int64(input.Offset - dh.entryStreamOffset)
|
||||
|
||||
if err := meta_cache.EnsureVisited(wfs.metaCache, wfs, dirPath); err != nil {
|
||||
glog.Errorf("dir ReadDirAll %s: %v", dirPath, err)
|
||||
return fuse.EIO
|
||||
}
|
||||
|
||||
// Load entries from beginning to fill cache up to the requested offset
|
||||
loadErr := wfs.metaCache.ListDirectoryEntries(context.Background(), dirPath, "", false, skipCount+int64(batchSize), func(entry *filer.Entry) (bool, error) {
|
||||
dh.entryStream = append(dh.entryStream, entry)
|
||||
return true, nil
|
||||
})
|
||||
if loadErr != nil {
|
||||
glog.Errorf("list meta cache: %v", loadErr)
|
||||
return fuse.EIO
|
||||
}
|
||||
}
|
||||
|
||||
if input.Offset > dh.entryStreamOffset {
|
||||
entryPreviousIndex := (input.Offset - dh.entryStreamOffset) - 1
|
||||
if uint64(len(dh.entryStream)) > entryPreviousIndex {
|
||||
lastEntryName = dh.entryStream[entryPreviousIndex].Name()
|
||||
dh.entryStream = dh.entryStream[entryPreviousIndex:]
|
||||
dh.entryStreamOffset = input.Offset - 1
|
||||
}
|
||||
}
|
||||
entryCurrentIndex := input.Offset - dh.entryStreamOffset
|
||||
for uint64(len(dh.entryStream)) > entryCurrentIndex {
|
||||
|
||||
entryCurrentIndex := int64(input.Offset - dh.entryStreamOffset)
|
||||
for int64(len(dh.entryStream)) > entryCurrentIndex {
|
||||
entry := dh.entryStream[entryCurrentIndex]
|
||||
if processEachEntryFn(entry) {
|
||||
if processEachEntryFn(entry, entryCurrentIndex) {
|
||||
lastEntryName = entry.Name()
|
||||
entryCurrentIndex++
|
||||
} else {
|
||||
// early terminated
|
||||
return fuse.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := meta_cache.EnsureVisited(wfs.metaCache, wfs, dirPath); err != nil {
|
||||
glog.Errorf("dir ReadDirAll %s: %v", dirPath, err)
|
||||
return fuse.EIO
|
||||
}
|
||||
listErr := wfs.metaCache.ListDirectoryEntries(context.Background(), dirPath, lastEntryName, false, int64(math.MaxInt32), func(entry *filer.Entry) (bool, error) {
|
||||
dh.entryStream = append(dh.entryStream, entry)
|
||||
if !processEachEntryFn(entry) {
|
||||
return false, nil
|
||||
// Cache exhausted, load next batch
|
||||
if err := meta_cache.EnsureVisited(wfs.metaCache, wfs, dirPath); err != nil {
|
||||
glog.Errorf("dir ReadDirAll %s: %v", dirPath, err)
|
||||
return fuse.EIO
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
if listErr != nil {
|
||||
glog.Errorf("list meta cache: %v", listErr)
|
||||
return fuse.EIO
|
||||
}
|
||||
|
||||
if !isEarlyTerminated {
|
||||
dh.isFinished = true
|
||||
// Batch loading: fetch batchSize entries starting from lastEntryName
|
||||
loadedCount := 0
|
||||
bufferFull := false
|
||||
loadErr := wfs.metaCache.ListDirectoryEntries(context.Background(), dirPath, lastEntryName, false, int64(batchSize), func(entry *filer.Entry) (bool, error) {
|
||||
currentIndex := int64(len(dh.entryStream))
|
||||
dh.entryStream = append(dh.entryStream, entry)
|
||||
loadedCount++
|
||||
if !processEachEntryFn(entry, currentIndex) {
|
||||
bufferFull = true
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
if loadErr != nil {
|
||||
glog.Errorf("list meta cache: %v", loadErr)
|
||||
return fuse.EIO
|
||||
}
|
||||
|
||||
// Mark finished only when loading completed normally (not buffer full)
|
||||
// and we got fewer entries than requested
|
||||
if !bufferFull && loadedCount < batchSize {
|
||||
dh.isFinished = true
|
||||
}
|
||||
}
|
||||
|
||||
return fuse.OK
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// loopFlushDirtyMetadata periodically flushes dirty file metadata to the filer.
|
||||
// This protects newly uploaded chunks from being purged by volume.fsck orphan cleanup
|
||||
// for files that remain open for extended periods without being closed.
|
||||
//
|
||||
// The problem: When a file is opened and written to continuously, chunks are uploaded
|
||||
// to volume servers but the file metadata (containing chunk references) is only saved
|
||||
// to the filer on file close or fsync. If volume.fsck runs during this window, it may
|
||||
// identify these chunks as orphans (since they're not referenced in filer metadata)
|
||||
// and purge them.
|
||||
//
|
||||
// This background task periodically flushes metadata for open files, ensuring chunk
|
||||
// references are visible to volume.fsck even before files are closed.
|
||||
func (wfs *WFS) loopFlushDirtyMetadata() {
|
||||
if wfs.option.MetadataFlushSeconds <= 0 {
|
||||
glog.V(0).Infof("periodic metadata flush disabled")
|
||||
return
|
||||
}
|
||||
|
||||
flushInterval := time.Duration(wfs.option.MetadataFlushSeconds) * time.Second
|
||||
glog.V(0).Infof("periodic metadata flush enabled, interval: %v", flushInterval)
|
||||
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
wfs.flushAllDirtyMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
// flushAllDirtyMetadata iterates through all open file handles and flushes
|
||||
// metadata for files that have dirty metadata (chunks uploaded but not yet persisted).
|
||||
func (wfs *WFS) flushAllDirtyMetadata() {
|
||||
// Collect file handles with dirty metadata under a read lock
|
||||
var dirtyHandles []*FileHandle
|
||||
wfs.fhMap.RLock()
|
||||
for _, fh := range wfs.fhMap.inode2fh {
|
||||
if fh.dirtyMetadata {
|
||||
dirtyHandles = append(dirtyHandles, fh)
|
||||
}
|
||||
}
|
||||
wfs.fhMap.RUnlock()
|
||||
|
||||
if len(dirtyHandles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(3).Infof("flushing metadata for %d open files", len(dirtyHandles))
|
||||
|
||||
// Process dirty handles in parallel with limited concurrency
|
||||
var wg sync.WaitGroup
|
||||
concurrency := wfs.option.ConcurrentWriters
|
||||
if concurrency <= 0 {
|
||||
concurrency = 16
|
||||
}
|
||||
sem := make(chan struct{}, concurrency)
|
||||
|
||||
for _, fh := range dirtyHandles {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(handle *FileHandle) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if err := wfs.flushFileMetadata(handle); err != nil {
|
||||
glog.Warningf("failed to flush metadata for %s: %v", handle.FullPath(), err)
|
||||
}
|
||||
}(fh)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// flushFileMetadata flushes the current file metadata to the filer without
|
||||
// flushing dirty pages from memory. This updates chunk references in the filer
|
||||
// so volume.fsck can see them, while keeping data in the write buffer.
|
||||
func (wfs *WFS) flushFileMetadata(fh *FileHandle) error {
|
||||
// Acquire exclusive lock on the file handle
|
||||
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("flushMetadata", fh.fh, util.ExclusiveLock)
|
||||
defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
|
||||
|
||||
// Double-check dirty flag under lock
|
||||
if !fh.dirtyMetadata {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileFullPath := fh.FullPath()
|
||||
dir, name := fileFullPath.DirAndName()
|
||||
|
||||
glog.V(4).Infof("flushFileMetadata %s fh %d", fileFullPath, fh.fh)
|
||||
|
||||
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
entry := fh.GetEntry()
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
entry.Name = name
|
||||
|
||||
if entry.Attributes != nil {
|
||||
entry.Attributes.Mtime = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Get current chunks - these include chunks that have been uploaded
|
||||
// but not yet persisted to filer metadata
|
||||
chunks := entry.GetChunks()
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Separate manifest and non-manifest chunks
|
||||
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(chunks)
|
||||
|
||||
// Compact chunks to remove fully overlapped ones
|
||||
compactedChunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
|
||||
|
||||
// Try to create manifest chunks for large files
|
||||
compactedChunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), compactedChunks)
|
||||
if manifestErr != nil {
|
||||
glog.V(0).Infof("flushFileMetadata MaybeManifestize: %v", manifestErr)
|
||||
}
|
||||
|
||||
entry.Chunks = append(compactedChunks, manifestChunks...)
|
||||
|
||||
request := &filer_pb.CreateEntryRequest{
|
||||
Directory: string(dir),
|
||||
Entry: entry.GetEntry(),
|
||||
Signatures: []int32{wfs.signature},
|
||||
SkipCheckParentDirectory: true,
|
||||
}
|
||||
|
||||
wfs.mapPbIdFromLocalToFiler(request.Entry)
|
||||
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
|
||||
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update meta cache
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("update meta cache for %s: %w", fileFullPath, err)
|
||||
}
|
||||
|
||||
glog.V(3).Infof("flushed metadata for %s with %d chunks", fileFullPath, len(entry.GetChunks()))
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Note: We do NOT clear dirtyMetadata here because:
|
||||
// 1. There may still be dirty pages in the write buffer
|
||||
// 2. The file may receive more writes before close
|
||||
// 3. dirtyMetadata will be cleared on the final flush when the file is closed
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"google.golang.org/grpc"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
@@ -101,7 +102,7 @@ func LookupVolumeIds(masterFn GetMasterFn, grpcDialOption grpc.DialOption, vids
|
||||
GrpcPort: int(loc.GrpcPort),
|
||||
})
|
||||
}
|
||||
if vidLocations.Error != "" {
|
||||
if vidLocations.Error == "" {
|
||||
vc.Set(vidLocations.VolumeOrFileId, locations, 10*time.Minute)
|
||||
}
|
||||
ret[vidLocations.VolumeOrFileId] = &LookupResult{
|
||||
|
||||
@@ -110,7 +110,7 @@ func makeSubscribeMetadataFunc(option *MetadataFollowOption, processEventFn Proc
|
||||
|
||||
func AddOffsetFunc(processEventFn ProcessMetadataFunc, offsetInterval time.Duration, offsetFunc func(counter int64, offset int64) error) ProcessMetadataFunc {
|
||||
var counter int64
|
||||
var lastWriteTime time.Time
|
||||
var lastWriteTime = time.Now()
|
||||
return func(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
if err := processEventFn(resp); err != nil {
|
||||
return err
|
||||
|
||||
@@ -138,23 +138,25 @@ func requestIDUnaryInterceptor() grpc.UnaryServerInterceptor {
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (interface{}, error) {
|
||||
incomingMd, _ := metadata.FromIncomingContext(ctx)
|
||||
idList := incomingMd.Get(request_id.AmzRequestIDHeader)
|
||||
// Get request ID from incoming metadata
|
||||
var reqID string
|
||||
if len(idList) > 0 {
|
||||
reqID = idList[0]
|
||||
if incomingMd, ok := metadata.FromIncomingContext(ctx); ok {
|
||||
if idList := incomingMd.Get(request_id.AmzRequestIDHeader); len(idList) > 0 {
|
||||
reqID = idList[0]
|
||||
}
|
||||
}
|
||||
if reqID == "" {
|
||||
reqID = uuid.New().String()
|
||||
}
|
||||
|
||||
ctx = metadata.NewOutgoingContext(ctx,
|
||||
metadata.New(map[string]string{
|
||||
request_id.AmzRequestIDHeader: reqID,
|
||||
}))
|
||||
|
||||
// Store request ID in context for handlers to access
|
||||
ctx = request_id.Set(ctx, reqID)
|
||||
|
||||
// Also set outgoing context so handlers making downstream gRPC calls
|
||||
// will automatically propagate the request ID
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, request_id.AmzRequestIDHeader, reqID)
|
||||
|
||||
// Set trailer with request ID for response
|
||||
grpc.SetTrailer(ctx, metadata.Pairs(request_id.AmzRequestIDHeader, reqID))
|
||||
|
||||
return handler(ctx, req)
|
||||
@@ -187,8 +189,8 @@ func WithGrpcClient(streamingMode bool, signature int32, fn func(*grpc.ClientCon
|
||||
} else {
|
||||
ctx := context.Background()
|
||||
if signature != 0 {
|
||||
md := metadata.New(map[string]string{"sw-client-id": fmt.Sprintf("%d", signature)})
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
// Optimize: Use AppendToOutgoingContext instead of creating new map
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "sw-client-id", fmt.Sprintf("%d", signature))
|
||||
}
|
||||
grpcConnection, err := GrpcDial(ctx, address, waitForReady, opts...)
|
||||
if err != nil {
|
||||
|
||||
+2
-2
@@ -24,13 +24,13 @@ message Identity {
|
||||
repeated Credential credentials = 2;
|
||||
repeated string actions = 3;
|
||||
Account account = 4;
|
||||
bool disabled = 5; // User status: false = enabled (default), true = disabled
|
||||
}
|
||||
|
||||
message Credential {
|
||||
string access_key = 1;
|
||||
string secret_key = 2;
|
||||
// uint64 expiration = 3;
|
||||
// bool is_disabled = 4;
|
||||
string status = 3; // Access key status: "Active" or "Inactive"
|
||||
}
|
||||
|
||||
message Account {
|
||||
|
||||
@@ -79,6 +79,7 @@ type Identity struct {
|
||||
Credentials []*Credential `protobuf:"bytes,2,rep,name=credentials,proto3" json:"credentials,omitempty"`
|
||||
Actions []string `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"`
|
||||
Account *Account `protobuf:"bytes,4,opt,name=account,proto3" json:"account,omitempty"`
|
||||
Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -141,10 +142,18 @@ func (x *Identity) GetAccount() *Account {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Identity) GetDisabled() bool {
|
||||
if x != nil {
|
||||
return x.Disabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccessKey string `protobuf:"bytes,1,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"`
|
||||
SecretKey string `protobuf:"bytes,2,opt,name=secret_key,json=secretKey,proto3" json:"secret_key,omitempty"`
|
||||
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // Access key status: "Active" or "Inactive"
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -193,6 +202,13 @@ func (x *Credential) GetSecretKey() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Credential) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -262,18 +278,20 @@ const file_iam_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" +
|
||||
"identities\x12+\n" +
|
||||
"\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\"\x99\x01\n" +
|
||||
"\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\"\xb5\x01\n" +
|
||||
"\bIdentity\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x124\n" +
|
||||
"\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" +
|
||||
"\aactions\x18\x03 \x03(\tR\aactions\x12)\n" +
|
||||
"\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\"J\n" +
|
||||
"\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\x12\x1a\n" +
|
||||
"\bdisabled\x18\x05 \x01(\bR\bdisabled\"b\n" +
|
||||
"\n" +
|
||||
"Credential\x12\x1d\n" +
|
||||
"\n" +
|
||||
"access_key\x18\x01 \x01(\tR\taccessKey\x12\x1d\n" +
|
||||
"\n" +
|
||||
"secret_key\x18\x02 \x01(\tR\tsecretKey\"a\n" +
|
||||
"secret_key\x18\x02 \x01(\tR\tsecretKey\x12\x16\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\"a\n" +
|
||||
"\aAccount\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12!\n" +
|
||||
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12#\n" +
|
||||
|
||||
+103
-11
@@ -66,6 +66,7 @@ type Identity struct {
|
||||
Credentials []*Credential
|
||||
Actions []Action
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
Disabled bool // User status: false = enabled (default), true = disabled
|
||||
}
|
||||
|
||||
// Account represents a system user, a system user can
|
||||
@@ -101,6 +102,7 @@ var (
|
||||
type Credential struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Status string // Access key status: "Active" or "Inactive" (empty treated as "Active")
|
||||
}
|
||||
|
||||
// "Permission": "FULL_CONTROL"|"WRITE"|"WRITE_ACP"|"READ"|"READ_ACP"
|
||||
@@ -318,12 +320,13 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
emailAccount[AccountAnonymous.EmailAddress] = &AccountAnonymous
|
||||
}
|
||||
for _, ident := range config.Identities {
|
||||
glog.V(3).Infof("loading identity %s", ident.Name)
|
||||
glog.V(3).Infof("loading identity %s (disabled=%v)", ident.Name, ident.Disabled)
|
||||
t := &Identity{
|
||||
Name: ident.Name,
|
||||
Credentials: nil,
|
||||
Actions: nil,
|
||||
PrincipalArn: generatePrincipalArn(ident.Name),
|
||||
Disabled: ident.Disabled, // false (default) = enabled, true = disabled
|
||||
}
|
||||
switch {
|
||||
case ident.Name == AccountAnonymous.Id:
|
||||
@@ -347,6 +350,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
|
||||
t.Credentials = append(t.Credentials, &Credential{
|
||||
AccessKey: cred.AccessKey,
|
||||
SecretKey: cred.SecretKey,
|
||||
Status: cred.Status, // Load access key status
|
||||
})
|
||||
accessKeyIdent[cred.AccessKey] = t
|
||||
}
|
||||
@@ -405,8 +409,19 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi
|
||||
truncatedKey, len(accessKey), len(iam.accessKeyIdent))
|
||||
|
||||
if ident, ok := iam.accessKeyIdent[accessKey]; ok {
|
||||
// Check if user is disabled
|
||||
if ident.Disabled {
|
||||
glog.V(2).Infof("User %s is disabled, rejecting access key %s", ident.Name, truncatedKey)
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
for _, credential := range ident.Credentials {
|
||||
if credential.AccessKey == accessKey {
|
||||
// Check if access key is inactive (empty Status treated as Active for backward compatibility)
|
||||
if credential.Status == iamAccessKeyStatusInactive {
|
||||
glog.V(2).Infof("Access key %s for identity %s is inactive", truncatedKey, ident.Name)
|
||||
return nil, nil, false
|
||||
}
|
||||
glog.V(2).Infof("Found access key %s for identity %s", truncatedKey, ident.Name)
|
||||
return ident, credential, true
|
||||
}
|
||||
@@ -427,6 +442,16 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// LookupByAccessKey is an exported wrapper for lookupByAccessKey.
|
||||
// It returns the identity and credential associated with the given access key.
|
||||
//
|
||||
// WARNING: The returned pointers reference internal data structures.
|
||||
// Callers MUST NOT modify the returned Identity or Credential objects.
|
||||
// If mutation is needed, make a copy first.
|
||||
func (iam *IdentityAccessManagement) LookupByAccessKey(accessKey string) (identity *Identity, cred *Credential, found bool) {
|
||||
return iam.lookupByAccessKey(accessKey)
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) lookupAnonymous() (identity *Identity, found bool) {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
@@ -582,8 +607,10 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action)
|
||||
// - No policy or indeterminate → fall through to IAM checks
|
||||
if iam.policyEngine != nil && bucket != "" {
|
||||
principal := buildPrincipalARN(identity)
|
||||
// Use context-aware policy evaluation to get the correct S3 action
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicyWithContext(bucket, object, string(action), principal, r)
|
||||
// Phase 1: Evaluate bucket policy without object entry.
|
||||
// Tag-based conditions (s3:ExistingObjectTag) are re-checked by handlers
|
||||
// after fetching the entry, which is the Phase 2 check.
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
@@ -608,19 +635,19 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action)
|
||||
}
|
||||
|
||||
// Only check IAM if bucket policy didn't explicitly allow
|
||||
// This ensures bucket policies can independently grant access (AWS semantics)
|
||||
if !policyAllows {
|
||||
// Use enhanced IAM authorization if available, otherwise fall back to legacy authorization
|
||||
if iam.iamIntegration != nil {
|
||||
// Always use IAM when available for unified authorization
|
||||
// Traditional identities (with Actions from -s3.config) use legacy auth,
|
||||
// JWT/STS identities (no Actions) use IAM authorization
|
||||
if len(identity.Actions) > 0 {
|
||||
if !identity.canDo(action, bucket, object) {
|
||||
return identity, s3err.ErrAccessDenied
|
||||
}
|
||||
} else if iam.iamIntegration != nil {
|
||||
if errCode := iam.authorizeWithIAM(r, identity, action, bucket, object); errCode != s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
} else {
|
||||
// Fall back to existing authorization when IAM is not configured
|
||||
if !identity.canDo(action, bucket, object) {
|
||||
return identity, s3err.ErrAccessDenied
|
||||
}
|
||||
return identity, s3err.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -631,6 +658,66 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action)
|
||||
|
||||
}
|
||||
|
||||
// AuthSignatureOnly performs only signature verification without any authorization checks.
|
||||
// This is used for IAM API operations where authorization is handled separately based on
|
||||
// the specific IAM action (e.g., self-service vs admin operations).
|
||||
// Returns the authenticated identity and any signature verification error.
|
||||
func (iam *IdentityAccessManagement) AuthSignatureOnly(r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
var identity *Identity
|
||||
var s3Err s3err.ErrorCode
|
||||
var authType string
|
||||
switch getRequestAuthType(r) {
|
||||
case authTypeUnknown:
|
||||
glog.V(3).Infof("unknown auth type")
|
||||
r.Header.Set(s3_constants.AmzAuthType, "Unknown")
|
||||
return identity, s3err.ErrAccessDenied
|
||||
case authTypePresignedV2, authTypeSignedV2:
|
||||
glog.V(3).Infof("v2 auth type")
|
||||
identity, s3Err = iam.isReqAuthenticatedV2(r)
|
||||
authType = "SigV2"
|
||||
case authTypeStreamingSigned, authTypeSigned, authTypePresigned:
|
||||
glog.V(3).Infof("v4 auth type")
|
||||
identity, s3Err = iam.reqSignatureV4Verify(r)
|
||||
authType = "SigV4"
|
||||
case authTypePostPolicy:
|
||||
glog.V(3).Infof("post policy auth type")
|
||||
r.Header.Set(s3_constants.AmzAuthType, "PostPolicy")
|
||||
return identity, s3err.ErrNone
|
||||
case authTypeStreamingUnsigned:
|
||||
glog.V(3).Infof("unsigned streaming upload")
|
||||
return identity, s3err.ErrNone
|
||||
case authTypeJWT:
|
||||
glog.V(3).Infof("jwt auth type detected, iamIntegration != nil? %t", iam.iamIntegration != nil)
|
||||
r.Header.Set(s3_constants.AmzAuthType, "Jwt")
|
||||
if iam.iamIntegration != nil {
|
||||
identity, s3Err = iam.authenticateJWTWithIAM(r)
|
||||
authType = "Jwt"
|
||||
} else {
|
||||
glog.V(2).Infof("IAM integration is nil, returning ErrNotImplemented")
|
||||
return identity, s3err.ErrNotImplemented
|
||||
}
|
||||
case authTypeAnonymous:
|
||||
// Anonymous users cannot use IAM API
|
||||
return identity, s3err.ErrAccessDenied
|
||||
default:
|
||||
return identity, s3err.ErrNotImplemented
|
||||
}
|
||||
|
||||
if len(authType) > 0 {
|
||||
r.Header.Set(s3_constants.AmzAuthType, authType)
|
||||
}
|
||||
if s3Err != s3err.ErrNone {
|
||||
return identity, s3Err
|
||||
}
|
||||
|
||||
// Set account ID header for downstream handlers
|
||||
if identity != nil && identity.Account != nil {
|
||||
r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id)
|
||||
}
|
||||
|
||||
return identity, s3err.ErrNone
|
||||
}
|
||||
|
||||
func (identity *Identity) canDo(action Action, bucket string, objectKey string) bool {
|
||||
if identity.isAdmin() {
|
||||
return true
|
||||
@@ -769,6 +856,11 @@ func (iam *IdentityAccessManagement) SetIAMIntegration(integration *S3IAMIntegra
|
||||
iam.m.Lock()
|
||||
defer iam.m.Unlock()
|
||||
iam.iamIntegration = integration
|
||||
// When IAM integration is configured, authentication must be enabled
|
||||
// to ensure requests go through proper auth checks
|
||||
if integration != nil {
|
||||
iam.isAuthEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// authenticateJWTWithIAM authenticates JWT tokens using the IAM integration
|
||||
|
||||
@@ -190,7 +190,7 @@ func mustNewRequest(method string, urlStr string, contentLength int64, body io.R
|
||||
// is signed with AWS Signature V4, fails if not able to do so.
|
||||
func mustNewSignedRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
|
||||
req := mustNewRequest(method, urlStr, contentLength, body, t)
|
||||
cred := &Credential{"access_key_1", "secret_key_1"}
|
||||
cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"}
|
||||
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
|
||||
t.Fatalf("Unable to initialized new signed http request %s", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func mustNewSignedRequest(method string, urlStr string, contentLength int64, bod
|
||||
// is presigned with AWS Signature V4, fails if not able to do so.
|
||||
func mustNewPresignedRequest(iam *IdentityAccessManagement, method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
|
||||
req := mustNewRequest(method, urlStr, contentLength, body, t)
|
||||
cred := &Credential{"access_key_1", "secret_key_1"}
|
||||
cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"}
|
||||
if err := preSignV4(iam, req, cred.AccessKey, cred.SecretKey, int64(10*time.Minute.Seconds())); err != nil {
|
||||
t.Fatalf("Unable to initialized new signed http request %s", err)
|
||||
}
|
||||
|
||||
@@ -263,6 +263,49 @@ func TestMatchesOrigin(t *testing.T) {
|
||||
origin: "http://other.com",
|
||||
want: true,
|
||||
},
|
||||
// HTTPS test cases
|
||||
{
|
||||
name: "https exact match",
|
||||
allowedOrigins: []string{"https://example.com"},
|
||||
origin: "https://example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https no match",
|
||||
allowedOrigins: []string{"https://example.com"},
|
||||
origin: "https://other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain match",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://api.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain no match - base domain",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain no match - different domain",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://api.other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "protocol mismatch - http pattern https origin",
|
||||
allowedOrigins: []string{"http://*.example.com"},
|
||||
origin: "https://api.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "protocol mismatch - https pattern http origin",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "http://api.example.com",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -480,7 +523,7 @@ func TestApplyHeaders(t *testing.T) {
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Expose-Headers": "ETag",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
"Vary": "Origin",
|
||||
"Vary": "Origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -494,7 +537,7 @@ func TestApplyHeaders(t *testing.T) {
|
||||
"Access-Control-Allow-Origin": "http://example.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
"Vary": "Origin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -552,8 +552,8 @@ func (s3a *S3ApiServer) completeMultipartUpload(r *http.Request, input *s3.Compl
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) getEntryNameAndDir(input *s3.CompleteMultipartUploadInput) (string, string) {
|
||||
entryName := filepath.Base(*input.Key)
|
||||
dirName := filepath.ToSlash(filepath.Dir(*input.Key))
|
||||
entryName := path.Base(*input.Key)
|
||||
dirName := path.Dir(*input.Key)
|
||||
if dirName == "." {
|
||||
dirName = ""
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitiateMultipartUploadResult(t *testing.T) {
|
||||
@@ -74,3 +75,60 @@ func Test_parsePartNumber(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryNameAndDir(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
option: &S3ApiServerOption{
|
||||
BucketsPath: "/buckets",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bucket string
|
||||
key string
|
||||
expectedName string
|
||||
expectedDirEnd string // We check the suffix since dir includes BucketsPath
|
||||
}{
|
||||
{
|
||||
name: "simple file at root",
|
||||
bucket: "test-bucket",
|
||||
key: "/file.txt",
|
||||
expectedName: "file.txt",
|
||||
expectedDirEnd: "/buckets/test-bucket",
|
||||
},
|
||||
{
|
||||
name: "file in subdirectory",
|
||||
bucket: "test-bucket",
|
||||
key: "/folder/file.txt",
|
||||
expectedName: "file.txt",
|
||||
expectedDirEnd: "/buckets/test-bucket/folder",
|
||||
},
|
||||
{
|
||||
name: "file in nested subdirectory",
|
||||
bucket: "test-bucket",
|
||||
key: "/folder/subfolder/file.txt",
|
||||
expectedName: "file.txt",
|
||||
expectedDirEnd: "/buckets/test-bucket/folder/subfolder",
|
||||
},
|
||||
{
|
||||
name: "key without leading slash",
|
||||
bucket: "test-bucket",
|
||||
key: "folder/file.txt",
|
||||
expectedName: "file.txt",
|
||||
expectedDirEnd: "/buckets/test-bucket/folder",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := &s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(tt.bucket),
|
||||
Key: aws.String(tt.key),
|
||||
}
|
||||
entryName, dirName := s3a.getEntryNameAndDir(input)
|
||||
assert.Equal(t, tt.expectedName, entryName, "entry name mismatch")
|
||||
assert.Equal(t, tt.expectedDirEnd, dirName, "directory mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_objectlock"
|
||||
)
|
||||
|
||||
// ObjectLockUtils provides shared utilities for Object Lock configuration
|
||||
@@ -361,3 +363,28 @@ func validateDefaultRetention(retention *DefaultRetention) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// SHARED OBJECT LOCK CHECKING FUNCTIONS
|
||||
// ====================================================================
|
||||
// These functions delegate to s3_objectlock package to avoid code duplication.
|
||||
// They are kept here for backward compatibility with existing callers.
|
||||
|
||||
// EntryHasActiveLock checks if an entry has an active retention or legal hold
|
||||
// Delegates to s3_objectlock.EntryHasActiveLock
|
||||
func EntryHasActiveLock(entry *filer_pb.Entry, currentTime time.Time) bool {
|
||||
return s3_objectlock.EntryHasActiveLock(entry, currentTime)
|
||||
}
|
||||
|
||||
// HasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold
|
||||
// Delegates to s3_objectlock.HasObjectsWithActiveLocks
|
||||
func HasObjectsWithActiveLocks(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketPath string) (bool, error) {
|
||||
return s3_objectlock.HasObjectsWithActiveLocks(ctx, client, bucketPath)
|
||||
}
|
||||
|
||||
// CheckBucketForLockedObjects is a unified function that checks if a bucket has Object Lock enabled
|
||||
// and if so, scans for objects with active locks.
|
||||
// Delegates to s3_objectlock.CheckBucketForLockedObjects
|
||||
func CheckBucketForLockedObjects(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketsPath, bucketName string) error {
|
||||
return s3_objectlock.CheckBucketForLockedObjects(ctx, client, bucketsPath, bucketName)
|
||||
}
|
||||
|
||||
@@ -135,8 +135,70 @@ Standard AWS condition keys are supported:
|
||||
- `aws:UserAgent` - Client user agent
|
||||
- `s3:x-amz-acl` - Requested ACL
|
||||
- `s3:VersionId` - Object version ID
|
||||
- `s3:ExistingObjectTag/<tag-key>` - Value of an existing object tag (see example below)
|
||||
- And many more...
|
||||
|
||||
### 5. Object Tag-Based Access Control
|
||||
|
||||
You can control access based on object tags using `s3:ExistingObjectTag/<tag-key>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/status": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This allows anonymous access only to objects that have a tag `status=public`.
|
||||
|
||||
**Deny access to confidential objects:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/classification": ["confidential", "secret"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Operations for Tag-Based Conditions:**
|
||||
|
||||
Tag-based conditions (`s3:ExistingObjectTag/<key>`) are evaluated for the following operations:
|
||||
- `s3:GetObject` (GET object)
|
||||
- `s3:GetObjectVersion` (GET object with versionId)
|
||||
- `s3:HeadObject` (HEAD object)
|
||||
|
||||
Note: For these conditions to be evaluated, the object must exist and the policy engine re-checks access after fetching the object metadata.
|
||||
|
||||
## Policy Evaluation
|
||||
|
||||
### Evaluation Order (AWS-Compatible)
|
||||
@@ -270,10 +332,39 @@ go test -v -run TestPolicyValidation
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ **Full backward compatibility** with existing `identities.json`
|
||||
- ✅ **AWS S3 API compatibility** for bucket policies
|
||||
- ✅ **Standard condition operators** and keys
|
||||
- ✅ **Proper evaluation precedence** (Deny > Allow > Default Deny)
|
||||
- ✅ **Performance optimized** with caching and compiled patterns
|
||||
- Full backward compatibility with existing `identities.json`
|
||||
- AWS S3 API compatibility for bucket policies
|
||||
- Standard condition operators and keys
|
||||
- Proper evaluation precedence (Deny > Allow > Default Deny)
|
||||
- Performance optimized with caching and compiled patterns
|
||||
|
||||
The policy engine provides a seamless upgrade path from SeaweedFS's existing simple IAM system to full AWS S3-compatible policies, giving you the best of both worlds: simplicity for basic use cases and power for complex enterprise scenarios.
|
||||
The policy engine provides a seamless upgrade path from SeaweedFS's existing simple IAM system to full AWS S3-compatible policies, giving you the best of both worlds: simplicity for basic use cases and power for complex enterprise scenarios.
|
||||
|
||||
## Feature Status
|
||||
|
||||
### Implemented
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Bucket Policies | Full AWS S3-compatible bucket policies |
|
||||
| Condition Operators | StringEquals, IpAddress, Bool, DateGreaterThan, etc. |
|
||||
| `aws:SourceIp` | IP-based access control with CIDR support |
|
||||
| `aws:SecureTransport` | Require HTTPS |
|
||||
| `aws:CurrentTime` | Time-based access control |
|
||||
| `s3:ExistingObjectTag/<key>` | Tag-based access control for existing objects |
|
||||
| Wildcard Patterns | Support for `*` and `?` in actions and resources |
|
||||
| Principal Matching | `*`, account IDs, and user ARNs |
|
||||
|
||||
### Planned
|
||||
|
||||
| Feature | GitHub Issue |
|
||||
|---------|--------------|
|
||||
| `s3:RequestObjectTag/<key>` | For tag conditions on PUT requests |
|
||||
| `s3:RequestObjectTagKeys` | Check which tag keys are in request |
|
||||
| `s3:x-amz-content-sha256` | Content hash condition |
|
||||
| `s3:x-amz-server-side-encryption` | SSE condition |
|
||||
| `s3:x-amz-storage-class` | Storage class condition |
|
||||
| Cross-account access | Access across different accounts |
|
||||
| VPC Endpoint policies | Network-level policies |
|
||||
|
||||
For feature requests or to track progress, see the [GitHub Issues](https://github.com/seaweedfs/seaweedfs/issues).
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// LRUNode represents a node in the doubly-linked list for efficient LRU operations
|
||||
@@ -705,8 +706,36 @@ func GetConditionEvaluator(operator string) (ConditionEvaluator, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ExistingObjectTagPrefix is the prefix for S3 policy condition keys
|
||||
const ExistingObjectTagPrefix = "s3:ExistingObjectTag/"
|
||||
|
||||
// getConditionContextValue resolves the value(s) for a condition key.
|
||||
// For s3:ExistingObjectTag/<key> conditions, it looks up the tag in objectEntry.
|
||||
// For other condition keys, it looks up the value in contextValues.
|
||||
func getConditionContextValue(key string, contextValues map[string][]string, objectEntry map[string][]byte) []string {
|
||||
if strings.HasPrefix(key, ExistingObjectTagPrefix) {
|
||||
tagKey := key[len(ExistingObjectTagPrefix):]
|
||||
if tagKey == "" {
|
||||
return []string{} // Invalid: empty tag key
|
||||
}
|
||||
metadataKey := s3_constants.AmzObjectTaggingPrefix + tagKey
|
||||
if objectEntry != nil {
|
||||
if tagValue, exists := objectEntry[metadataKey]; exists {
|
||||
return []string{string(tagValue)}
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if vals, exists := contextValues[key]; exists {
|
||||
return vals
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// EvaluateConditions evaluates all conditions in a policy statement
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string) bool {
|
||||
// objectEntry is the object's metadata from entry.Extended (can be nil)
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true // No conditions means always true
|
||||
}
|
||||
@@ -719,11 +748,7 @@ func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]
|
||||
}
|
||||
|
||||
for key, value := range conditionMap {
|
||||
contextVals, exists := contextValues[key]
|
||||
if !exists {
|
||||
contextVals = []string{}
|
||||
}
|
||||
|
||||
contextVals := getConditionContextValue(key, contextValues, objectEntry)
|
||||
if !conditionEvaluator.Evaluate(value.Strings(), contextVals) {
|
||||
return false // If any condition fails, the whole condition block fails
|
||||
}
|
||||
@@ -734,7 +759,8 @@ func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]
|
||||
}
|
||||
|
||||
// EvaluateConditionsLegacy evaluates conditions using the old interface{} format for backward compatibility
|
||||
func EvaluateConditionsLegacy(conditions map[string]interface{}, contextValues map[string][]string) bool {
|
||||
// objectEntry is the object's metadata from entry.Extended (can be nil)
|
||||
func EvaluateConditionsLegacy(conditions map[string]interface{}, contextValues map[string][]string, objectEntry map[string][]byte) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true // No conditions means always true
|
||||
}
|
||||
@@ -753,11 +779,7 @@ func EvaluateConditionsLegacy(conditions map[string]interface{}, contextValues m
|
||||
}
|
||||
|
||||
for key, value := range conditionMapTyped {
|
||||
contextVals, exists := contextValues[key]
|
||||
if !exists {
|
||||
contextVals = []string{}
|
||||
}
|
||||
|
||||
contextVals := getConditionContextValue(key, contextValues, objectEntry)
|
||||
if !conditionEvaluator.Evaluate(value, contextVals) {
|
||||
return false // If any condition fails, the whole condition block fails
|
||||
}
|
||||
|
||||
@@ -91,6 +91,14 @@ func (engine *PolicyEngine) DeleteBucketPolicy(bucketName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasPolicyForBucket checks if a bucket has a policy configured
|
||||
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
_, exists := engine.contexts[bucketName]
|
||||
return exists
|
||||
}
|
||||
|
||||
// EvaluatePolicy evaluates a policy for the given arguments
|
||||
func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
||||
engine.mutex.RLock()
|
||||
@@ -154,7 +162,7 @@ func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *Pol
|
||||
|
||||
// Check conditions
|
||||
if len(stmt.Statement.Condition) > 0 {
|
||||
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions) {
|
||||
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -201,10 +209,8 @@ func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
||||
values["aws:Referer"] = []string{referer}
|
||||
}
|
||||
|
||||
// S3 object-level conditions
|
||||
if r.Method == "GET" || r.Method == "HEAD" {
|
||||
values["s3:ExistingObjectTag"] = extractObjectTags(r)
|
||||
}
|
||||
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
|
||||
// passed to EvaluatePolicy, not extracted from the request.
|
||||
|
||||
// S3 bucket-level conditions
|
||||
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" {
|
||||
@@ -243,13 +249,6 @@ func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
||||
return values
|
||||
}
|
||||
|
||||
// extractObjectTags extracts object tags from request (placeholder implementation)
|
||||
func extractObjectTags(r *http.Request) []string {
|
||||
// This would need to be implemented based on how object tags are stored
|
||||
// For now, return empty slice
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// BuildResourceArn builds an ARN for the given bucket and object
|
||||
func BuildResourceArn(bucketName, objectName string) string {
|
||||
if objectName == "" {
|
||||
@@ -352,15 +351,6 @@ func GetObjectNameFromArn(arn string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasPolicyForBucket checks if a bucket has a policy
|
||||
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
|
||||
_, exists := engine.contexts[bucketName]
|
||||
return exists
|
||||
}
|
||||
|
||||
// GetPolicyStatements returns all policy statements for a bucket
|
||||
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement {
|
||||
engine.mutex.RLock()
|
||||
|
||||
@@ -5,9 +5,23 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// tagsToEntry converts a map of tag key-value pairs to the entry.Extended format
|
||||
// used for s3:ExistingObjectTag/<key> condition evaluation
|
||||
func tagsToEntry(tags map[string]string) map[string][]byte {
|
||||
if tags == nil {
|
||||
return nil
|
||||
}
|
||||
entry := make(map[string][]byte)
|
||||
for k, v := range tags {
|
||||
entry[s3_constants.AmzObjectTaggingPrefix+k] = []byte(v)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func TestPolicyEngine(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
@@ -714,3 +728,233 @@ type MockLegacyIAM struct{}
|
||||
func (m *MockLegacyIAM) authRequest(r *http.Request, action Action) (Identity, s3err.ErrorCode) {
|
||||
return nil, s3err.ErrNone
|
||||
}
|
||||
|
||||
// TestExistingObjectTagCondition tests s3:ExistingObjectTag/<tag-key> condition support
|
||||
func TestExistingObjectTagCondition(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that allows GetObject only for objects with specific tag
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/status": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
objectTags map[string]string
|
||||
expected PolicyEvaluationResult
|
||||
}{
|
||||
{
|
||||
name: "Matching tag value - should allow",
|
||||
objectTags: map[string]string{"status": "public"},
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
{
|
||||
name: "Non-matching tag value - should be indeterminate",
|
||||
objectTags: map[string]string{"status": "private"},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "Missing tag - should be indeterminate",
|
||||
objectTags: map[string]string{"other": "value"},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "No tags - should be indeterminate",
|
||||
objectTags: nil,
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "Empty tags - should be indeterminate",
|
||||
objectTags: map[string]string{},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "Multiple tags with matching one - should allow",
|
||||
objectTags: map[string]string{"status": "public", "owner": "admin"},
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||
Principal: "*",
|
||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExistingObjectTagConditionMultipleTags tests policies with multiple tag conditions
|
||||
func TestExistingObjectTagConditionMultipleTags(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that requires multiple tag conditions
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/status": ["public"],
|
||||
"s3:ExistingObjectTag/tier": ["free", "premium"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
objectTags map[string]string
|
||||
expected PolicyEvaluationResult
|
||||
}{
|
||||
{
|
||||
name: "Both tags match - should allow",
|
||||
objectTags: map[string]string{"status": "public", "tier": "free"},
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
{
|
||||
name: "Both tags match (premium tier) - should allow",
|
||||
objectTags: map[string]string{"status": "public", "tier": "premium"},
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
{
|
||||
name: "Only status matches - should be indeterminate",
|
||||
objectTags: map[string]string{"status": "public"},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "Only tier matches - should be indeterminate",
|
||||
objectTags: map[string]string{"tier": "free"},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
{
|
||||
name: "Neither tag matches - should be indeterminate",
|
||||
objectTags: map[string]string{"status": "private", "tier": "basic"},
|
||||
expected: PolicyResultIndeterminate,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||
Principal: "*",
|
||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExistingObjectTagDenyPolicy tests deny policies with tag conditions
|
||||
func TestExistingObjectTagDenyPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that denies access to objects with confidential tag
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/classification": ["confidential"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
objectTags map[string]string
|
||||
expected PolicyEvaluationResult
|
||||
}{
|
||||
{
|
||||
name: "No tags - allow by default statement",
|
||||
objectTags: nil,
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
{
|
||||
name: "Non-confidential tag - allow",
|
||||
objectTags: map[string]string{"classification": "public"},
|
||||
expected: PolicyResultAllow,
|
||||
},
|
||||
{
|
||||
name: "Confidential tag - deny",
|
||||
objectTags: map[string]string{"classification": "confidential"},
|
||||
expected: PolicyResultDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||
Principal: "*",
|
||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ type PolicyEvaluationArgs struct {
|
||||
Resource string
|
||||
Principal string
|
||||
Conditions map[string][]string
|
||||
// ObjectEntry is the object's metadata from entry.Extended.
|
||||
// Used for evaluating conditions like s3:ExistingObjectTag/<tag-key>.
|
||||
// Tags are stored with s3_constants.AmzObjectTaggingPrefix (X-Amz-Tagging-) prefix.
|
||||
// Can be nil for bucket-level operations or when object doesn't exist.
|
||||
ObjectEntry map[string][]byte
|
||||
}
|
||||
|
||||
// PolicyCache for caching compiled policies
|
||||
|
||||
@@ -218,13 +218,14 @@ func (s3a *S3ApiServer) getEncryptionConfiguration(bucket string) (*s3_pb.Encryp
|
||||
// updateEncryptionConfiguration updates the encryption configuration for a bucket
|
||||
func (s3a *S3ApiServer) updateEncryptionConfiguration(bucket string, encryptionConfig *s3_pb.EncryptionConfiguration) s3err.ErrorCode {
|
||||
// Update using structured API
|
||||
// Note: UpdateBucketEncryption -> UpdateBucketMetadata -> setBucketMetadata
|
||||
// already invalidates the cache synchronously after successful update
|
||||
err := s3a.UpdateBucketEncryption(bucket, encryptionConfig)
|
||||
if err != nil {
|
||||
glog.Errorf("updateEncryptionConfiguration: failed to update encryption config for bucket %s: %v", bucket, err)
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
|
||||
// Cache will be updated automatically via metadata subscription
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
@@ -242,13 +243,14 @@ func (s3a *S3ApiServer) removeEncryptionConfiguration(bucket string) s3err.Error
|
||||
}
|
||||
|
||||
// Update using structured API
|
||||
// Note: ClearBucketEncryption -> UpdateBucketMetadata -> setBucketMetadata
|
||||
// already invalidates the cache synchronously after successful update
|
||||
err = s3a.ClearBucketEncryption(bucket)
|
||||
if err != nil {
|
||||
glog.Errorf("removeEncryptionConfiguration: failed to remove encryption config for bucket %s: %v", bucket, err)
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
|
||||
// Cache will be updated automatically via metadata subscription
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package s3_constants
|
||||
|
||||
const (
|
||||
// DefaultBucketsPath is the default path for S3 buckets in the filer
|
||||
DefaultBucketsPath = "/buckets"
|
||||
)
|
||||
@@ -140,17 +140,47 @@ const (
|
||||
func GetBucketAndObject(r *http.Request) (bucket, object string) {
|
||||
vars := mux.Vars(r)
|
||||
bucket = vars["bucket"]
|
||||
object = vars["object"]
|
||||
object = NormalizeObjectKey(vars["object"])
|
||||
return
|
||||
}
|
||||
|
||||
// NormalizeObjectKey ensures the object key has a leading slash and no duplicate slashes.
|
||||
// This normalizes keys from various sources (URL path, form values, etc.) to a consistent format.
|
||||
// It also converts Windows-style backslashes to forward slashes for cross-platform compatibility.
|
||||
func NormalizeObjectKey(object string) string {
|
||||
// Convert Windows-style backslashes to forward slashes
|
||||
object = strings.ReplaceAll(object, "\\", "/")
|
||||
object = removeDuplicateSlashes(object)
|
||||
if !strings.HasPrefix(object, "/") {
|
||||
object = "/" + object
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
return
|
||||
// removeDuplicateSlashes removes consecutive slashes from a path
|
||||
func removeDuplicateSlashes(s string) string {
|
||||
var result strings.Builder
|
||||
result.Grow(len(s))
|
||||
|
||||
lastWasSlash := false
|
||||
for _, r := range s {
|
||||
if r == '/' {
|
||||
if !lastWasSlash {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
lastWasSlash = true
|
||||
} else {
|
||||
result.WriteRune(r)
|
||||
lastWasSlash = false
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func GetPrefix(r *http.Request) string {
|
||||
query := r.URL.Query()
|
||||
prefix := query.Get("prefix")
|
||||
prefix = removeDuplicateSlashes(prefix)
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package s3_constants
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeObjectKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple key",
|
||||
input: "file.txt",
|
||||
expected: "/file.txt",
|
||||
},
|
||||
{
|
||||
name: "key with leading slash",
|
||||
input: "/file.txt",
|
||||
expected: "/file.txt",
|
||||
},
|
||||
{
|
||||
name: "key with directory",
|
||||
input: "folder/file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "key with leading slash and directory",
|
||||
input: "/folder/file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "key with duplicate slashes",
|
||||
input: "folder//subfolder///file.txt",
|
||||
expected: "/folder/subfolder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "Windows backslash - simple",
|
||||
input: "folder\\file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "Windows backslash - nested",
|
||||
input: "folder\\subfolder\\file.txt",
|
||||
expected: "/folder/subfolder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "Windows backslash - with leading slash",
|
||||
input: "/folder\\subfolder\\file.txt",
|
||||
expected: "/folder/subfolder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "mixed slashes",
|
||||
input: "folder\\subfolder/another\\file.txt",
|
||||
expected: "/folder/subfolder/another/file.txt",
|
||||
},
|
||||
{
|
||||
name: "Windows full path style (edge case)",
|
||||
input: "C:\\Users\\test\\file.txt",
|
||||
expected: "/C:/Users/test/file.txt",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "just a slash",
|
||||
input: "/",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "just a backslash",
|
||||
input: "\\",
|
||||
expected: "/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := NormalizeObjectKey(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("NormalizeObjectKey(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDuplicateSlashes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no duplicates",
|
||||
input: "/folder/file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "double slash",
|
||||
input: "/folder//file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "triple slash",
|
||||
input: "/folder///file.txt",
|
||||
expected: "/folder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "multiple duplicate locations",
|
||||
input: "//folder//subfolder///file.txt",
|
||||
expected: "/folder/subfolder/file.txt",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := removeDuplicateSlashes(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("removeDuplicateSlashes(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -654,3 +655,155 @@ func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TestS3AuthenticationDenied tests that unauthenticated and invalid requests are properly rejected
|
||||
func TestS3AuthenticationDenied(t *testing.T) {
|
||||
s3Server, _ := setupCompleteS3IAMSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() *http.Request
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "no_authorization_header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test-auth", nil)
|
||||
// No Authorization header
|
||||
return req
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
description: "Request without Authorization header should be rejected",
|
||||
},
|
||||
{
|
||||
name: "empty_bearer_token",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test-auth", nil)
|
||||
req.Header.Set("Authorization", "Bearer ")
|
||||
return req
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
description: "Request with empty Bearer token should be rejected",
|
||||
},
|
||||
{
|
||||
name: "invalid_jwt_token",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test-auth", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid.jwt.token")
|
||||
return req
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
description: "Request with invalid JWT token should be rejected",
|
||||
},
|
||||
{
|
||||
name: "malformed_authorization_header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test-auth", nil)
|
||||
req.Header.Set("Authorization", "NotBearer sometoken")
|
||||
return req
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
description: "Request with malformed Authorization header should be rejected",
|
||||
},
|
||||
{
|
||||
name: "expired_jwt_token",
|
||||
setupRequest: func() *http.Request {
|
||||
// Create an expired JWT token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": "https://test-issuer.com",
|
||||
"sub": "test-user",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString([]byte("test-signing-key"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/test-auth", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenString)
|
||||
return req
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
description: "Request with expired JWT token should be rejected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := tt.setupRequest()
|
||||
recorder := httptest.NewRecorder()
|
||||
s3Server.ServeHTTP(recorder, req)
|
||||
|
||||
// Verify the request was rejected with the expected status
|
||||
assert.Equal(t, tt.expectedStatus, recorder.Code,
|
||||
"%s: expected status %d but got %d. Response: %s",
|
||||
tt.description, tt.expectedStatus, recorder.Code, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3IAMOnlyModeRejectsAnonymous tests that when only IAM is configured
|
||||
// (no traditional identities), anonymous requests are properly denied
|
||||
func TestS3IAMOnlyModeRejectsAnonymous(t *testing.T) {
|
||||
// Create IAM with NO traditional identities (simulating IAM-only setup)
|
||||
iam := &IdentityAccessManagement{
|
||||
identities: []*Identity{},
|
||||
accessKeyIdent: make(map[string]*Identity),
|
||||
nameToIdentity: make(map[string]*Identity),
|
||||
accounts: make(map[string]*Account),
|
||||
emailAccount: make(map[string]*Account),
|
||||
hashes: make(map[string]*sync.Pool),
|
||||
hashCounters: make(map[string]*int32),
|
||||
}
|
||||
|
||||
// Set up IAM integration
|
||||
iamManager := integration.NewIAMManager()
|
||||
config := &integration.IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
},
|
||||
Roles: &integration.RoleStoreConfig{
|
||||
StoreType: "memory",
|
||||
},
|
||||
}
|
||||
|
||||
err := iamManager.Initialize(config, func() string {
|
||||
return "localhost:8888"
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
|
||||
require.NotNil(t, s3IAMIntegration)
|
||||
|
||||
// Set IAM integration - this should enable auth
|
||||
iam.SetIAMIntegration(s3IAMIntegration)
|
||||
|
||||
// Verify auth is enabled
|
||||
require.True(t, iam.isEnabled(), "Auth must be enabled when IAM integration is configured")
|
||||
|
||||
// Test that the Auth middleware blocks unauthenticated requests
|
||||
handlerCalled := false
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
wrappedHandler := iam.Auth(testHandler, "Write")
|
||||
|
||||
// Create an unauthenticated request
|
||||
req := httptest.NewRequest("PUT", "/mybucket/test.txt", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
|
||||
// Handler should NOT have been called
|
||||
assert.False(t, handlerCalled, "Handler should not be called for unauthenticated request")
|
||||
assert.NotEqual(t, http.StatusOK, rr.Code, "Unauthenticated request should not return 200 OK")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCheckPolicyWithEntry tests the checkPolicyWithEntry helper method
|
||||
// This verifies that tag-based conditions are properly evaluated when
|
||||
// the object entry is available (Phase 2 of policy evaluation)
|
||||
func TestCheckPolicyWithEntry(t *testing.T) {
|
||||
// Create a minimal S3ApiServer with policy engine
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: NewBucketPolicyEngine(),
|
||||
}
|
||||
|
||||
bucket := "test-bucket"
|
||||
|
||||
// Set up a policy that allows access only to objects with status=public tag
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/status": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := s3a.policyEngine.engine.SetBucketPolicy(bucket, policyJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
objectEntry map[string][]byte
|
||||
wantErrCode s3err.ErrorCode
|
||||
wantChecked bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "object with public tag - allow",
|
||||
objectEntry: map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "status": []byte("public"),
|
||||
},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
wantChecked: true,
|
||||
description: "Object with status=public tag should be allowed",
|
||||
},
|
||||
{
|
||||
name: "object with private tag - indeterminate (no matching statement)",
|
||||
objectEntry: map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "status": []byte("private"),
|
||||
},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
wantChecked: false, // Indeterminate = no policy matched
|
||||
description: "Object with status=private should return indeterminate (falls back to IAM)",
|
||||
},
|
||||
{
|
||||
name: "object with no tags - indeterminate (no matching statement)",
|
||||
objectEntry: map[string][]byte{},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
wantChecked: false, // Indeterminate = no policy matched
|
||||
description: "Object without tags should return indeterminate (falls back to IAM)",
|
||||
},
|
||||
{
|
||||
name: "nil entry - indeterminate (condition cannot be evaluated)",
|
||||
objectEntry: nil,
|
||||
wantErrCode: s3err.ErrNone,
|
||||
wantChecked: false, // Indeterminate = no policy matched
|
||||
description: "Nil entry should return indeterminate (falls back to IAM)",
|
||||
},
|
||||
{
|
||||
name: "object with multiple tags including public - allow",
|
||||
objectEntry: map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "status": []byte("public"),
|
||||
s3_constants.AmzObjectTaggingPrefix + "category": []byte("documents"),
|
||||
},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
wantChecked: true,
|
||||
description: "Object with status=public among other tags should be allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test-bucket/test-object.txt", nil)
|
||||
object := "/test-object.txt"
|
||||
principal := "*"
|
||||
action := string(s3_constants.ACTION_READ)
|
||||
|
||||
errCode, checked := s3a.checkPolicyWithEntry(req, bucket, object, action, principal, tt.objectEntry)
|
||||
|
||||
assert.Equal(t, tt.wantErrCode, errCode, tt.description)
|
||||
assert.Equal(t, tt.wantChecked, checked, "Policy should be evaluated")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckPolicyWithEntryNoPolicyForBucket tests that checkPolicyWithEntry
|
||||
// returns early when there's no policy for the bucket
|
||||
func TestCheckPolicyWithEntryNoPolicyForBucket(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: NewBucketPolicyEngine(),
|
||||
}
|
||||
|
||||
bucket := "bucket-without-policy"
|
||||
req := httptest.NewRequest(http.MethodGet, "/bucket-without-policy/test.txt", nil)
|
||||
|
||||
objectEntry := map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "status": []byte("private"),
|
||||
}
|
||||
|
||||
errCode, checked := s3a.checkPolicyWithEntry(req, bucket, "/test.txt", string(s3_constants.ACTION_READ), "*", objectEntry)
|
||||
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "No policy should mean no denial")
|
||||
assert.False(t, checked, "Policy should not be evaluated when bucket has no policy")
|
||||
}
|
||||
|
||||
// TestCheckPolicyWithEntryNilPolicyEngine tests that checkPolicyWithEntry
|
||||
// handles nil policy engine gracefully
|
||||
func TestCheckPolicyWithEntryNilPolicyEngine(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: nil,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test-bucket/test.txt", nil)
|
||||
|
||||
errCode, checked := s3a.checkPolicyWithEntry(req, "test-bucket", "/test.txt", string(s3_constants.ACTION_READ), "*", nil)
|
||||
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "Nil policy engine should allow access")
|
||||
assert.False(t, checked, "Policy should not be evaluated when engine is nil")
|
||||
}
|
||||
|
||||
// TestCheckPolicyWithEntryDenyPolicy tests deny policies with tag conditions
|
||||
func TestCheckPolicyWithEntryDenyPolicy(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: NewBucketPolicyEngine(),
|
||||
}
|
||||
|
||||
bucket := "secure-bucket"
|
||||
|
||||
// Policy that allows all access but denies access to confidential objects
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::secure-bucket/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::secure-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/classification": ["confidential"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := s3a.policyEngine.engine.SetBucketPolicy(bucket, policyJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
objectEntry map[string][]byte
|
||||
wantErrCode s3err.ErrorCode
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "public object - allow",
|
||||
objectEntry: map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "classification": []byte("public"),
|
||||
},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
description: "Object with classification=public should be allowed",
|
||||
},
|
||||
{
|
||||
name: "confidential object - deny",
|
||||
objectEntry: map[string][]byte{
|
||||
s3_constants.AmzObjectTaggingPrefix + "classification": []byte("confidential"),
|
||||
},
|
||||
wantErrCode: s3err.ErrAccessDenied,
|
||||
description: "Object with classification=confidential should be denied by explicit deny",
|
||||
},
|
||||
{
|
||||
name: "no classification tag - allow",
|
||||
objectEntry: map[string][]byte{},
|
||||
wantErrCode: s3err.ErrNone,
|
||||
description: "Object without classification tag should be allowed (deny condition not met)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/secure-bucket/test.txt", nil)
|
||||
|
||||
errCode, checked := s3a.checkPolicyWithEntry(req, bucket, "/test.txt", string(s3_constants.ACTION_READ), "*", tt.objectEntry)
|
||||
|
||||
assert.Equal(t, tt.wantErrCode, errCode, tt.description)
|
||||
assert.True(t, checked, "Policy should be evaluated")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasPolicyForBucket tests the HasPolicyForBucket method
|
||||
func TestHasPolicyForBucket(t *testing.T) {
|
||||
engine := policy_engine.NewPolicyEngine()
|
||||
|
||||
// Initially no policy
|
||||
assert.False(t, engine.HasPolicyForBucket("test-bucket"))
|
||||
|
||||
// Set a policy
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now has policy
|
||||
assert.True(t, engine.HasPolicyForBucket("test-bucket"))
|
||||
|
||||
// Other bucket still has no policy
|
||||
assert.False(t, engine.HasPolicyForBucket("other-bucket"))
|
||||
|
||||
// Delete the policy
|
||||
err = engine.DeleteBucketPolicy("test-bucket")
|
||||
require.NoError(t, err)
|
||||
|
||||
// No longer has policy
|
||||
assert.False(t, engine.HasPolicyForBucket("test-bucket"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package s3_objectlock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// ====================================================================
|
||||
// SHARED OBJECT LOCK CHECKING FUNCTIONS
|
||||
// ====================================================================
|
||||
// These functions are used by S3 API, Admin UI, and shell commands for
|
||||
// checking Object Lock status before bucket deletion.
|
||||
|
||||
// EntryHasActiveLock checks if an entry has an active retention or legal hold
|
||||
// This is a standalone function that can be used by any component
|
||||
func EntryHasActiveLock(entry *filer_pb.Entry, currentTime time.Time) bool {
|
||||
if entry == nil || entry.Extended == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for active legal hold (case-insensitive, trimmed for defensive parsing)
|
||||
if legalHoldBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists {
|
||||
legalHold := strings.TrimSpace(strings.ToUpper(string(legalHoldBytes)))
|
||||
if legalHold == s3_constants.LegalHoldOn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for active retention (case-insensitive, trimmed for defensive parsing)
|
||||
if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists {
|
||||
mode := strings.TrimSpace(strings.ToUpper(string(modeBytes)))
|
||||
if mode == s3_constants.RetentionModeCompliance || mode == s3_constants.RetentionModeGovernance {
|
||||
// Check if retention is still active
|
||||
if dateBytes, dateExists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; dateExists {
|
||||
dateStr := strings.TrimSpace(string(dateBytes))
|
||||
timestamp, err := strconv.ParseInt(dateStr, 10, 64)
|
||||
if err != nil {
|
||||
// Fail-safe: if we can't parse the retention date, assume the object is locked
|
||||
// to prevent accidental data loss
|
||||
glog.Warningf("Failed to parse retention date '%s' for entry, assuming locked: %v", dateStr, err)
|
||||
return true
|
||||
}
|
||||
retainUntil := time.Unix(timestamp, 0)
|
||||
if retainUntil.After(currentTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold
|
||||
// This function uses the filer gRPC client to scan the bucket directory
|
||||
func HasObjectsWithActiveLocks(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketPath string) (bool, error) {
|
||||
hasLocks := false
|
||||
currentTime := time.Now()
|
||||
|
||||
err := recursivelyCheckLocksWithClient(ctx, client, bucketPath, &hasLocks, currentTime)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error checking for locked objects: %w", err)
|
||||
}
|
||||
|
||||
return hasLocks, nil
|
||||
}
|
||||
|
||||
// paginateEntries is a generic helper that handles pagination logic for listing directory entries.
|
||||
// The processEntry callback is called for each entry; returning stop=true stops iteration early.
|
||||
func paginateEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string,
|
||||
processEntry func(entry *filer_pb.Entry) (stop bool, err error)) error {
|
||||
lastFileName := ""
|
||||
for {
|
||||
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: dir,
|
||||
StartFromFileName: lastFileName,
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
entriesReceived := false
|
||||
for {
|
||||
entryResp, recvErr := resp.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break // Normal end of stream
|
||||
}
|
||||
return fmt.Errorf("failed to receive entry from %s: %w", dir, recvErr)
|
||||
}
|
||||
entriesReceived = true
|
||||
entry := entryResp.Entry
|
||||
lastFileName = entry.Name
|
||||
|
||||
// Skip invalid entry names to prevent path traversal
|
||||
if entry.Name == "" || entry.Name == "." || entry.Name == ".." ||
|
||||
strings.ContainsAny(entry.Name, "/\\") {
|
||||
glog.V(2).Infof("Skipping invalid entry name: %q in %s", entry.Name, dir)
|
||||
continue
|
||||
}
|
||||
|
||||
stop, err := processEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stop {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !entriesReceived {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recursivelyCheckLocksWithClient recursively checks all objects and versions for active locks
|
||||
func recursivelyCheckLocksWithClient(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string, hasLocks *bool, currentTime time.Time) error {
|
||||
if *hasLocks {
|
||||
return nil // Early exit if already found a locked object
|
||||
}
|
||||
|
||||
return paginateEntries(ctx, client, dir, func(entry *filer_pb.Entry) (bool, error) {
|
||||
if *hasLocks {
|
||||
return true, nil // Stop iteration
|
||||
}
|
||||
|
||||
// Skip special directories
|
||||
if entry.Name == s3_constants.MultipartUploadsFolder {
|
||||
return false, nil // Continue
|
||||
}
|
||||
|
||||
if entry.IsDirectory {
|
||||
subDir := dir + "/" + entry.Name
|
||||
if entry.Name == s3_constants.VersionsFolder {
|
||||
// Check all version files (exact match for .versions folder)
|
||||
if err := checkVersionsForLocksWithClient(ctx, client, subDir, hasLocks, currentTime); err != nil {
|
||||
return false, err
|
||||
}
|
||||
} else {
|
||||
// Recursively check subdirectories
|
||||
if err := recursivelyCheckLocksWithClient(ctx, client, subDir, hasLocks, currentTime); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if this object has an active lock
|
||||
if EntryHasActiveLock(entry, currentTime) {
|
||||
*hasLocks = true
|
||||
glog.V(2).Infof("Found object with active lock: %s/%s", dir, entry.Name)
|
||||
return true, nil // Stop iteration
|
||||
}
|
||||
}
|
||||
return false, nil // Continue
|
||||
})
|
||||
}
|
||||
|
||||
// checkVersionsForLocksWithClient checks all versions in a .versions directory for active locks
|
||||
func checkVersionsForLocksWithClient(ctx context.Context, client filer_pb.SeaweedFilerClient, versionsDir string, hasLocks *bool, currentTime time.Time) error {
|
||||
return paginateEntries(ctx, client, versionsDir, func(entry *filer_pb.Entry) (bool, error) {
|
||||
if *hasLocks {
|
||||
return true, nil // Stop iteration
|
||||
}
|
||||
|
||||
if EntryHasActiveLock(entry, currentTime) {
|
||||
*hasLocks = true
|
||||
glog.V(2).Infof("Found version with active lock: %s/%s", versionsDir, entry.Name)
|
||||
return true, nil // Stop iteration
|
||||
}
|
||||
return false, nil // Continue
|
||||
})
|
||||
}
|
||||
|
||||
// IsObjectLockEnabled checks if Object Lock is enabled on a bucket entry
|
||||
func IsObjectLockEnabled(entry *filer_pb.Entry) bool {
|
||||
if entry == nil || entry.Extended == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
enabledBytes, exists := entry.Extended[s3_constants.ExtObjectLockEnabledKey]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
enabled := string(enabledBytes)
|
||||
return enabled == s3_constants.ObjectLockEnabled || enabled == "true"
|
||||
}
|
||||
|
||||
// CheckBucketForLockedObjects is a unified function that checks if a bucket has Object Lock enabled
|
||||
// and if so, scans for objects with active locks. This combines the bucket lookup and lock check
|
||||
// into a single operation used by S3 API, Admin UI, and shell commands.
|
||||
// Returns an error if the bucket has locked objects or if the check fails.
|
||||
func CheckBucketForLockedObjects(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketsPath, bucketName string) error {
|
||||
// Look up the bucket entry
|
||||
lookupResp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: bucketsPath,
|
||||
Name: bucketName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket not found: %w", err)
|
||||
}
|
||||
|
||||
// Check if Object Lock is enabled
|
||||
if !IsObjectLockEnabled(lookupResp.Entry) {
|
||||
return nil // No Object Lock, nothing to check
|
||||
}
|
||||
|
||||
// Check for objects with active locks
|
||||
bucketPath := bucketsPath + "/" + bucketName
|
||||
hasLockedObjects, checkErr := HasObjectsWithActiveLocks(ctx, client, bucketPath)
|
||||
if checkErr != nil {
|
||||
return fmt.Errorf("failed to check for locked objects: %w", checkErr)
|
||||
}
|
||||
if hasLockedObjects {
|
||||
return fmt.Errorf("bucket has objects with active Object Lock retention or legal hold")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -612,26 +612,28 @@ func (s3a *S3ApiServer) getCORSConfiguration(bucket string) (*cors.CORSConfigura
|
||||
// updateCORSConfiguration updates the CORS configuration for a bucket
|
||||
func (s3a *S3ApiServer) updateCORSConfiguration(bucket string, corsConfig *cors.CORSConfiguration) s3err.ErrorCode {
|
||||
// Update using structured API
|
||||
// Note: UpdateBucketCORS -> UpdateBucketMetadata -> setBucketMetadata
|
||||
// already invalidates the cache synchronously after successful update
|
||||
err := s3a.UpdateBucketCORS(bucket, corsConfig)
|
||||
if err != nil {
|
||||
glog.Errorf("updateCORSConfiguration: failed to update CORS config for bucket %s: %v", bucket, err)
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
|
||||
// Cache will be updated automatically via metadata subscription
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// removeCORSConfiguration removes the CORS configuration for a bucket
|
||||
func (s3a *S3ApiServer) removeCORSConfiguration(bucket string) s3err.ErrorCode {
|
||||
// Update using structured API
|
||||
// Note: ClearBucketCORS -> UpdateBucketMetadata -> setBucketMetadata
|
||||
// already invalidates the cache synchronously after successful update
|
||||
err := s3a.ClearBucketCORS(bucket)
|
||||
if err != nil {
|
||||
glog.Errorf("removeCORSConfiguration: failed to remove CORS config for bucket %s: %v", bucket, err)
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
|
||||
// Cache will be updated automatically via metadata subscription
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -336,7 +334,7 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// If object lock is enabled, check for objects with active locks
|
||||
if bucketConfig.ObjectLockConfig != nil {
|
||||
hasLockedObjects, checkErr := s3a.hasObjectsWithActiveLocks(bucket)
|
||||
hasLockedObjects, checkErr := s3a.hasObjectsWithActiveLocks(r.Context(), bucket)
|
||||
if checkErr != nil {
|
||||
glog.Errorf("DeleteBucketHandler: failed to check for locked objects in bucket %s: %v", bucket, checkErr)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
@@ -400,156 +398,20 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// hasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold
|
||||
func (s3a *S3ApiServer) hasObjectsWithActiveLocks(bucket string) (bool, error) {
|
||||
// Delegates to the shared HasObjectsWithActiveLocks function in object_lock_utils.go
|
||||
func (s3a *S3ApiServer) hasObjectsWithActiveLocks(ctx context.Context, bucket string) (bool, error) {
|
||||
bucketPath := s3a.option.BucketsPath + "/" + bucket
|
||||
var hasLocks bool
|
||||
var checkErr error
|
||||
|
||||
// Check all objects including versions for active locks
|
||||
// Establish current time once at the start for consistency across the entire scan
|
||||
hasLocks := false
|
||||
currentTime := time.Now()
|
||||
err := s3a.recursivelyCheckLocks(bucketPath, "", &hasLocks, currentTime)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error checking for locked objects: %w", err)
|
||||
}
|
||||
|
||||
return hasLocks, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// lockCheckPaginationSize is the page size for listing directories during lock checks
|
||||
lockCheckPaginationSize = 10000
|
||||
)
|
||||
|
||||
// errStopPagination is a sentinel error to signal early termination of pagination
|
||||
var errStopPagination = errors.New("stop pagination")
|
||||
|
||||
// paginateEntries iterates through directory entries with pagination
|
||||
// Calls fn for each page of entries. If fn returns errStopPagination, iteration stops successfully.
|
||||
func (s3a *S3ApiServer) paginateEntries(dir string, fn func(entries []*filer_pb.Entry) error) error {
|
||||
startFrom := ""
|
||||
for {
|
||||
entries, isLast, err := s3a.list(dir, "", startFrom, false, lockCheckPaginationSize)
|
||||
if err != nil {
|
||||
// Fail-safe: propagate error to prevent incorrect bucket deletion
|
||||
return fmt.Errorf("failed to list directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := fn(entries); err != nil {
|
||||
if errors.Is(err, errStopPagination) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if isLast || len(entries) == 0 {
|
||||
break
|
||||
}
|
||||
// Use the last entry name as the start point for next page
|
||||
startFrom = entries[len(entries)-1].Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recursivelyCheckLocks recursively checks all objects and versions for active locks
|
||||
// Uses pagination to handle directories with more than 10,000 entries
|
||||
func (s3a *S3ApiServer) recursivelyCheckLocks(dir string, relativePath string, hasLocks *bool, currentTime time.Time) error {
|
||||
if *hasLocks {
|
||||
// Early exit if we've already found a locked object
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process entries in the current directory with pagination
|
||||
err := s3a.paginateEntries(dir, func(entries []*filer_pb.Entry) error {
|
||||
for _, entry := range entries {
|
||||
if *hasLocks {
|
||||
// Early exit if we've already found a locked object
|
||||
return errStopPagination
|
||||
}
|
||||
|
||||
// Skip special directories (multipart uploads, etc)
|
||||
if entry.Name == s3_constants.MultipartUploadsFolder {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.IsDirectory {
|
||||
subDir := path.Join(dir, entry.Name)
|
||||
if strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) {
|
||||
// If it's a .versions directory, check all version files with pagination
|
||||
err := s3a.paginateEntries(subDir, func(versionEntries []*filer_pb.Entry) error {
|
||||
for _, versionEntry := range versionEntries {
|
||||
if s3a.entryHasActiveLock(versionEntry, currentTime) {
|
||||
*hasLocks = true
|
||||
glog.V(2).Infof("Found object with active lock in versions: %s/%s", subDir, versionEntry.Name)
|
||||
return errStopPagination
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Recursively check other subdirectories
|
||||
subRelativePath := path.Join(relativePath, entry.Name)
|
||||
if err := s3a.recursivelyCheckLocks(subDir, subRelativePath, hasLocks, currentTime); err != nil {
|
||||
return err
|
||||
}
|
||||
// Early exit if a locked object was found in the subdirectory
|
||||
if *hasLocks {
|
||||
return errStopPagination
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check regular files for locks
|
||||
if s3a.entryHasActiveLock(entry, currentTime) {
|
||||
*hasLocks = true
|
||||
objectPath := path.Join(relativePath, entry.Name)
|
||||
glog.V(2).Infof("Found object with active lock: %s", objectPath)
|
||||
return errStopPagination
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
hasLocks, checkErr = HasObjectsWithActiveLocks(ctx, client, bucketPath)
|
||||
return checkErr
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// entryHasActiveLock checks if an entry has an active retention or legal hold
|
||||
func (s3a *S3ApiServer) entryHasActiveLock(entry *filer_pb.Entry, currentTime time.Time) bool {
|
||||
if entry.Extended == nil {
|
||||
return false
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Check for active legal hold
|
||||
if legalHoldBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists {
|
||||
if string(legalHoldBytes) == s3_constants.LegalHoldOn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for active retention
|
||||
if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists {
|
||||
mode := string(modeBytes)
|
||||
if mode == s3_constants.RetentionModeCompliance || mode == s3_constants.RetentionModeGovernance {
|
||||
// Check if retention is still active
|
||||
if dateBytes, dateExists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; dateExists {
|
||||
timestamp, err := strconv.ParseInt(string(dateBytes), 10, 64)
|
||||
if err != nil {
|
||||
// Fail-safe: if we can't parse the retention date, assume the object is locked
|
||||
// to prevent accidental data loss
|
||||
glog.Warningf("Failed to parse retention date '%s' for entry, assuming locked: %v", string(dateBytes), err)
|
||||
return true
|
||||
}
|
||||
retainUntil := time.Unix(timestamp, 0)
|
||||
if retainUntil.After(currentTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return hasLocks, nil
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) HeadBucketHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -765,8 +627,8 @@ func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Acti
|
||||
|
||||
// Check bucket policy for anonymous access using the policy engine
|
||||
principal := "*" // Anonymous principal
|
||||
// Use context-aware policy evaluation to get the correct S3 action
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicyWithContext(bucket, object, string(action), principal, r)
|
||||
// Evaluate bucket policy (objectEntry nil - not yet fetched)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
// If we can't evaluate the policy, deny access rather than falling through to IAM
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user