mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-11 09:05:50 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc2fefb4a | ||
|
|
6e393f3326 | ||
|
|
7939e5808e |
@@ -15,20 +15,24 @@ permissions:
|
||||
|
||||
jobs:
|
||||
|
||||
vet:
|
||||
name: Go Vet
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
cd weed; go get -v -t -d ./...
|
||||
|
||||
- name: Go Vet (excluding protobuf lock copying)
|
||||
run: |
|
||||
cd weed
|
||||
@@ -38,30 +42,8 @@ jobs:
|
||||
# Fail only if there are actual vet errors (not counting the filtered lock warnings)
|
||||
if grep -q "vet:" vet-output.txt; then exit 1; fi
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
- name: Build
|
||||
run: cd weed; go build -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v .
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
- name: Test
|
||||
run: cd weed; go test -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v ./...
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
# Wait for S3 API
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:8333/healthz > /dev/null 2>&1; then
|
||||
if curl -s http://localhost:8333/ > /dev/null 2>&1; then
|
||||
echo "✓ S3 API is ready"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -39,14 +39,6 @@ jobs:
|
||||
echo "=== Running S3 Integration Tests ==="
|
||||
go test -v -timeout=60s -run TestS3Integration ./...
|
||||
|
||||
- name: Run S3 DeleteBucketNotEmpty Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/s3/normal
|
||||
run: |
|
||||
set -x
|
||||
echo "=== Running S3 DeleteBucketNotEmpty Tests ==="
|
||||
go test -v -timeout=60s -run TestS3DeleteBucketNotEmpty ./...
|
||||
|
||||
- name: Run IAM Integration Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/s3/normal
|
||||
|
||||
@@ -461,8 +461,6 @@ jobs:
|
||||
export S3_ENDPOINT="http://localhost:8006"
|
||||
export S3_ACCESS_KEY="0555b35654ad1656d804"
|
||||
export S3_SECRET_KEY="h7GhxuBLTrlhVUyxSPUKUV8r/2EI4ngqJxD7iBdBYLhwluN30JaT3Q=="
|
||||
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
|
||||
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
|
||||
|
||||
# Run the specific test that is equivalent to AWS S3 tagging behavior
|
||||
make test-with-server || {
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# Verify service accessibility
|
||||
echo "=== Verifying Service Accessibility ==="
|
||||
curl -f http://localhost:8080/realms/master
|
||||
curl -s http://localhost:8333/healthz
|
||||
curl -s http://localhost:8333
|
||||
echo "✅ SeaweedFS S3 API is responding (IAM-protected endpoint)"
|
||||
|
||||
# Run Keycloak-specific tests
|
||||
|
||||
@@ -394,75 +394,6 @@ jobs:
|
||||
path: test/s3tables/sts_integration/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
lakekeeper-integration-tests:
|
||||
name: Lakekeeper Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
|
||||
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: Set up Docker
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Pre-pull Python image
|
||||
run: docker pull python:3
|
||||
|
||||
- name: Pre-pull LocalStack image (if needed)
|
||||
run: docker pull localstack/localstack:latest || true
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false ./weed
|
||||
|
||||
- name: Run Lakekeeper Integration Tests
|
||||
timeout-minutes: 25
|
||||
working-directory: test/s3tables/lakekeeper
|
||||
run: |
|
||||
set -x
|
||||
set -o pipefail
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
echo "=== Starting Lakekeeper Integration Tests ==="
|
||||
|
||||
# Run Lakekeeper integration tests
|
||||
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
|
||||
echo "Lakekeeper integration tests failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Show test output on failure
|
||||
if: failure()
|
||||
working-directory: test/s3tables/lakekeeper
|
||||
run: |
|
||||
echo "=== Test Output ==="
|
||||
if [ -f test-output.log ]; then
|
||||
tail -200 test-output.log
|
||||
fi
|
||||
|
||||
echo "=== Process information ==="
|
||||
ps aux | grep -E "(weed|test|docker)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: lakekeeper-integration-test-logs
|
||||
path: test/s3tables/lakekeeper/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
s3-tables-build-verification:
|
||||
name: S3 Tables Build Verification
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
set -e
|
||||
mkdir -p /tmp/data
|
||||
./weed -v=3 server -s3 -dir=/tmp/data -s3.config=../docker/compose/s3.json -master.peers=none > weed.log 2>&1 &
|
||||
until curl -s http://localhost:8333/healthz > /dev/null; do sleep 1; done
|
||||
until curl -s http://localhost:8333/ > /dev/null; do sleep 1; done
|
||||
|
||||
- name: Setup Caddy
|
||||
run: |
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Start Caddy
|
||||
run: |
|
||||
./caddy start
|
||||
until curl -fsS --insecure https://localhost:8443/healthz > /dev/null; do sleep 1; done
|
||||
until curl -fsS --insecure https://localhost:8443 > /dev/null; do sleep 1; done
|
||||
|
||||
- name: Create Bucket
|
||||
run: |
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
name: "Volume Server Integration Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'test/volume_server/**'
|
||||
- 'weed/server/**'
|
||||
- 'weed/storage/**'
|
||||
- 'weed/pb/volume_server.proto'
|
||||
- 'weed/pb/volume_server_pb/**'
|
||||
- '.github/workflows/volume-server-integration-tests.yml'
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'test/volume_server/**'
|
||||
- 'weed/server/**'
|
||||
- 'weed/storage/**'
|
||||
- 'weed/pb/volume_server.proto'
|
||||
- 'weed/pb/volume_server_pb/**'
|
||||
- '.github/workflows/volume-server-integration-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.ref }}/volume-server-integration-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
TEST_TIMEOUT: '30m'
|
||||
|
||||
jobs:
|
||||
volume-server-integration-tests:
|
||||
name: Volume Server Integration Tests (${{ matrix.test-type }} - Shard ${{ matrix.shard }})
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test-type: [grpc, http]
|
||||
shard: [1, 2, 3]
|
||||
|
||||
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 binary
|
||||
run: |
|
||||
cd weed
|
||||
go build -o weed .
|
||||
chmod +x weed
|
||||
./weed version
|
||||
|
||||
- name: Run volume server integration tests
|
||||
env:
|
||||
WEED_BINARY: ${{ github.workspace }}/weed/weed
|
||||
run: |
|
||||
if [ "${{ matrix.test-type }}" == "grpc" ]; then
|
||||
if [ "${{ matrix.shard }}" == "1" ]; then
|
||||
TEST_PATTERN="^Test[A-H]"
|
||||
elif [ "${{ matrix.shard }}" == "2" ]; then
|
||||
TEST_PATTERN="^Test[I-S]"
|
||||
else
|
||||
TEST_PATTERN="^Test[T-Z]"
|
||||
fi
|
||||
else
|
||||
if [ "${{ matrix.shard }}" == "1" ]; then
|
||||
TEST_PATTERN="^Test[A-G]"
|
||||
elif [ "${{ matrix.shard }}" == "2" ]; then
|
||||
TEST_PATTERN="^Test[H-R]"
|
||||
else
|
||||
TEST_PATTERN="^Test[S-Z]"
|
||||
fi
|
||||
fi
|
||||
echo "Running volume server integration tests for ${{ matrix.test-type }} (Shard ${{ matrix.shard }}, pattern: ${TEST_PATTERN})..."
|
||||
go test -v -count=1 -timeout=${{ env.TEST_TIMEOUT }} ./test/volume_server/${{ matrix.test-type }}/... -run "${TEST_PATTERN}"
|
||||
|
||||
- name: Collect logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p /tmp/volume-server-it-logs
|
||||
find /tmp -maxdepth 1 -type d -name "seaweedfs_volume_server_it_*" -print -exec cp -r {} /tmp/volume-server-it-logs/ \; || true
|
||||
|
||||
- name: Archive logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: volume-server-integration-test-logs
|
||||
path: /tmp/volume-server-it-logs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Test summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ matrix.test-type }}" == "grpc" ]; then
|
||||
if [ "${{ matrix.shard }}" == "1" ]; then
|
||||
TEST_PATTERN="^Test[A-H]"
|
||||
elif [ "${{ matrix.shard }}" == "2" ]; then
|
||||
TEST_PATTERN="^Test[I-S]"
|
||||
else
|
||||
TEST_PATTERN="^Test[T-Z]"
|
||||
fi
|
||||
else
|
||||
if [ "${{ matrix.shard }}" == "1" ]; then
|
||||
TEST_PATTERN="^Test[A-G]"
|
||||
elif [ "${{ matrix.shard }}" == "2" ]; then
|
||||
TEST_PATTERN="^Test[H-R]"
|
||||
else
|
||||
TEST_PATTERN="^Test[S-Z]"
|
||||
fi
|
||||
fi
|
||||
echo "## Volume Server Integration Test Summary (${{ matrix.test-type }} - Shard ${{ matrix.shard }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Suite: test/volume_server/${{ matrix.test-type }} (Pattern: ${TEST_PATTERN})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Command: go test -v -count=1 -timeout=${{ env.TEST_TIMEOUT }} ./test/volume_server/${{ matrix.test-type }}/... -run \"${TEST_PATTERN}\"" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -40,7 +40,7 @@ test: admin-generate
|
||||
# Admin component targets
|
||||
admin-generate:
|
||||
@echo "Generating admin component templates..."
|
||||
@cd $(ADMIN_DIR) && templ generate ./view
|
||||
@templ generate
|
||||
|
||||
admin-build: admin-generate
|
||||
@echo "Building admin component..."
|
||||
|
||||
@@ -20,17 +20,13 @@ if [ "$(id -u)" = "0" ]; then
|
||||
|
||||
DATA_UID=$(stat -c '%u' /data 2>/dev/null)
|
||||
DATA_GID=$(stat -c '%g' /data 2>/dev/null)
|
||||
|
||||
# Only run chown -R if ownership doesn't already match (avoids expensive
|
||||
# recursive chown on subsequent starts, and is a no-op on OpenShift when
|
||||
# fsGroup has already set correct ownership on the PVC).
|
||||
if [ "$DATA_UID" != "$SEAWEED_UID" ] || [ "$DATA_GID" != "$SEAWEED_GID" ]; then
|
||||
|
||||
# Only run chown -R if ownership doesn't match (much faster for subsequent starts)
|
||||
echo "Fixing /data ownership for seaweed user (uid=$SEAWEED_UID, gid=$SEAWEED_GID)"
|
||||
if ! chown -R seaweed:seaweed /data; then
|
||||
echo "Warning: Failed to change ownership of /data. This may cause permission errors." >&2
|
||||
echo "If /data is read-only or has mount issues, the application may fail to start." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Use su-exec to drop privileges and run as seaweed user
|
||||
exec su-exec seaweed "$0" "$@"
|
||||
|
||||
@@ -42,12 +42,3 @@ services:
|
||||
- master
|
||||
- volume
|
||||
- filer
|
||||
admin:
|
||||
image: chrislusf/seaweedfs:dev # use a remote dev image
|
||||
ports:
|
||||
- 23646:23646
|
||||
command: 'admin -master=master:9333'
|
||||
depends_on:
|
||||
- master
|
||||
- volume
|
||||
- filer
|
||||
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/karlseguin/ccache/v2 v2.0.8
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/klauspost/compress v1.18.3
|
||||
github.com/klauspost/reedsolomon v1.13.0
|
||||
github.com/kurin/blazer v0.5.3
|
||||
github.com/linxGnu/grocksdb v1.10.7
|
||||
@@ -130,7 +130,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||
github.com/cognusion/imaging v1.0.2
|
||||
github.com/fluent/fluent-logger-golang v1.10.1
|
||||
github.com/getsentry/sentry-go v0.42.0
|
||||
github.com/getsentry/sentry-go v0.40.0
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
@@ -142,7 +142,7 @@ require (
|
||||
github.com/jhump/protoreflect v1.18.0
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/linkedin/goavro/v2 v2.14.1
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/minio/crc64nvme v1.1.1
|
||||
github.com/orcaman/concurrent-map/v2 v2.0.1
|
||||
github.com/parquet-go/parquet-go v0.26.4
|
||||
@@ -150,7 +150,7 @@ require (
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/rclone/rclone v1.72.1
|
||||
github.com/rdleal/intervalst v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/seaweedfs/go-fuse/v2 v2.9.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.1
|
||||
@@ -293,11 +293,11 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
|
||||
@@ -547,8 +547,8 @@ cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcP
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
|
||||
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
@@ -569,8 +569,8 @@ github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGlu
|
||||
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||
@@ -1053,8 +1053,8 @@ github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
|
||||
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
|
||||
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
@@ -1487,8 +1487,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
@@ -1564,8 +1564,8 @@ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg
|
||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
@@ -1814,8 +1814,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6hamU=
|
||||
github.com/rdleal/intervalst v1.5.0/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/redis/rueidis v1.0.69 h1:WlUefRhuDekji5LsD387ys3UCJtSFeBVf0e5yI0B8b4=
|
||||
github.com/redis/rueidis v1.0.69/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.69 h1:IWVYY9lXdjNO3do2VpJT7aDFi8zbCUuQxZB6E2Grahs=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
description: SeaweedFS
|
||||
name: seaweedfs
|
||||
appVersion: "4.13"
|
||||
appVersion: "4.12"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.0.413
|
||||
version: 4.0.412
|
||||
|
||||
@@ -212,9 +212,8 @@ To enable workers, add the following to your values.yaml:
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2 # Scale based on workload
|
||||
jobType: "vacuum,volume_balance,erasure_coding" # Job types this worker can handle
|
||||
maxDetect: 1 # Maximum concurrent detection requests
|
||||
maxExecute: 4 # Maximum concurrent execution jobs per worker
|
||||
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"
|
||||
@@ -249,14 +248,14 @@ worker:
|
||||
memory: "2Gi"
|
||||
```
|
||||
|
||||
### Worker Job Types
|
||||
### Worker Capabilities
|
||||
|
||||
Workers can be configured with different job types:
|
||||
Workers can be configured with different capabilities:
|
||||
- **vacuum**: Reclaim deleted file space
|
||||
- **volume_balance**: Balance volumes across volume servers
|
||||
- **balance**: Balance volumes across volume servers
|
||||
- **erasure_coding**: Handle erasure coding operations
|
||||
|
||||
You can configure workers with all job types or create specialized worker pools with specific job types.
|
||||
You can configure workers with all capabilities or create specialized worker pools with specific capabilities.
|
||||
|
||||
### Worker Deployment Strategy
|
||||
|
||||
@@ -265,11 +264,11 @@ 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 job types if needed
|
||||
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 job types:
|
||||
For specialized worker pools, deploy separate Helm releases with different capabilities:
|
||||
|
||||
**values-worker-vacuum.yaml** (for vacuum operations):
|
||||
```yaml
|
||||
@@ -288,8 +287,8 @@ admin:
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2
|
||||
jobType: "vacuum"
|
||||
maxExecute: 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":
|
||||
@@ -314,8 +313,8 @@ admin:
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
jobType: "volume_balance"
|
||||
maxExecute: 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":
|
||||
@@ -324,7 +323,6 @@ worker:
|
||||
```
|
||||
|
||||
Deploy the specialized workers as separate releases:
|
||||
### Specialized Worker Deployment
|
||||
```bash
|
||||
# Deploy vacuum workers
|
||||
helm install seaweedfs-worker-vacuum seaweedfs/seaweedfs -f values-worker-vacuum.yaml
|
||||
@@ -333,22 +331,6 @@ helm install seaweedfs-worker-vacuum seaweedfs/seaweedfs -f values-worker-vacuum
|
||||
helm install seaweedfs-worker-balance seaweedfs/seaweedfs -f values-worker-balance.yaml
|
||||
```
|
||||
|
||||
## OpenShift Support
|
||||
|
||||
SeaweedFS can be deployed on OpenShift or any cluster enforcing the Kubernetes "restricted" Pod Security Standard. By default, OpenShift blocks containers that run as root or use `hostPath` volumes.
|
||||
|
||||
To deploy on OpenShift, use the provided `openshift-values.yaml` which overrides the default configuration to:
|
||||
1. Use `PersistentVolumeClaims` instead of `hostPath`.
|
||||
2. Enable `runAsNonRoot` and omit hardcoded UIDs to allow OpenShift to assign valid UIDs automatically.
|
||||
3. Apply appropriate `seccompProfile` and drop capabilities.
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
helm install seaweedfs seaweedfs/seaweedfs \
|
||||
-n seaweedfs --create-namespace \
|
||||
-f openshift-values.yaml
|
||||
```
|
||||
|
||||
## Enterprise
|
||||
|
||||
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# openshift-values.yaml
|
||||
#
|
||||
# Example overrides for deploying SeaweedFS on OpenShift (or any cluster
|
||||
# enforcing the Kubernetes "restricted" Pod Security Standard).
|
||||
#
|
||||
# OpenShift's default "restricted" SCC blocks containers that:
|
||||
# - Run as UID 0 (root)
|
||||
# - Request privilege escalation
|
||||
# - Use hostPath volumes
|
||||
# - Omit a seccompProfile
|
||||
#
|
||||
# These overrides satisfy all four requirements by:
|
||||
# 1. Replacing hostPath volumes with PersistentVolumeClaims (or emptyDir for logs)
|
||||
# 2. Enabling runAsNonRoot: true. By omitting runAsUser, OpenShift will
|
||||
# automatically assign a valid UID from the namespace's allocated range.
|
||||
# 3. Dropping all Linux capabilities and setting allowPrivilegeEscalation: false
|
||||
# 4. Enabling RuntimeDefault seccompProfile
|
||||
#
|
||||
# Usage:
|
||||
# helm install seaweedfs seaweedfs/seaweedfs \
|
||||
# -n seaweedfs --create-namespace \
|
||||
# -f openshift-values.yaml
|
||||
#
|
||||
# Adjust storageClass and sizes to match your cluster's available StorageClasses.
|
||||
# On OpenShift you can discover them with: oc get storageclass
|
||||
|
||||
master:
|
||||
data:
|
||||
type: "persistentVolumeClaim"
|
||||
size: "10Gi"
|
||||
storageClass: "" # leave empty to use the cluster default StorageClass
|
||||
|
||||
logs:
|
||||
type: "emptyDir" # avoids hostPath; use persistentVolumeClaim if you need log persistence
|
||||
|
||||
podSecurityContext:
|
||||
enabled: true
|
||||
# On OpenShift, we omit runAsUser/runAsGroup/fsGroup to let the admission
|
||||
# controller assign them automatically based on the namespace's SCC.
|
||||
runAsNonRoot: true
|
||||
|
||||
containerSecurityContext:
|
||||
enabled: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
volume:
|
||||
dataDirs:
|
||||
- name: data1
|
||||
type: "persistentVolumeClaim"
|
||||
size: "100Gi"
|
||||
storageClass: "" # leave empty to use the cluster default StorageClass
|
||||
maxVolumes: 0
|
||||
|
||||
logs:
|
||||
type: "emptyDir"
|
||||
|
||||
podSecurityContext:
|
||||
enabled: true
|
||||
# On OpenShift, we omit runAsUser/runAsGroup/fsGroup to let the admission
|
||||
# controller assign them automatically based on the namespace's SCC.
|
||||
runAsNonRoot: true
|
||||
|
||||
containerSecurityContext:
|
||||
enabled: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
filer:
|
||||
data:
|
||||
type: "persistentVolumeClaim"
|
||||
size: "25Gi"
|
||||
storageClass: "" # leave empty to use the cluster default StorageClass
|
||||
|
||||
logs:
|
||||
type: "emptyDir"
|
||||
|
||||
podSecurityContext:
|
||||
enabled: true
|
||||
# On OpenShift, we omit runAsUser/runAsGroup/fsGroup to let the admission
|
||||
# controller assign them automatically based on the namespace's SCC.
|
||||
runAsNonRoot: true
|
||||
|
||||
containerSecurityContext:
|
||||
enabled: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# S3 gateway (if enabled)
|
||||
s3:
|
||||
podSecurityContext:
|
||||
enabled: true
|
||||
# On OpenShift, we omit runAsUser/runAsGroup/fsGroup to let the admission
|
||||
# controller assign them automatically based on the namespace's SCC.
|
||||
runAsNonRoot: true
|
||||
|
||||
containerSecurityContext:
|
||||
enabled: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
@@ -10,21 +10,10 @@
|
||||
{{- if and .Values.s3.reuseLegacySecret $existingSecret }}
|
||||
{{- $reuse = true }}
|
||||
{{- end }}
|
||||
{{- $creds := .Values.s3.credentials | default dict -}}
|
||||
{{- $adminCreds := $creds.admin | default dict -}}
|
||||
{{- $access_key_admin := $adminCreds.accessKey -}}
|
||||
{{- $secret_key_admin := $adminCreds.secretKey -}}
|
||||
{{- if not (and $access_key_admin $secret_key_admin) -}}
|
||||
{{- $access_key_admin = include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_admin = include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_secret_access_key" "length" 40 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- end -}}
|
||||
{{- $readCreds := $creds.read | default dict -}}
|
||||
{{- $access_key_read := $readCreds.accessKey -}}
|
||||
{{- $secret_key_read := $readCreds.secretKey -}}
|
||||
{{- if not (and $access_key_read $secret_key_read) -}}
|
||||
{{- $access_key_read = include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_read = include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_secret_access_key" "length" 40 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- end -}}
|
||||
{{- $access_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_secret_access_key" "length" 40 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $access_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_secret_access_key" "length" 40 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: Opaque
|
||||
@@ -54,4 +43,4 @@ stringData:
|
||||
s3_auditLogConfig.json: |
|
||||
{{ toJson .Values.s3.auditLogConfig | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -6,7 +6,6 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: sftp
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
|
||||
@@ -135,9 +135,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- $repositoryName := default .Values.image.repository .Values.global.repository | toString -}}
|
||||
{{- $name := .Values.global.imageName | toString -}}
|
||||
{{- $tag := default .Chart.AppVersion .Values.image.tag | toString -}}
|
||||
{{- if .Values.image.repository -}}
|
||||
{{- $name = $repositoryName -}}
|
||||
{{- else if $repositoryName -}}
|
||||
{{- if $repositoryName -}}
|
||||
{{- $name = printf "%s/%s" (trimSuffix "/" $repositoryName) (base $name) -}}
|
||||
{{- end -}}
|
||||
{{- if $registryName -}}
|
||||
|
||||
@@ -12,7 +12,6 @@ metadata:
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" $ }}
|
||||
app.kubernetes.io/instance: {{ $.Release.Name }}
|
||||
app.kubernetes.io/component: {{ $volumeName }}
|
||||
helm.sh/chart: {{ $.Chart.Name }}-{{ $.Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/managed-by: {{ $.Release.Service }}
|
||||
|
||||
@@ -134,17 +134,13 @@ spec:
|
||||
{{- if .Values.worker.adminServer }}
|
||||
-admin={{ .Values.worker.adminServer }} \
|
||||
{{- else }}
|
||||
-admin={{ template "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.port }}{{ if .Values.admin.grpcPort }}.{{ .Values.admin.grpcPort }}{{ end }} \
|
||||
-admin={{ include "seaweedfs.componentName" (list . "admin") }}.{{ .Release.Namespace }}:{{ .Values.admin.port }}{{ if .Values.admin.grpcPort }}.{{ .Values.admin.grpcPort }}{{ end }} \
|
||||
{{- end }}
|
||||
-jobType={{ .Values.worker.jobType }} \
|
||||
-maxDetect={{ .Values.worker.maxDetect }} \
|
||||
-maxExecute={{ .Values.worker.maxExecute }} \
|
||||
-workingDir={{ .Values.worker.workingDir }}{{- if or .Values.worker.metricsPort .Values.worker.metricsIp .Values.worker.extraArgs }} \{{ end }}
|
||||
-capabilities={{ .Values.worker.capabilities }} \
|
||||
-maxConcurrent={{ .Values.worker.maxConcurrent }} \
|
||||
-workingDir={{ .Values.worker.workingDir }}{{- if or .Values.worker.metricsPort .Values.worker.extraArgs }} \{{ end }}
|
||||
{{- if .Values.worker.metricsPort }}
|
||||
-metricsPort={{ .Values.worker.metricsPort }}{{- if or .Values.worker.metricsIp .Values.worker.extraArgs }} \{{ end }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.metricsIp }}
|
||||
-metricsIp={{ .Values.worker.metricsIp }}{{- if .Values.worker.extraArgs }} \{{ end }}
|
||||
-metricsPort={{ .Values.worker.metricsPort }}{{- if .Values.worker.extraArgs }} \{{ end }}
|
||||
{{- end }}
|
||||
{{- range $index, $arg := .Values.worker.extraArgs }}
|
||||
{{ $arg }}{{- if lt $index (sub (len $.Values.worker.extraArgs) 1) }} \{{ end }}
|
||||
|
||||
@@ -890,10 +890,6 @@ filer:
|
||||
# set to the name of an existing kubernetes Secret with the s3 json config file
|
||||
# should have a secret key called seaweedfs_s3_config with an inline json configure
|
||||
existingConfigSecret: null
|
||||
# To provide explicit credentials for the S3 gateway, set them under
|
||||
# the top-level s3.credentials key (not filer.s3.credentials).
|
||||
# The s3-secret.yaml template only reads from .Values.s3.credentials.
|
||||
# See: s3.credentials.admin.accessKey, s3.credentials.read.accessKey
|
||||
auditLogConfig: {}
|
||||
# You may specify buckets to be created during the install or upgrade process.
|
||||
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
|
||||
@@ -922,16 +918,6 @@ s3:
|
||||
# set to the name of an existing kubernetes Secret with the s3 json config file
|
||||
# should have a secret key called seaweedfs_s3_config with an inline json config
|
||||
existingConfigSecret: null
|
||||
# Optionally provide explicit credentials for the S3 gateway.
|
||||
# When set, these are used in the generated s3 secret instead of
|
||||
# auto-generating random credentials.
|
||||
# credentials:
|
||||
# admin:
|
||||
# accessKey: ""
|
||||
# secretKey: ""
|
||||
# read:
|
||||
# accessKey: ""
|
||||
# secretKey: ""
|
||||
auditLogConfig: {}
|
||||
# You may specify buckets to be created during the install or upgrade process.
|
||||
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
|
||||
@@ -1284,20 +1270,17 @@ worker:
|
||||
replicas: 1
|
||||
loggingOverrideLevel: null
|
||||
metricsPort: 9327
|
||||
metricsIp: "" # If empty, defaults to 0.0.0.0
|
||||
|
||||
# Admin server to connect to
|
||||
adminServer: ""
|
||||
|
||||
# Worker job types - comma-separated list
|
||||
# Available: vacuum, volume_balance, erasure_coding
|
||||
jobType: "vacuum,volume_balance,erasure_coding"
|
||||
# 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 detection requests
|
||||
maxDetect: 1
|
||||
|
||||
# Maximum number of concurrent execution jobs
|
||||
maxExecute: 4
|
||||
# Maximum number of concurrent tasks
|
||||
maxConcurrent: 3
|
||||
|
||||
# Working directory for task execution
|
||||
workingDir: "/tmp/seaweedfs-worker"
|
||||
@@ -1435,10 +1418,6 @@ allInOne:
|
||||
# Set to the name of an existing kubernetes Secret with the s3 json config file
|
||||
# should have a secret key called seaweedfs_s3_config with an inline json config
|
||||
existingConfigSecret: null
|
||||
# To provide explicit credentials for the S3 gateway, set them under
|
||||
# the top-level s3.credentials key (not allInOne.s3.credentials).
|
||||
# The s3-secret.yaml template only reads from .Values.s3.credentials.
|
||||
# See: s3.credentials.admin.accessKey, s3.credentials.read.accessKey
|
||||
auditLogConfig: null # S3 audit log configuration (null inherits from s3.auditLogConfig)
|
||||
# You may specify buckets to be created during the install process.
|
||||
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
|
||||
|
||||
@@ -2,11 +2,13 @@ package admin_dockertest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
crand "crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -159,95 +161,129 @@ func TestEcEndToEnd(t *testing.T) {
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// 1. Configure plugin job types for fast EC detection/execution.
|
||||
t.Log("Configuring plugin job types via API...")
|
||||
// 1. Configure Global Maintenance (Scan Interval = 1s) via API
|
||||
t.Log("Configuring Global Maintenance via API...")
|
||||
|
||||
// Disable volume balance to reduce interference for this EC-focused test.
|
||||
balanceConfig := map[string]interface{}{
|
||||
"job_type": "volume_balance",
|
||||
"admin_runtime": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
}
|
||||
jsonBody, err := json.Marshal(balanceConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal volume_balance config: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest("PUT", AdminUrl+"/api/plugin/job-types/volume_balance/config", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create volume_balance config request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// 1.1 Fetch current config
|
||||
req, _ := http.NewRequest("GET", AdminUrl+"/api/maintenance/config", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update volume_balance config: %v", err)
|
||||
t.Fatalf("Failed to get global config: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Failed to update volume_balance config (status %d): %s", resp.StatusCode, string(body))
|
||||
t.Fatalf("Failed to get global config (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var globalConfig map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&globalConfig); err != nil {
|
||||
t.Fatalf("Failed to decode global config: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
ecConfig := map[string]interface{}{
|
||||
"job_type": "erasure_coding",
|
||||
"admin_runtime": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"detection_interval_seconds": 1,
|
||||
"global_execution_concurrency": 4,
|
||||
"per_worker_execution_concurrency": 4,
|
||||
"max_jobs_per_detection": 100,
|
||||
},
|
||||
"worker_config_values": map[string]interface{}{
|
||||
"quiet_for_seconds": map[string]interface{}{
|
||||
"int64_value": "1",
|
||||
},
|
||||
"min_interval_seconds": map[string]interface{}{
|
||||
"int64_value": "1",
|
||||
},
|
||||
"min_size_mb": map[string]interface{}{
|
||||
"int64_value": "1",
|
||||
},
|
||||
"fullness_ratio": map[string]interface{}{
|
||||
"double_value": 0.0001,
|
||||
},
|
||||
},
|
||||
// 1.2 Modify config
|
||||
globalConfig["enabled"] = true
|
||||
globalConfig["scan_interval_seconds"] = 1
|
||||
|
||||
// Ensure policy structure exists
|
||||
if globalConfig["policy"] == nil {
|
||||
globalConfig["policy"] = map[string]interface{}{}
|
||||
}
|
||||
jsonBody, err = json.Marshal(ecConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal erasure_coding config: %v", err)
|
||||
policy, _ := globalConfig["policy"].(map[string]interface{})
|
||||
|
||||
// Ensure task_policies structure exists
|
||||
if policy["task_policies"] == nil {
|
||||
policy["task_policies"] = map[string]interface{}{}
|
||||
}
|
||||
req, err = http.NewRequest("PUT", AdminUrl+"/api/plugin/job-types/erasure_coding/config", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create erasure_coding config request: %v", err)
|
||||
taskPolicies, _ := policy["task_policies"].(map[string]interface{})
|
||||
|
||||
// Disable balance tasks to avoid interference with EC test
|
||||
if taskPolicies["balance"] == nil {
|
||||
taskPolicies["balance"] = map[string]interface{}{}
|
||||
}
|
||||
balancePolicy, _ := taskPolicies["balance"].(map[string]interface{})
|
||||
balancePolicy["enabled"] = false
|
||||
|
||||
// Set global max concurrent
|
||||
policy["global_max_concurrent"] = 4
|
||||
globalConfig["policy"] = policy
|
||||
|
||||
// Explicitly set required fields
|
||||
requiredFields := map[string]float64{
|
||||
"worker_timeout_seconds": 300,
|
||||
"task_timeout_seconds": 7200,
|
||||
"retry_delay_seconds": 900,
|
||||
"cleanup_interval_seconds": 86400,
|
||||
"task_retention_seconds": 604800,
|
||||
"max_retries": 3,
|
||||
}
|
||||
for field, val := range requiredFields {
|
||||
if _, ok := globalConfig[field]; !ok || globalConfig[field] == 0 {
|
||||
globalConfig[field] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 1.3 Update config
|
||||
jsonBody, _ := json.Marshal(globalConfig)
|
||||
req, _ = http.NewRequest("PUT", AdminUrl+"/api/maintenance/config", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update erasure_coding config: %v", err)
|
||||
t.Fatalf("Failed to update global config: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Failed to update erasure_coding config (status %d): %s", resp.StatusCode, string(body))
|
||||
t.Fatalf("Failed to update global config (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 2. Upload a file
|
||||
// 2. Configure EC Task (Short intervals) via Form API
|
||||
t.Log("Configuring EC Task via Form API...")
|
||||
formData := url.Values{}
|
||||
formData.Set("enabled", "true")
|
||||
formData.Set("scan_interval_seconds", "1")
|
||||
formData.Set("repeat_interval_seconds", "1")
|
||||
formData.Set("check_interval_seconds", "1")
|
||||
formData.Set("max_concurrent", "4")
|
||||
formData.Set("quiet_for_seconds_value", "1")
|
||||
formData.Set("quiet_for_seconds_unit", "seconds")
|
||||
formData.Set("min_size_mb", "1")
|
||||
formData.Set("fullness_ratio", "0.0001")
|
||||
|
||||
req, _ = http.NewRequest("POST", AdminUrl+"/maintenance/config/erasure_coding", strings.NewReader(formData.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update EC config: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 303 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Failed to update EC config (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
resp.Body.Close()
|
||||
t.Log("EC Task Configuration updated")
|
||||
|
||||
// 3. Restart Admin to pick up Global Config (Scan Interval)
|
||||
if len(runningCmds) > 0 {
|
||||
adminCmd := runningCmds[len(runningCmds)-1]
|
||||
t.Log("Restarting Admin Server to apply configuration...")
|
||||
stopWeed(t, adminCmd)
|
||||
time.Sleep(10 * time.Second)
|
||||
startWeed(t, "admin_restarted", "admin", "-master=localhost:9333", "-port=23646", "-port.grpc=33646", "-dataDir=./tmp/admin")
|
||||
waitForUrl(t, AdminUrl+"/health", 60)
|
||||
}
|
||||
|
||||
// 4. Upload a file
|
||||
fileSize := 5 * 1024 * 1024
|
||||
data := make([]byte, fileSize)
|
||||
crand.Read(data)
|
||||
rand.Read(data)
|
||||
fileName := fmt.Sprintf("ec_test_file_%d", time.Now().Unix())
|
||||
t.Logf("Uploading %d bytes file %s to Filer...", fileSize, fileName)
|
||||
uploadUrl := FilerUrl + "/" + fileName
|
||||
|
||||
var uploadErr error
|
||||
for i := 0; i < 10; i++ {
|
||||
req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
uploadErr = err
|
||||
t.Logf("Upload attempt %d failed to create request: %v", i+1, err)
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
req, _ := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(data))
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
if resp.StatusCode == 201 {
|
||||
@@ -270,17 +306,17 @@ func TestEcEndToEnd(t *testing.T) {
|
||||
}
|
||||
t.Log("Upload successful")
|
||||
|
||||
// 3. Verify EC Encoding
|
||||
// 5. Verify EC Encoding
|
||||
t.Log("Waiting for EC encoding (checking Master topology)...")
|
||||
startTime := time.Now()
|
||||
ecVerified := false
|
||||
var lastBody []byte
|
||||
|
||||
for time.Since(startTime) < 300*time.Second {
|
||||
// 3.1 Check Master Topology
|
||||
// 5.1 Check Master Topology
|
||||
resp, err := http.Get(MasterUrl + "/dir/status")
|
||||
if err == nil {
|
||||
lastBody, _ = io.ReadAll(resp.Body)
|
||||
lastBody, _ = ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// Check total EC shards
|
||||
@@ -300,8 +336,8 @@ func TestEcEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.2 Debug: Check workers and jobs
|
||||
wResp, wErr := http.Get(AdminUrl + "/api/plugin/workers")
|
||||
// 5.2 Debug: Check workers and tasks
|
||||
wResp, wErr := http.Get(AdminUrl + "/api/maintenance/workers")
|
||||
workerCount := 0
|
||||
if wErr == nil {
|
||||
var workers []interface{}
|
||||
@@ -310,7 +346,7 @@ func TestEcEndToEnd(t *testing.T) {
|
||||
workerCount = len(workers)
|
||||
}
|
||||
|
||||
tResp, tErr := http.Get(AdminUrl + "/api/plugin/jobs?limit=1000")
|
||||
tResp, tErr := http.Get(AdminUrl + "/api/maintenance/tasks")
|
||||
taskCount := 0
|
||||
if tErr == nil {
|
||||
var tasks []interface{}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build foundationdb
|
||||
// +build foundationdb
|
||||
|
||||
package foundationdb
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build foundationdb
|
||||
// +build foundationdb
|
||||
|
||||
package foundationdb
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
seaweedfs-volume:
|
||||
build:
|
||||
context: ../../../docker
|
||||
dockerfile: Dockerfile.local
|
||||
image: seaweedfs:local
|
||||
container_name: seaweedfs-spark-volume
|
||||
ports:
|
||||
@@ -40,6 +43,9 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
seaweedfs-filer:
|
||||
build:
|
||||
context: ../../../docker
|
||||
dockerfile: Dockerfile.local
|
||||
image: seaweedfs:local
|
||||
container_name: seaweedfs-spark-filer
|
||||
ports:
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.avro</groupId>
|
||||
<artifactId>avro</artifactId>
|
||||
<version>1.11.5</version>
|
||||
<version>1.11.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Apache ZooKeeper - Fix CVEs -->
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.avro</groupId>
|
||||
<artifactId>avro</artifactId>
|
||||
<version>1.11.5</version>
|
||||
<version>1.11.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
|
||||
@@ -12,7 +12,6 @@ FILER_PORT := 8888
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN := TestCORS
|
||||
SERVER_DIR := test-mini-data
|
||||
S3_CONFIG := s3_test_config.json
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -81,15 +80,13 @@ start-server: check-deps
|
||||
@echo "🔍 DEBUG: Creating volume directory..."
|
||||
@mkdir -p $(SERVER_DIR)
|
||||
@echo "🔍 DEBUG: Launching SeaweedFS S3 server in background..."
|
||||
@echo "🔍 DEBUG: Command: AWS_ACCESS_KEY_ID=some_access_key1 AWS_SECRET_ACCESS_KEY=some_secret_key1 $(WEED_BINARY) mini -dir=$(SERVER_DIR) -s3.port=$(S3_PORT) -s3.config=$(S3_CONFIG)"
|
||||
@env AWS_ACCESS_KEY_ID=some_access_key1 \
|
||||
AWS_SECRET_ACCESS_KEY=some_secret_key1 \
|
||||
$(WEED_BINARY) mini \
|
||||
@echo "🔍 DEBUG: Command: $(WEED_BINARY) mini -dir=$(SERVER_DIR) -s3.port=$(S3_PORT) -s3.config=$(S3_CONFIG)"
|
||||
@$(WEED_BINARY) mini \
|
||||
-dir=$(SERVER_DIR) \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.config=$(S3_CONFIG) \
|
||||
> weed-test.log 2>&1 & \
|
||||
echo $$! > weed-server.pid
|
||||
echo $$! > weed-test.pid
|
||||
|
||||
@echo "Waiting for S3 server to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
@@ -100,7 +97,7 @@ start-server: check-deps
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "S3 server failed to start"; \
|
||||
exit 1
|
||||
exit 1 > weed-server.pid
|
||||
@echo "🔍 DEBUG: Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')"
|
||||
@echo "🔍 DEBUG: Checking if PID is still running..."
|
||||
@sleep 2
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "anonymous",
|
||||
"actions": [
|
||||
"Read",
|
||||
"List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "some_access_key1",
|
||||
"secretKey": "some_secret_key1"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Admin",
|
||||
"Read",
|
||||
"List",
|
||||
"Tagging",
|
||||
"Write"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -203,116 +202,6 @@ func TestIAMPolicyManagement(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("managed_policy_crud_lifecycle", func(t *testing.T) {
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}`
|
||||
|
||||
policyNames := []string{"test-managed-policy-lifecycle-a", "test-managed-policy-lifecycle-b"}
|
||||
policyArns := make([]*string, 0, len(policyNames))
|
||||
for _, policyName := range policyNames {
|
||||
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policyArns = append(policyArns, createResp.Policy.Arn)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
for _, policyArn := range policyArns {
|
||||
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn})
|
||||
}
|
||||
})
|
||||
|
||||
listResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
foundByName := map[string]bool{}
|
||||
for _, policy := range listResp.Policies {
|
||||
if policy.PolicyName != nil {
|
||||
foundByName[*policy.PolicyName] = true
|
||||
}
|
||||
}
|
||||
for _, policyName := range policyNames {
|
||||
assert.True(t, foundByName[policyName], "policy %s should be listed", policyName)
|
||||
}
|
||||
|
||||
getResp, err := iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getResp.Policy)
|
||||
assert.Equal(t, policyNames[0], aws.StringValue(getResp.Policy.PolicyName))
|
||||
assert.Equal(t, aws.StringValue(policyArns[0]), aws.StringValue(getResp.Policy.Arn))
|
||||
|
||||
_, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArns[0]})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
|
||||
listAfterDeleteResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
deletedPolicyFound := false
|
||||
remainingPolicyFound := false
|
||||
for _, policy := range listAfterDeleteResp.Policies {
|
||||
if policy.PolicyName == nil {
|
||||
continue
|
||||
}
|
||||
if *policy.PolicyName == policyNames[0] {
|
||||
deletedPolicyFound = true
|
||||
}
|
||||
if *policy.PolicyName == policyNames[1] {
|
||||
remainingPolicyFound = true
|
||||
}
|
||||
}
|
||||
assert.False(t, deletedPolicyFound, "deleted policy should no longer be listed")
|
||||
assert.True(t, remainingPolicyFound, "remaining policy should still be listed")
|
||||
|
||||
policyArns[0] = nil
|
||||
})
|
||||
|
||||
t.Run("managed_policy_versions", func(t *testing.T) {
|
||||
policyName := "test-managed-policy-version"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
|
||||
|
||||
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createResp.Policy.Arn})
|
||||
})
|
||||
|
||||
listVersionsResp, err := iamClient.ListPolicyVersions(&iam.ListPolicyVersionsInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, listVersionsResp.Versions)
|
||||
assert.Equal(t, "v1", aws.StringValue(listVersionsResp.Versions[0].VersionId))
|
||||
assert.Equal(t, true, aws.BoolValue(listVersionsResp.Versions[0].IsDefaultVersion))
|
||||
|
||||
getVersionResp, err := iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
VersionId: aws.String("v1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getVersionResp.PolicyVersion)
|
||||
assert.Equal(t, "v1", aws.StringValue(getVersionResp.PolicyVersion.VersionId))
|
||||
assert.Contains(t, aws.StringValue(getVersionResp.PolicyVersion.Document), "s3:ListBucket")
|
||||
|
||||
_, err = iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
VersionId: aws.String("v2"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
})
|
||||
|
||||
t.Run("user_inline_policy", func(t *testing.T) {
|
||||
userName := "test-user-policy"
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSTSTemporaryCredentialPrefix verifies that STS temporary credentials use ASIA prefix
|
||||
// This test ensures AWS compatibility - temporary credentials should use ASIA, not AKIA
|
||||
func TestSTSTemporaryCredentialPrefix(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Use test credentials from environment or fall back to defaults
|
||||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
|
||||
t.Run("assume_role_returns_asia_prefix", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"asia-prefix-test"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
t.Skip("AssumeRole not fully implemented yet")
|
||||
}
|
||||
|
||||
var stsResp AssumeRoleTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
require.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
|
||||
// Verify ASIA prefix for temporary credentials
|
||||
assert.True(t, strings.HasPrefix(creds.AccessKeyId, "ASIA"),
|
||||
"Temporary credentials must use ASIA prefix (not AKIA for permanent keys), got: %s", creds.AccessKeyId)
|
||||
|
||||
// Verify it's NOT using AKIA (permanent credentials)
|
||||
assert.False(t, strings.HasPrefix(creds.AccessKeyId, "AKIA"),
|
||||
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys), got: %s", creds.AccessKeyId)
|
||||
|
||||
// Verify format: ASIA + 16 hex characters = 20 chars total
|
||||
assert.Equal(t, 20, len(creds.AccessKeyId),
|
||||
"Access key ID should be 20 characters (ASIA + 16 hex chars), got: %s", creds.AccessKeyId)
|
||||
|
||||
t.Logf("✓ Temporary credentials correctly use ASIA prefix: %s", creds.AccessKeyId)
|
||||
})
|
||||
|
||||
t.Run("assume_role_with_web_identity_returns_asia_prefix", func(t *testing.T) {
|
||||
// This test would require OIDC setup, so we'll skip it for now
|
||||
// but the same ASIA prefix validation should apply
|
||||
t.Skip("AssumeRoleWithWebIdentity requires OIDC provider setup")
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,8 +22,10 @@ func TestIAMOperations(t *testing.T) {
|
||||
// Set credentials before starting cluster
|
||||
accessKey := "testkey123"
|
||||
secretKey := "testsecret456"
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", accessKey)
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", secretKey)
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", accessKey)
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", secretKey)
|
||||
defer os.Unsetenv("AWS_ACCESS_KEY_ID")
|
||||
defer os.Unsetenv("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
// Create and start test cluster
|
||||
cluster, err := startMiniCluster(t)
|
||||
|
||||
@@ -3,8 +3,6 @@ package example
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
@@ -17,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
@@ -71,22 +68,6 @@ func TestS3Integration(t *testing.T) {
|
||||
testPutObject(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("UploadPart", func(t *testing.T) {
|
||||
testPutPartWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("PutObjectWithChecksum", func(t *testing.T) {
|
||||
testPutObjectWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("UploadPartWithChecksum", func(t *testing.T) {
|
||||
testUploadPartWithChecksum(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("PutObjectWithChecksumAndSSEC", func(t *testing.T) {
|
||||
testPutObjectWithChecksumAndSSEC(t, cluster)
|
||||
})
|
||||
|
||||
t.Run("GetObject", func(t *testing.T) {
|
||||
testGetObject(t, cluster)
|
||||
})
|
||||
@@ -116,9 +97,8 @@ func findAvailablePort() (int, error) {
|
||||
return addr.Port, nil
|
||||
}
|
||||
|
||||
// startMiniCluster starts a weed mini instance directly without exec.
|
||||
// Extra flags (e.g. "-s3.allowDeleteBucketNotEmpty=false") can be appended via extraArgs.
|
||||
func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
// startMiniCluster starts a weed mini instance directly without exec
|
||||
func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
// Find available ports
|
||||
masterPort, err := findAvailablePort()
|
||||
if err != nil {
|
||||
@@ -166,14 +146,6 @@ func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
return nil, fmt.Errorf("failed to create security.toml: %v", err)
|
||||
}
|
||||
|
||||
// Set environment variables for admin credentials safely for this test
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
// Start weed mini in a goroutine by calling the command directly
|
||||
cluster.wg.Add(1)
|
||||
go func() {
|
||||
@@ -193,7 +165,7 @@ func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
// Configure args for mini command
|
||||
// Note: When running via 'go test', os.Args[0] is the test binary
|
||||
// We need to make it look like we're running 'weed mini'
|
||||
os.Args = append([]string{
|
||||
os.Args = []string{
|
||||
"weed",
|
||||
"-dir=" + testDir,
|
||||
"-master.port=" + strconv.Itoa(masterPort),
|
||||
@@ -206,7 +178,7 @@ func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
"-ip=127.0.0.1",
|
||||
"-master.peers=none", // Faster startup
|
||||
"-s3.iam.readOnly=false", // Enable IAM write operations for tests
|
||||
}, extraArgs...)
|
||||
}
|
||||
|
||||
// Suppress most logging during tests
|
||||
glog.MaxSize = 1024 * 1024
|
||||
@@ -364,282 +336,6 @@ func testPutObject(t *testing.T, cluster *TestCluster) {
|
||||
t.Logf("✓ Put object: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
func createTestBucket(t *testing.T, cluster *TestCluster, prefix string) string {
|
||||
bucketName := prefix + randomString(8)
|
||||
_, err := cluster.s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return bucketName
|
||||
}
|
||||
|
||||
// generateSSECKey returns a 32-byte key as a raw string (what the SDK expects
|
||||
// for SSECustomerKey) and its base64-encoded MD5 (for SSECustomerKeyMD5).
|
||||
func generateSSECKey() (keyRaw, keyMD5B64 string) {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(rng.Intn(256))
|
||||
}
|
||||
keyRaw = string(key)
|
||||
keyHash := md5.Sum(key)
|
||||
keyMD5B64 = base64.StdEncoding.EncodeToString(keyHash[:])
|
||||
return
|
||||
}
|
||||
|
||||
func testPutObjectWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-")
|
||||
objectKey := "test-checksummed-object.txt"
|
||||
objectData := "Hello, SeaweedFS S3!"
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
|
||||
// Put object with incorrect MD5 should be rejected
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "PutObject should fail with incorrect MD5")
|
||||
|
||||
t.Logf("✓ Put object with incorrect MD5 rejected: %s/%s", bucketName, objectKey)
|
||||
|
||||
// Put object with correct MD5 should succeed
|
||||
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err, "Failed to put object")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Put object with correct MD5: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
// putObjectSSEC sends a PutObject request with SSE-C headers over HTTP.
|
||||
// The AWS SDK v1 refuses to send SSE-C keys over plain HTTP, so we use the
|
||||
// low-level Request API and clear the Validate handlers to bypass that check.
|
||||
// We use Clear() because the specific validator is internal and not easily removable by name.
|
||||
func putObjectSSEC(client *s3.S3, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
req, output := client.PutObjectRequest(input)
|
||||
req.Handlers.Validate.Clear()
|
||||
err := req.Send()
|
||||
return output, err
|
||||
}
|
||||
|
||||
func headObjectSSEC(client *s3.S3, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
req, output := client.HeadObjectRequest(input)
|
||||
req.Handlers.Validate.Clear()
|
||||
err := req.Send()
|
||||
return output, err
|
||||
}
|
||||
|
||||
func testPutObjectWithChecksumAndSSEC(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-ssec-")
|
||||
objectKey := "test-checksummed-ssec-object.txt"
|
||||
objectData := "Hello, SeaweedFS S3 with SSE-C!"
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
keyRaw, keyMD5B64 := generateSSECKey()
|
||||
|
||||
// Put object with SSE-C and incorrect MD5 should be rejected
|
||||
_, err := putObjectSSEC(cluster.s3Client, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
assertBadDigestError(t, err, "PutObject with SSE-C should fail with incorrect MD5")
|
||||
|
||||
t.Logf("Put object with SSE-C and incorrect MD5 rejected: %s/%s", bucketName, objectKey)
|
||||
|
||||
// Put object with SSE-C and correct MD5 should succeed
|
||||
_, err = putObjectSSEC(cluster.s3Client, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
require.NoError(t, err, "Failed to put object with SSE-C and correct MD5")
|
||||
|
||||
// Verify object exists (SSE-C requires the key for HeadObject too)
|
||||
headResp, err := headObjectSSEC(cluster.s3Client, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
SSECustomerAlgorithm: aws.String("AES256"),
|
||||
SSECustomerKey: aws.String(keyRaw),
|
||||
SSECustomerKeyMD5: aws.String(keyMD5B64),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, headResp.ContentLength)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("Put object with SSE-C and correct MD5: %s/%s (%d bytes)", bucketName, objectKey, len(objectData))
|
||||
}
|
||||
|
||||
func testUploadPartWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-upload-part-checksum-")
|
||||
objectKey := "test-multipart-checksum.txt"
|
||||
objectData := "Hello, SeaweedFS S3 Multipart!"
|
||||
|
||||
// Initiate multipart upload
|
||||
initResp, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadID := initResp.UploadId
|
||||
|
||||
correctMD5 := calculateMd5(objectData)
|
||||
incorrectMD5 := calculateMd5(objectData + "incorrect")
|
||||
|
||||
// Upload part with incorrect MD5
|
||||
_, err = cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(1),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "UploadPart should fail with incorrect MD5")
|
||||
|
||||
// Upload part with correct MD5
|
||||
partResp, err := cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
PartNumber: aws.Int64(1),
|
||||
UploadId: uploadID,
|
||||
Body: bytes.NewReader([]byte(objectData)),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err, "Failed to upload part with correct MD5")
|
||||
|
||||
// Complete multipart upload
|
||||
_, err = cluster.s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{
|
||||
Parts: []*s3.CompletedPart{
|
||||
{
|
||||
ETag: partResp.ETag,
|
||||
PartNumber: aws.Int64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to complete multipart upload")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(objectData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ Multipart upload with checksum successful: %s/%s", bucketName, objectKey)
|
||||
}
|
||||
|
||||
func testPutPartWithChecksum(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := createTestBucket(t, cluster, "test-put-checksum-")
|
||||
objectKey := "test-checksummed-part.txt"
|
||||
|
||||
partData := "Hello, SeaweedFS S3!"
|
||||
|
||||
correctMD5 := calculateMd5(partData)
|
||||
incorrectMD5 := calculateMd5(partData + "incorrect")
|
||||
|
||||
createResp, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadID := createResp.UploadId
|
||||
|
||||
partBody := []byte(partData)
|
||||
|
||||
_, err = cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
PartNumber: aws.Int64(1),
|
||||
Body: bytes.NewReader(partBody),
|
||||
ContentMD5: aws.String(incorrectMD5),
|
||||
})
|
||||
assertBadDigestError(t, err, "UploadPart should fail with incorrect MD5")
|
||||
|
||||
uploadResp, err := cluster.s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
PartNumber: aws.Int64(1),
|
||||
Body: bytes.NewReader(partBody),
|
||||
ContentMD5: aws.String(correctMD5),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cluster.s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
UploadId: uploadID,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{
|
||||
Parts: []*s3.CompletedPart{
|
||||
{
|
||||
ETag: uploadResp.ETag,
|
||||
PartNumber: aws.Int64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to complete multipart upload")
|
||||
|
||||
// Verify object exists
|
||||
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(partData)), aws.Int64Value(headResp.ContentLength))
|
||||
|
||||
t.Logf("✓ UploadPart with MD5 validation: %s/%s", bucketName, objectKey)
|
||||
}
|
||||
|
||||
func calculateMd5(objectData string) string {
|
||||
dataBytes := []byte(objectData)
|
||||
hash := md5.Sum(dataBytes)
|
||||
return base64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func assertBadDigestError(t *testing.T, err error, description string) {
|
||||
require.Error(t, err, description)
|
||||
|
||||
var awsErr awserr.Error
|
||||
require.ErrorAs(t, err, &awsErr)
|
||||
assert.Equal(t, "BadDigest", awsErr.Code())
|
||||
}
|
||||
|
||||
func testGetObject(t *testing.T, cluster *TestCluster) {
|
||||
bucketName := "test-get-" + randomString(8)
|
||||
objectKey := "test-data.txt"
|
||||
@@ -780,49 +476,6 @@ func testDeleteBucket(t *testing.T, cluster *TestCluster) {
|
||||
t.Logf("✓ Deleted bucket: %s", bucketName)
|
||||
}
|
||||
|
||||
func TestS3DeleteBucketNotEmpty(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t, "-s3.allowDeleteBucketNotEmpty=false")
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
t.Run("DeleteNonEmptyBucketFails", func(t *testing.T) {
|
||||
bucketName := createTestBucket(t, cluster, "test-notempty-")
|
||||
objectKey := "keep-me.txt"
|
||||
|
||||
// Put an object so the bucket is non-empty
|
||||
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to delete the non-empty bucket — must fail with BucketNotEmpty (409)
|
||||
_, err = cluster.s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.Error(t, err, "deleting a non-empty bucket should fail")
|
||||
var awsErr awserr.Error
|
||||
require.ErrorAs(t, err, &awsErr)
|
||||
assert.Equal(t, "BucketNotEmpty", awsErr.Code(),
|
||||
"expected BucketNotEmpty error code, got %s: %s", awsErr.Code(), awsErr.Message())
|
||||
})
|
||||
|
||||
t.Run("DeleteEmptyBucketSucceeds", func(t *testing.T) {
|
||||
bucketName := createTestBucket(t, cluster, "test-empty-")
|
||||
|
||||
// Delete the empty bucket — should succeed even with the flag
|
||||
_, err := cluster.s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err, "deleting an empty bucket should succeed")
|
||||
})
|
||||
}
|
||||
|
||||
// randomString generates a random string for unique naming
|
||||
func randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
@@ -2,7 +2,6 @@ package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,11 +14,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/command"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
@@ -136,151 +130,6 @@ func TestS3PolicyShellRevised(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3IAMAttachDetachUserPolicy(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
policyName := uniqueName("managed-policy")
|
||||
policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
|
||||
policyContent := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
|
||||
tmpPolicyFile, err := os.CreateTemp("", "test_policy_attach_*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpPolicyFile.Name())
|
||||
_, err = tmpPolicyFile.WriteString(policyContent)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpPolicyFile.Close())
|
||||
|
||||
weedCmd := "weed"
|
||||
masterAddr := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filerAddr := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
execShell(t, weedCmd, masterAddr, filerAddr, fmt.Sprintf("s3.policy -put -name=%s -file=%s", policyName, tmpPolicyFile.Name()))
|
||||
|
||||
iamClient := newIAMClient(t, cluster.s3Endpoint)
|
||||
|
||||
userName := uniqueName("iam-user")
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyArn: aws.String(policyArn),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
listOut, err := iamClient.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, attachedPolicyContains(listOut.AttachedPolicies, policyName))
|
||||
|
||||
_, err = iamClient.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyArn: aws.String(policyArn),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
listOut, err = iamClient.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, attachedPolicyContains(listOut.AttachedPolicies, policyName))
|
||||
|
||||
_, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3IAMListPoliciesAndGetPolicy(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
policyName := uniqueName("managed-policy")
|
||||
policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
|
||||
policyContent := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListAllMyBuckets","Resource":"*"}]}`
|
||||
|
||||
iamClient := newIAMClient(t, cluster.s3Endpoint)
|
||||
_, err = iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
listOut, err := iamClient.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, managedPolicyContains(listOut.Policies, policyName))
|
||||
|
||||
getOut, err := iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(policyArn)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getOut.Policy)
|
||||
require.NotNil(t, getOut.Policy.PolicyName)
|
||||
require.Equal(t, policyName, *getOut.Policy.PolicyName)
|
||||
|
||||
missingArn := fmt.Sprintf("arn:aws:iam:::policy/%s", uniqueName("missing"))
|
||||
_, err = iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(missingArn)})
|
||||
require.Error(t, err)
|
||||
var awsErr awserr.Error
|
||||
require.True(t, errors.As(err, &awsErr))
|
||||
require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
}
|
||||
|
||||
func TestS3IAMDeletePolicyInUse(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
policyName := uniqueName("managed-delete-policy")
|
||||
policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
|
||||
policyContent := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`
|
||||
|
||||
iamClient := newIAMClient(t, cluster.s3Endpoint)
|
||||
_, err = iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userName := uniqueName("iam-user-delete-policy")
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyArn: aws.String(policyArn),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyArn)})
|
||||
require.Error(t, err)
|
||||
var awsErr awserr.Error
|
||||
require.True(t, errors.As(err, &awsErr))
|
||||
require.Equal(t, iam.ErrCodeDeleteConflictException, awsErr.Code())
|
||||
}
|
||||
|
||||
func execShell(t *testing.T, weedCmd, master, filer, shellCmd string) string {
|
||||
// weed shell -master=... -filer=...
|
||||
args := []string{"shell", "-master=" + master, "-filer=" + filer}
|
||||
@@ -296,52 +145,6 @@ func execShell(t *testing.T, weedCmd, master, filer, shellCmd string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func newIAMClient(t *testing.T, endpoint string) *iam.IAM {
|
||||
t.Helper()
|
||||
|
||||
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
|
||||
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(endpoint),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return iam.New(sess)
|
||||
}
|
||||
|
||||
func attachedPolicyContains(policies []*iam.AttachedPolicy, policyName string) bool {
|
||||
for _, policy := range policies {
|
||||
if policy.PolicyName != nil && *policy.PolicyName == policyName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func managedPolicyContains(policies []*iam.Policy, policyName string) bool {
|
||||
for _, policy := range policies {
|
||||
if policy.PolicyName != nil && *policy.PolicyName == policyName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueName(prefix string) string {
|
||||
return fmt.Sprintf("%s-%s", prefix, strconv.FormatInt(time.Now().UnixNano(), 36))
|
||||
}
|
||||
|
||||
// --- Test setup helpers ---
|
||||
|
||||
func findAvailablePort() (int, error) {
|
||||
@@ -413,14 +216,6 @@ enabled = true
|
||||
err = os.WriteFile(credentialToml, []byte(credentialConfig), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set environment variables for admin credentials safely for this test
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
cluster.wg.Add(1)
|
||||
go func() {
|
||||
defer cluster.wg.Done()
|
||||
@@ -447,7 +242,6 @@ enabled = true
|
||||
"-master.volumeSizeLimitMB=32",
|
||||
"-ip=127.0.0.1",
|
||||
"-master.peers=none",
|
||||
"-s3.iam.readOnly=false",
|
||||
}
|
||||
glog.MaxSize = 1024 * 1024
|
||||
for _, cmd := range command.Commands {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sse
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -19,37 +18,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// sharedEnv is the single TestEnvironment shared across all tests in this package.
|
||||
var sharedEnv *TestEnvironment
|
||||
|
||||
// TestMain starts one weed mini instance for the whole package and tears it down
|
||||
// after all tests have run.
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if os.Getenv("SHORT") != "" || testing.Short() {
|
||||
// Let tests self-skip when run with -short.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
env, err := newTestEnvironmentForMain()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: setup failed: %v\n", err)
|
||||
os.Exit(0) // Skip all tests rather than fail
|
||||
}
|
||||
sharedEnv = env
|
||||
|
||||
if startErr := sharedEnv.startSeaweedFSForMain(); startErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: weed mini failed to start: %v\n", startErr)
|
||||
sharedEnv.cleanupForMain()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
sharedEnv.cleanupForMain()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestEnvironment contains the test environment configuration
|
||||
type TestEnvironment struct {
|
||||
seaweedDir string
|
||||
@@ -86,15 +54,17 @@ func getFreePort() (int, net.Listener, error) {
|
||||
return addr.Port, listener, nil
|
||||
}
|
||||
|
||||
// newTestEnvironmentForMain creates a TestEnvironment without calling t.Fatalf so it
|
||||
// can be used from TestMain (which has no *testing.T).
|
||||
func newTestEnvironmentForMain() (*TestEnvironment, error) {
|
||||
// NewTestEnvironment creates a new test environment
|
||||
func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
||||
t.Helper()
|
||||
|
||||
// Find the SeaweedFS root directory
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get working directory: %w", err)
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Navigate up to find the SeaweedFS root (contains go.mod)
|
||||
seaweedDir := wd
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
||||
@@ -106,93 +76,82 @@ func newTestEnvironmentForMain() (*TestEnvironment, error) {
|
||||
// Check for weed binary
|
||||
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
||||
if _, err := os.Stat(weedBinary); os.IsNotExist(err) {
|
||||
// Try system PATH
|
||||
weedBinary = "weed"
|
||||
if _, err := exec.LookPath(weedBinary); err != nil {
|
||||
return nil, fmt.Errorf("weed binary not found")
|
||||
t.Skip("weed binary not found, skipping integration test")
|
||||
}
|
||||
}
|
||||
|
||||
// Create temporary data directory
|
||||
dataDir, err := os.MkdirTemp("", "seaweed-iceberg-test-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Allocate free ephemeral ports for each service
|
||||
var listeners []net.Listener
|
||||
closeListeners := func() {
|
||||
defer func() {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var l net.Listener
|
||||
s3Port, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for S3: %w", err)
|
||||
t.Fatalf("Failed to get free port for S3: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
icebergPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Iceberg: %w", err)
|
||||
t.Fatalf("Failed to get free port for Iceberg: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
s3GrpcPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for S3 gRPC: %w", err)
|
||||
t.Fatalf("Failed to get free port for S3 gRPC: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
masterPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Master: %w", err)
|
||||
t.Fatalf("Failed to get free port for Master: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
masterGrpcPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Master gRPC: %w", err)
|
||||
t.Fatalf("Failed to get free port for Master gRPC: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
filerPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Filer: %w", err)
|
||||
t.Fatalf("Failed to get free port for Filer: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
filerGrpcPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Filer gRPC: %w", err)
|
||||
t.Fatalf("Failed to get free port for Filer gRPC: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
volumePort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Volume: %w", err)
|
||||
t.Fatalf("Failed to get free port for Volume: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
volumeGrpcPort, l, err := getFreePort()
|
||||
if err != nil {
|
||||
closeListeners()
|
||||
return nil, fmt.Errorf("get free port for Volume gRPC: %w", err)
|
||||
t.Fatalf("Failed to get free port for Volume gRPC: %v", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
|
||||
// Release the port reservations so weed mini can bind to them
|
||||
closeListeners()
|
||||
|
||||
return &TestEnvironment{
|
||||
seaweedDir: seaweedDir,
|
||||
weedBinary: weedBinary,
|
||||
@@ -207,11 +166,13 @@ func newTestEnvironmentForMain() (*TestEnvironment, error) {
|
||||
volumePort: volumePort,
|
||||
volumeGrpcPort: volumeGrpcPort,
|
||||
dockerAvailable: hasDocker(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// startSeaweedFSForMain starts weed mini without a *testing.T (for use in TestMain).
|
||||
func (env *TestEnvironment) startSeaweedFSForMain() error {
|
||||
// StartSeaweedFS starts a SeaweedFS mini cluster
|
||||
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env.weedCancel = cancel
|
||||
|
||||
@@ -221,8 +182,7 @@ func (env *TestEnvironment) startSeaweedFSForMain() error {
|
||||
|
||||
for _, dir := range []string{masterDir, filerDir, volumeDir} {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
t.Fatalf("Failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,30 +203,13 @@ func (env *TestEnvironment) startSeaweedFSForMain() error {
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("start SeaweedFS: %w", err)
|
||||
t.Fatalf("Failed to start SeaweedFS: %v", err)
|
||||
}
|
||||
env.weedProcess = cmd
|
||||
|
||||
// Wait for services to be ready
|
||||
if !env.waitForService(fmt.Sprintf("http://127.0.0.1:%d/v1/config", env.icebergPort), 30*time.Second) {
|
||||
cancel()
|
||||
cmd.Wait()
|
||||
return fmt.Errorf("Iceberg REST API did not become ready")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupForMain stops SeaweedFS and cleans up resources (no *testing.T needed).
|
||||
func (env *TestEnvironment) cleanupForMain() {
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
if env.weedProcess != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
env.weedProcess.Wait()
|
||||
}
|
||||
if env.dataDir != "" {
|
||||
os.RemoveAll(env.dataDir)
|
||||
t.Fatalf("Iceberg REST API did not become ready")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +230,25 @@ func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bo
|
||||
return false
|
||||
}
|
||||
|
||||
// Cleanup stops SeaweedFS and cleans up resources
|
||||
func (env *TestEnvironment) Cleanup(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
|
||||
if env.weedProcess != nil {
|
||||
// Give process time to shut down gracefully
|
||||
time.Sleep(2 * time.Second)
|
||||
env.weedProcess.Wait()
|
||||
}
|
||||
|
||||
if env.dataDir != "" {
|
||||
os.RemoveAll(env.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
// IcebergURL returns the Iceberg REST Catalog URL
|
||||
func (env *TestEnvironment) IcebergURL() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergPort)
|
||||
@@ -298,7 +260,10 @@ func TestIcebergConfig(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
env.StartSeaweedFS(t)
|
||||
|
||||
// Test GET /v1/config
|
||||
resp, err := http.Get(env.IcebergURL() + "/v1/config")
|
||||
@@ -329,14 +294,16 @@ func TestIcebergNamespaces(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
env.StartSeaweedFS(t)
|
||||
|
||||
// Create the default table bucket first via S3
|
||||
bucketName := "warehouse-ns-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
createTableBucket(t, env, "warehouse")
|
||||
|
||||
// Test GET /v1/namespaces (should return empty list initially)
|
||||
resp, err := http.Get(env.IcebergURL() + icebergPath(bucketName, "/v1/namespaces"))
|
||||
resp, err := http.Get(env.IcebergURL() + "/v1/namespaces")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list namespaces: %v", err)
|
||||
}
|
||||
@@ -354,14 +321,16 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
bucketName := "warehouse-stage-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
namespace := "stage_ns_" + randomSuffix()
|
||||
env.StartSeaweedFS(t)
|
||||
createTableBucket(t, env, "warehouse")
|
||||
|
||||
namespace := "stage_ns"
|
||||
tableName := "orders"
|
||||
|
||||
status, _, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, "/v1/namespaces"), map[string]any{
|
||||
status, _, err := doIcebergJSONRequest(env, http.MethodPost, "/v1/namespaces", map[string]any{
|
||||
"namespace": []string{namespace},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -371,7 +340,7 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
|
||||
}
|
||||
|
||||
status, badReqResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables", namespace)), map[string]any{
|
||||
status, badReqResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables", namespace), map[string]any{
|
||||
"stage-create": true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -389,7 +358,7 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Fatalf("error.message = %v, want it to include %q", errorObj["message"], "table name is required")
|
||||
}
|
||||
|
||||
status, stageResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables", namespace)), map[string]any{
|
||||
status, stageResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables", namespace), map[string]any{
|
||||
"name": tableName,
|
||||
"stage-create": true,
|
||||
})
|
||||
@@ -404,7 +373,7 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Fatalf("stage metadata-location = %q, want suffix /metadata/v1.metadata.json", stageLocation)
|
||||
}
|
||||
|
||||
status, _, err = doIcebergJSONRequest(env, http.MethodGet, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), nil)
|
||||
status, _, err = doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load staged table request failed: %v", err)
|
||||
}
|
||||
@@ -412,7 +381,7 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Fatalf("Load staged table status = %d, want 404", status)
|
||||
}
|
||||
|
||||
status, commitResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), map[string]any{
|
||||
status, commitResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), map[string]any{
|
||||
"requirements": []map[string]any{
|
||||
{"type": "assert-create"},
|
||||
},
|
||||
@@ -429,7 +398,7 @@ func TestStageCreateAndFinalizeFlow(t *testing.T) {
|
||||
t.Fatalf("final metadata-location = %q, want suffix /metadata/v1.metadata.json", commitLocation)
|
||||
}
|
||||
|
||||
status, loadResp, err := doIcebergJSONRequest(env, http.MethodGet, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), nil)
|
||||
status, loadResp, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load finalized table request failed: %v", err)
|
||||
}
|
||||
@@ -448,14 +417,16 @@ func TestCommitMissingTableWithoutAssertCreate(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
bucketName := "warehouse-missing-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
namespace := "stage_missing_assert_ns_" + randomSuffix()
|
||||
env.StartSeaweedFS(t)
|
||||
createTableBucket(t, env, "warehouse")
|
||||
|
||||
namespace := "stage_missing_assert_ns"
|
||||
tableName := "missing_table"
|
||||
|
||||
status, _, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, "/v1/namespaces"), map[string]any{
|
||||
status, _, err := doIcebergJSONRequest(env, http.MethodPost, "/v1/namespaces", map[string]any{
|
||||
"namespace": []string{namespace},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -465,7 +436,7 @@ func TestCommitMissingTableWithoutAssertCreate(t *testing.T) {
|
||||
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
|
||||
}
|
||||
|
||||
status, _, err = doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), map[string]any{
|
||||
status, _, err = doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), map[string]any{
|
||||
"requirements": []any{},
|
||||
"updates": []any{},
|
||||
})
|
||||
@@ -520,21 +491,6 @@ func doIcebergJSONRequest(env *TestEnvironment, method, path string, payload any
|
||||
return resp.StatusCode, decoded, nil
|
||||
}
|
||||
|
||||
// icebergPath inserts the table bucket prefix into Iceberg REST API paths.
|
||||
// For example, "/v1/namespaces" with prefix "my-bucket" becomes
|
||||
// "/v1/my-bucket/namespaces".
|
||||
func icebergPath(prefix, path string) string {
|
||||
if prefix == "" {
|
||||
return path
|
||||
}
|
||||
const base = "/v1/"
|
||||
if !strings.HasPrefix(path, base) {
|
||||
return path
|
||||
}
|
||||
withPrefix := base + prefix + "/" + strings.TrimPrefix(path, base)
|
||||
return withPrefix
|
||||
}
|
||||
|
||||
// createTableBucket creates a table bucket via the S3Tables REST API
|
||||
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
|
||||
t.Helper()
|
||||
@@ -555,20 +511,13 @@ func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Create table bucket %s response: status=%d, body=%s", bucketName, resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Failed to create table bucket %s, status %d: %s", bucketName, resp.StatusCode, body)
|
||||
}
|
||||
t.Logf("Created table bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// randomSuffix returns a short random hex suffix for unique resource naming.
|
||||
func randomSuffix() string {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano()&0xffffffff)
|
||||
}
|
||||
|
||||
// TestDuckDBIntegration tests Iceberg catalog operations using DuckDB
|
||||
// This test requires Docker to be available
|
||||
func TestDuckDBIntegration(t *testing.T) {
|
||||
@@ -576,12 +525,15 @@ func TestDuckDBIntegration(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
if !env.dockerAvailable {
|
||||
t.Skip("Docker not available, skipping DuckDB integration test")
|
||||
}
|
||||
|
||||
env.StartSeaweedFS(t)
|
||||
|
||||
// Create a temporary SQL file for DuckDB to execute
|
||||
sqlFile := filepath.Join(env.dataDir, "test.sql")
|
||||
sqlContent := fmt.Sprintf(`
|
||||
|
||||
@@ -26,14 +26,17 @@ func TestPyIcebergRestCatalog(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
if !env.dockerAvailable {
|
||||
t.Skip("Docker not available, skipping PyIceberg integration test")
|
||||
}
|
||||
|
||||
env.StartSeaweedFS(t)
|
||||
|
||||
// Create the test bucket first
|
||||
bucketName := "pyiceberg-compat-test-" + randomSuffix()
|
||||
bucketName := "pyiceberg-compat-test"
|
||||
createTableBucket(t, env, bucketName)
|
||||
|
||||
// Build the test working directory path
|
||||
@@ -53,6 +56,8 @@ func TestPyIcebergRestCatalog(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("docker", "run", "--rm",
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-e", fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", "test"),
|
||||
"-e", fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", "test"),
|
||||
"-e", fmt.Sprintf("AWS_ENDPOINT_URL=%s", s3Endpoint),
|
||||
"-v", fmt.Sprintf("%s:/app:ro", testDir),
|
||||
"iceberg-rest-test",
|
||||
@@ -73,65 +78,3 @@ func TestPyIcebergRestCatalog(t *testing.T) {
|
||||
t.Errorf("PyIceberg test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPyIcebergRestCatalogAuthenticated tests the Iceberg REST Catalog using PyIceberg with authentication.
|
||||
// This test uses the default admin credentials that SeaweedFS creates on startup.
|
||||
func TestPyIcebergRestCatalogAuthenticated(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
|
||||
if !env.dockerAvailable {
|
||||
t.Skip("Docker not available, skipping PyIceberg integration test")
|
||||
}
|
||||
|
||||
// Use default admin credentials
|
||||
testAccessKey := "admin"
|
||||
testSecretKey := "admin"
|
||||
|
||||
// Create the test bucket first (using unauthenticated request, which works with DefaultAllow)
|
||||
bucketName := "pyiceberg-auth-test-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
|
||||
// Build the test working directory path
|
||||
testDir := filepath.Join(env.seaweedDir, "test", "s3tables", "catalog")
|
||||
|
||||
// Run PyIceberg test using Docker with authentication
|
||||
catalogURL := fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort)
|
||||
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
|
||||
warehouse := fmt.Sprintf("s3://%s/", bucketName)
|
||||
|
||||
// Build the test image first for faster repeated runs
|
||||
buildCmd := exec.Command("docker", "build", "-t", "iceberg-rest-test", "-f", "Dockerfile.pyiceberg", ".")
|
||||
buildCmd.Dir = testDir
|
||||
if out, err := buildCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("Failed to build test image: %v\n%s", err, string(out))
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "run", "--rm",
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-e", fmt.Sprintf("AWS_ENDPOINT_URL=%s", s3Endpoint),
|
||||
"-v", fmt.Sprintf("%s:/app:ro", testDir),
|
||||
"iceberg-rest-test",
|
||||
"python3", "/app/test_rest_catalog_auth.py",
|
||||
"--catalog-url", catalogURL,
|
||||
"--warehouse", warehouse,
|
||||
"--prefix", bucketName,
|
||||
"--access-key", testAccessKey,
|
||||
"--secret-key", testSecretKey,
|
||||
)
|
||||
cmd.Dir = testDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
t.Logf("Running PyIceberg REST catalog test with authentication...")
|
||||
t.Logf(" Catalog URL: %s", catalogURL)
|
||||
t.Logf(" Warehouse: %s", warehouse)
|
||||
t.Logf(" Access Key: %s", testAccessKey)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Errorf("PyIceberg authenticated test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// verifyTableBucketMetadata verifies that a table bucket was created with proper metadata
|
||||
func verifyTableBucketMetadata(t *testing.T, env *TestEnvironment, bucketName string) {
|
||||
t.Helper()
|
||||
|
||||
// Use S3Tables REST API to get the bucket
|
||||
endpoint := fmt.Sprintf("http://localhost:%d/buckets/%s", env.s3Port, bucketName)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get table bucket %s: %v", bucketName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Get table bucket %s response: status=%d, body=%s", bucketName, resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Failed to get table bucket %s, status %d: %s", bucketName, resp.StatusCode, body)
|
||||
}
|
||||
t.Logf("Verified table bucket %s exists with metadata", bucketName)
|
||||
}
|
||||
@@ -201,7 +201,6 @@ def main():
|
||||
"uri": args.catalog_url,
|
||||
"warehouse": args.warehouse,
|
||||
"prefix": args.prefix,
|
||||
"s3.anonymous": "true", # Disable AWS request signing for unauthenticated access
|
||||
}
|
||||
)
|
||||
print(f"Successfully connected to catalog on attempt {attempt + 1}")
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Iceberg REST Catalog Compatibility Test for SeaweedFS (Authenticated)
|
||||
|
||||
This script tests the Iceberg REST Catalog API compatibility with authentication.
|
||||
|
||||
Usage:
|
||||
python3 test_rest_catalog_auth.py --catalog-url http://localhost:8182 \\
|
||||
--access-key admin --secret-key admin
|
||||
|
||||
Requirements:
|
||||
pip install pyiceberg[s3fs]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pyiceberg.catalog import load_catalog
|
||||
from pyiceberg.schema import Schema
|
||||
from pyiceberg.types import (
|
||||
IntegerType,
|
||||
LongType,
|
||||
StringType,
|
||||
NestedField,
|
||||
)
|
||||
from pyiceberg.exceptions import (
|
||||
NamespaceAlreadyExistsError,
|
||||
NoSuchNamespaceError,
|
||||
TableAlreadyExistsError,
|
||||
NoSuchTableError,
|
||||
)
|
||||
|
||||
|
||||
def test_config_endpoint(catalog):
|
||||
"""Test that the catalog config endpoint returns valid configuration."""
|
||||
print("Testing /v1/config endpoint...")
|
||||
# The catalog is already loaded which means config endpoint worked
|
||||
print(" /v1/config endpoint working")
|
||||
return True
|
||||
|
||||
|
||||
def test_namespace_operations(catalog, prefix):
|
||||
"""Test namespace CRUD operations."""
|
||||
print("Testing namespace operations...")
|
||||
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
|
||||
|
||||
# List initial namespaces
|
||||
namespaces = catalog.list_namespaces()
|
||||
print(f" Initial namespaces: {namespaces}")
|
||||
|
||||
# Create namespace
|
||||
try:
|
||||
catalog.create_namespace(namespace)
|
||||
print(f" Created namespace: {namespace}")
|
||||
except NamespaceAlreadyExistsError:
|
||||
print(f" ! Namespace already exists: {namespace}")
|
||||
|
||||
# List namespaces (should include our new one)
|
||||
namespaces = catalog.list_namespaces()
|
||||
if namespace in namespaces:
|
||||
print(" Namespace appears in list")
|
||||
else:
|
||||
print(f" Namespace not found in list: {namespaces}")
|
||||
return False
|
||||
|
||||
# Get namespace properties
|
||||
try:
|
||||
props = catalog.load_namespace_properties(namespace)
|
||||
print(f" Loaded namespace properties: {props}")
|
||||
except NoSuchNamespaceError:
|
||||
print(f" Failed to load namespace properties")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_table_operations(catalog, prefix):
|
||||
"""Test table CRUD operations."""
|
||||
print("Testing table operations...")
|
||||
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
|
||||
table_name = "auth_test_table"
|
||||
table_id = namespace + (table_name,)
|
||||
|
||||
# Define a simple schema
|
||||
schema = Schema(
|
||||
NestedField(field_id=1, name="id", field_type=LongType(), required=True),
|
||||
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
|
||||
NestedField(field_id=3, name="age", field_type=IntegerType(), required=False),
|
||||
)
|
||||
|
||||
# Create table
|
||||
try:
|
||||
table = catalog.create_table(
|
||||
identifier=table_id,
|
||||
schema=schema,
|
||||
)
|
||||
print(f" Created table: {table_id}")
|
||||
except TableAlreadyExistsError:
|
||||
print(f" ! Table already exists: {table_id}")
|
||||
_ = catalog.load_table(table_id)
|
||||
|
||||
# List tables
|
||||
tables = catalog.list_tables(namespace)
|
||||
if table_name in [t[1] for t in tables]:
|
||||
print(" Table appears in list")
|
||||
else:
|
||||
print(f" Table not found in list: {tables}")
|
||||
return False
|
||||
|
||||
# Load table
|
||||
try:
|
||||
loaded_table = catalog.load_table(table_id)
|
||||
print(f" Loaded table: {loaded_table.name()}")
|
||||
print(f" Schema: {loaded_table.schema()}")
|
||||
print(f" Location: {loaded_table.location()}")
|
||||
except NoSuchTableError:
|
||||
print(f" Failed to load table")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_cleanup(catalog, prefix):
|
||||
"""Test table and namespace deletion."""
|
||||
print("Testing cleanup operations...")
|
||||
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
|
||||
table_id = namespace + ("auth_test_table",)
|
||||
|
||||
# Drop table
|
||||
try:
|
||||
catalog.drop_table(table_id)
|
||||
print(f" Dropped table: {table_id}")
|
||||
except NoSuchTableError:
|
||||
print(f" ! Table already deleted: {table_id}")
|
||||
|
||||
# Drop namespace
|
||||
try:
|
||||
catalog.drop_namespace(namespace)
|
||||
print(f" Dropped namespace: {namespace}")
|
||||
except NoSuchNamespaceError:
|
||||
print(f" ! Namespace already deleted: {namespace}")
|
||||
except Exception as e:
|
||||
print(f" ? Namespace drop error (may be expected): {e}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test Iceberg REST Catalog with authentication")
|
||||
parser.add_argument("--catalog-url", required=True, help="Iceberg REST Catalog URL")
|
||||
parser.add_argument("--warehouse", default="s3://iceberg-test/", help="Warehouse location")
|
||||
parser.add_argument("--prefix", required=True, help="Table bucket prefix")
|
||||
parser.add_argument("--access-key", required=True, help="AWS Access Key ID")
|
||||
parser.add_argument("--secret-key", required=True, help="AWS Secret Access Key")
|
||||
parser.add_argument("--skip-cleanup", action="store_true", help="Skip cleanup at the end")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Connecting to Iceberg REST Catalog at: {args.catalog_url}")
|
||||
print(f"Warehouse: {args.warehouse}")
|
||||
print(f"Prefix: {args.prefix}")
|
||||
print(f"Using authenticated access with key: {args.access_key}")
|
||||
print()
|
||||
|
||||
# Load the REST catalog with authentication
|
||||
import time
|
||||
max_retries = 10
|
||||
catalog = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
catalog = load_catalog(
|
||||
"rest",
|
||||
**{
|
||||
"type": "rest",
|
||||
"uri": args.catalog_url,
|
||||
"warehouse": args.warehouse,
|
||||
"prefix": args.prefix,
|
||||
"s3.access-key-id": args.access_key,
|
||||
"s3.secret-access-key": args.secret_key,
|
||||
}
|
||||
)
|
||||
print(f"Successfully connected to catalog on attempt {attempt + 1}")
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(f" Attempt {attempt + 1} failed, retrying in 2s... ({e})")
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f" All {max_retries} attempts failed.")
|
||||
raise e
|
||||
|
||||
# Run tests
|
||||
tests = [
|
||||
("Config Endpoint", lambda: test_config_endpoint(catalog)),
|
||||
("Namespace Operations", lambda: test_namespace_operations(catalog, args.prefix)),
|
||||
("Table Operations", lambda: test_table_operations(catalog, args.prefix)),
|
||||
]
|
||||
|
||||
if not args.skip_cleanup:
|
||||
tests.append(("Cleanup", lambda: test_cleanup(catalog, args.prefix)))
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for name, test_fn in tests:
|
||||
print(f"\n{'='*50}")
|
||||
try:
|
||||
if test_fn():
|
||||
passed += 1
|
||||
print(f"PASSED: {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f"FAILED: {name}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f"ERROR in {name}: {e}")
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,219 +0,0 @@
|
||||
package catalog_risingwave
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRisingWaveIcebergDML(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
if !env.dockerAvailable {
|
||||
t.Skip("Docker not available, skipping RisingWave integration test")
|
||||
}
|
||||
|
||||
t.Log(">>> Starting SeaweedFS...")
|
||||
env.StartSeaweedFS(t)
|
||||
t.Log(">>> SeaweedFS started.")
|
||||
|
||||
tableBucket := "iceberg-tables"
|
||||
t.Logf(">>> Creating table bucket: %s", tableBucket)
|
||||
createTableBucket(t, env, tableBucket)
|
||||
|
||||
t.Log(">>> Starting RisingWave...")
|
||||
env.StartRisingWave(t)
|
||||
t.Log(">>> RisingWave started.")
|
||||
|
||||
// Create Iceberg namespace
|
||||
createIcebergNamespace(t, env, "default")
|
||||
|
||||
icebergUri := env.dockerIcebergEndpoint()
|
||||
s3Endpoint := env.dockerS3Endpoint()
|
||||
|
||||
// 1. Test INSERT (Append-only)
|
||||
t.Run("TestInsert", func(t *testing.T) {
|
||||
tableName := "test_insert_" + randomString(6)
|
||||
createIcebergTable(t, env, tableBucket, "default", tableName)
|
||||
|
||||
rwTableName := "rw_insert_" + randomString(6)
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int, name varchar);", rwTableName))
|
||||
|
||||
sinkName := "test_sink_insert_" + randomString(6)
|
||||
createSinkSql := fmt.Sprintf(`
|
||||
CREATE SINK %s FROM %s
|
||||
WITH (
|
||||
connector = 'iceberg',
|
||||
catalog.type = 'rest',
|
||||
catalog.uri = '%s',
|
||||
catalog.name = 'default',
|
||||
database.name = 'default',
|
||||
table.name = '%s',
|
||||
warehouse.path = 's3://%s',
|
||||
s3.endpoint = '%s',
|
||||
s3.region = 'us-east-1',
|
||||
s3.access.key = '%s',
|
||||
s3.secret.key = '%s',
|
||||
s3.path.style.access = 'true',
|
||||
catalog.rest.sigv4_enabled = 'true',
|
||||
catalog.rest.signing_region = 'us-east-1',
|
||||
catalog.rest.signing_name = 's3',
|
||||
type = 'append-only',
|
||||
force_append_only = 'true'
|
||||
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
|
||||
|
||||
t.Logf(">>> Creating sink %s...", sinkName)
|
||||
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
|
||||
|
||||
t.Log(">>> Inserting into RisingWave table...")
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Alice'), (2, 'Bob');", rwTableName))
|
||||
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
|
||||
|
||||
// Verify with Source
|
||||
sourceName := "test_source_insert_" + randomString(6)
|
||||
createSourceSql := fmt.Sprintf(`
|
||||
CREATE SOURCE %s WITH (
|
||||
connector = 'iceberg',
|
||||
catalog.type = 'rest',
|
||||
catalog.uri = '%s',
|
||||
catalog.name = 'default',
|
||||
database.name = 'default',
|
||||
table.name = '%s',
|
||||
warehouse.path = 's3://%s',
|
||||
s3.endpoint = '%s',
|
||||
s3.region = 'us-east-1',
|
||||
s3.access.key = '%s',
|
||||
s3.secret.key = '%s',
|
||||
s3.path.style.access = 'true',
|
||||
catalog.rest.sigv4_enabled = 'true',
|
||||
catalog.rest.signing_region = 'us-east-1',
|
||||
catalog.rest.signing_name = 's3'
|
||||
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
|
||||
|
||||
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
|
||||
|
||||
t.Log(">>> Selecting from source to verify INSERT...")
|
||||
verifyQuery(t, env, sourceName, "1 | Alice", "2 | Bob")
|
||||
})
|
||||
|
||||
// 2. Test UPSERT (Update/Delete)
|
||||
t.Run("TestUpsert", func(t *testing.T) {
|
||||
tableName := "test_upsert_" + randomString(6)
|
||||
// We need a table with PK for upsert to work effectively in RW logic,
|
||||
// effectively maps to Iceberg v2 table.
|
||||
createIcebergTable(t, env, tableBucket, "default", tableName)
|
||||
|
||||
rwTableName := "rw_upsert_" + randomString(6)
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int PRIMARY KEY, name varchar);", rwTableName))
|
||||
|
||||
sinkName := "test_sink_upsert_" + randomString(6)
|
||||
createSinkSql := fmt.Sprintf(`
|
||||
CREATE SINK %s FROM %s
|
||||
WITH (
|
||||
connector = 'iceberg',
|
||||
catalog.type = 'rest',
|
||||
catalog.uri = '%s',
|
||||
catalog.name = 'default',
|
||||
database.name = 'default',
|
||||
table.name = '%s',
|
||||
warehouse.path = 's3://%s',
|
||||
s3.endpoint = '%s',
|
||||
s3.region = 'us-east-1',
|
||||
s3.access.key = '%s',
|
||||
s3.secret.key = '%s',
|
||||
s3.path.style.access = 'true',
|
||||
catalog.rest.sigv4_enabled = 'true',
|
||||
catalog.rest.signing_region = 'us-east-1',
|
||||
catalog.rest.signing_name = 's3',
|
||||
type = 'upsert', -- Upsert mode
|
||||
primary_key = 'id'
|
||||
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
|
||||
|
||||
t.Logf(">>> Creating upsert sink %s...", sinkName)
|
||||
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
|
||||
|
||||
t.Log(">>> Inserting initial data...")
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Charlie'), (2, 'Dave');", rwTableName))
|
||||
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
|
||||
|
||||
// Update 1, Delete 2
|
||||
t.Log(">>> Updating and Deleting data...")
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("UPDATE %s SET name = 'Charles' WHERE id = 1;", rwTableName))
|
||||
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("DELETE FROM %s WHERE id = 2;", rwTableName))
|
||||
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
|
||||
|
||||
// Verify with Source
|
||||
sourceName := "test_source_upsert_" + randomString(6)
|
||||
createSourceSql := fmt.Sprintf(`
|
||||
CREATE SOURCE %s WITH (
|
||||
connector = 'iceberg',
|
||||
catalog.type = 'rest',
|
||||
catalog.uri = '%s',
|
||||
catalog.name = 'default',
|
||||
database.name = 'default',
|
||||
table.name = '%s',
|
||||
warehouse.path = 's3://%s',
|
||||
s3.endpoint = '%s',
|
||||
s3.region = 'us-east-1',
|
||||
s3.access.key = '%s',
|
||||
s3.secret.key = '%s',
|
||||
s3.path.style.access = 'true',
|
||||
catalog.rest.sigv4_enabled = 'true',
|
||||
catalog.rest.signing_region = 'us-east-1',
|
||||
catalog.rest.signing_name = 's3'
|
||||
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
|
||||
|
||||
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
|
||||
|
||||
t.Log(">>> Selecting from source to verify UPSERT...")
|
||||
// Should see (1, 'Charles') and NOT (2, 'Dave')
|
||||
verifyQuery(t, env, sourceName, "1 | Charles")
|
||||
verifyQueryAbsence(t, env, sourceName, "2 | Dave")
|
||||
})
|
||||
}
|
||||
|
||||
func verifyQuery(t *testing.T, env *TestEnvironment, sourceName string, expectedSubstrings ...string) {
|
||||
t.Helper()
|
||||
var output string
|
||||
for i := 0; i < 15; i++ {
|
||||
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
|
||||
allFound := true
|
||||
for _, s := range expectedSubstrings {
|
||||
if !strings.Contains(output, s) {
|
||||
allFound = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allFound {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
t.Fatalf("Failed to find expected data %v in output:\n%s", expectedSubstrings, output)
|
||||
}
|
||||
|
||||
func verifyQueryAbsence(t *testing.T, env *TestEnvironment, sourceName string, unexpectedSubstrings ...string) {
|
||||
t.Helper()
|
||||
var output string
|
||||
for i := 0; i < 15; i++ {
|
||||
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
|
||||
noneFound := true
|
||||
for _, s := range unexpectedSubstrings {
|
||||
if strings.Contains(output, s) {
|
||||
noneFound = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if noneFound {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
t.Fatalf("Found unexpected data %v in output:\n%s", unexpectedSubstrings, output)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
package lakekeeper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/s3tables/testutil"
|
||||
)
|
||||
|
||||
type TestEnvironment struct {
|
||||
seaweedDir string
|
||||
weedBinary string
|
||||
dataDir string
|
||||
bindIP string
|
||||
s3Port int
|
||||
s3GrpcPort int
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
weedProcess *exec.Cmd
|
||||
weedCancel context.CancelFunc
|
||||
accessKey string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
func TestLakekeeperIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !testutil.HasDocker() {
|
||||
t.Skip("Docker not available, skipping Lakekeeper integration test")
|
||||
}
|
||||
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
fmt.Printf(">>> Starting SeaweedFS with Lakekeeper configuration...\n")
|
||||
env.StartSeaweedFS(t)
|
||||
fmt.Printf(">>> SeaweedFS started.\n")
|
||||
|
||||
// Run python script in docker to test STS and S3 operations
|
||||
runLakekeeperRepro(t, env)
|
||||
}
|
||||
|
||||
func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
||||
t.Helper()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
seaweedDir := wd
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
seaweedDir = filepath.Dir(seaweedDir)
|
||||
}
|
||||
|
||||
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
||||
if _, err := os.Stat(weedBinary); err != nil {
|
||||
weedBinary = "weed"
|
||||
if _, err := exec.LookPath(weedBinary); err != nil {
|
||||
t.Skip("weed binary not found, skipping integration test")
|
||||
}
|
||||
}
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "seaweed-lakekeeper-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
bindIP := testutil.FindBindIP()
|
||||
|
||||
masterPort, masterGrpcPort := testutil.MustFreePortPair(t, "Master")
|
||||
volumePort, volumeGrpcPort := testutil.MustFreePortPair(t, "Volume")
|
||||
filerPort, filerGrpcPort := testutil.MustFreePortPair(t, "Filer")
|
||||
s3Port, s3GrpcPort := testutil.MustFreePortPair(t, "S3")
|
||||
|
||||
return &TestEnvironment{
|
||||
seaweedDir: seaweedDir,
|
||||
weedBinary: weedBinary,
|
||||
dataDir: dataDir,
|
||||
bindIP: bindIP,
|
||||
s3Port: s3Port,
|
||||
s3GrpcPort: s3GrpcPort,
|
||||
masterPort: masterPort,
|
||||
masterGrpcPort: masterGrpcPort,
|
||||
filerPort: filerPort,
|
||||
filerGrpcPort: filerGrpcPort,
|
||||
volumePort: volumePort,
|
||||
volumeGrpcPort: volumeGrpcPort,
|
||||
accessKey: "admin",
|
||||
secretKey: "admin",
|
||||
}
|
||||
}
|
||||
|
||||
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
|
||||
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
|
||||
iamConfig := fmt.Sprintf(`{
|
||||
"identities": [
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "%s",
|
||||
"secretKey": "%s"
|
||||
}
|
||||
],
|
||||
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
|
||||
}
|
||||
],
|
||||
"sts": {
|
||||
"tokenDuration": "12h",
|
||||
"maxSessionLength": "24h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "LakekeeperVendedRole",
|
||||
"roleArn": "arn:aws:iam::000000000000:role/LakekeeperVendedRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "sts:AssumeRole"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["FullAccess"]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "FullAccess",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`, env.accessKey, env.secretKey)
|
||||
|
||||
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to create IAM config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env.weedCancel = cancel
|
||||
|
||||
// Start weed mini with both S3 config (standard IAM) and IAM config (advanced IAM/STS)
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, "-v", "4", "mini",
|
||||
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
||||
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
||||
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
||||
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
||||
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
||||
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
||||
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
||||
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
||||
"-s3.config", iamConfigPath,
|
||||
"-s3.iam.config", iamConfigPath,
|
||||
"-s3.iam.readOnly=false",
|
||||
"-ip", env.bindIP,
|
||||
"-ip.bind", "0.0.0.0",
|
||||
"-dir", env.dataDir,
|
||||
)
|
||||
cmd.Dir = env.dataDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Failed to start SeaweedFS: %v", err)
|
||||
}
|
||||
env.weedProcess = cmd
|
||||
|
||||
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
|
||||
t.Fatalf("S3 API failed to become ready")
|
||||
}
|
||||
}
|
||||
|
||||
func (env *TestEnvironment) Cleanup(t *testing.T) {
|
||||
t.Helper()
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
if env.weedProcess != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = env.weedProcess.Wait()
|
||||
}
|
||||
if env.dataDir != "" {
|
||||
_ = os.RemoveAll(env.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
func runLakekeeperRepro(t *testing.T, env *TestEnvironment) {
|
||||
t.Helper()
|
||||
|
||||
scriptContent := fmt.Sprintf(`
|
||||
import boto3
|
||||
import botocore.config
|
||||
import botocore
|
||||
from botocore.exceptions import ClientError
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Enable botocore debug logging to see signature calculation
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
botocore.session.get_session().set_debug_logger()
|
||||
|
||||
print("Starting Lakekeeper repro test...")
|
||||
|
||||
endpoint_url = "http://host.docker.internal:%d"
|
||||
access_key = "%s"
|
||||
secret_key = "%s"
|
||||
region = "us-east-1"
|
||||
|
||||
print(f"Connecting to {endpoint_url}")
|
||||
|
||||
try:
|
||||
config = botocore.config.Config(
|
||||
retries={'max_attempts': 3}
|
||||
)
|
||||
sts = boto3.client(
|
||||
'sts',
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
config=config
|
||||
)
|
||||
|
||||
role_arn = "arn:aws:iam::000000000000:role/LakekeeperVendedRole"
|
||||
session_name = "lakekeeper-session"
|
||||
|
||||
print(f"Calling AssumeRole on {role_arn} with POST body...")
|
||||
|
||||
# Standard boto3 call sends parameters in POST body
|
||||
response = sts.assume_role(
|
||||
RoleArn=role_arn,
|
||||
RoleSessionName=session_name
|
||||
)
|
||||
|
||||
creds = response['Credentials']
|
||||
access_key_id = creds['AccessKeyId']
|
||||
secret_access_key = creds['SecretAccessKey']
|
||||
session_token = creds['SessionToken']
|
||||
|
||||
print(f"Success! Got credentials with prefix: {access_key_id[:4]}")
|
||||
|
||||
if not access_key_id.startswith("ASIA"):
|
||||
print(f"FAILED: Expected ASIA prefix, got {access_key_id}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Verifying S3 operations with vended credentials...")
|
||||
s3 = boto3.client(
|
||||
's3',
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=access_key_id,
|
||||
aws_secret_access_key=secret_access_key,
|
||||
aws_session_token=session_token,
|
||||
region_name=region,
|
||||
config=config
|
||||
)
|
||||
|
||||
bucket = "lakekeeper-vended-bucket"
|
||||
print(f"Creating bucket {bucket}...")
|
||||
s3.create_bucket(Bucket=bucket)
|
||||
|
||||
print("Listing buckets...")
|
||||
response = s3.list_buckets()
|
||||
buckets = [b['Name'] for b in response['Buckets']]
|
||||
print(f"Found buckets: {buckets}")
|
||||
|
||||
if bucket not in buckets:
|
||||
print(f"FAILED: Bucket {bucket} not found in list")
|
||||
sys.exit(1)
|
||||
|
||||
print("SUCCESS: Lakekeeper flow verified!")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
# Print more details if it is a ClientError
|
||||
if hasattr(e, 'response'):
|
||||
print(f"Response: {e.response}")
|
||||
sys.exit(1)
|
||||
`, env.s3Port, env.accessKey, env.secretKey)
|
||||
|
||||
scriptPath := filepath.Join(env.dataDir, "lakekeeper_repro.py")
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write python script: %v", err)
|
||||
}
|
||||
|
||||
containerName := "seaweed-lakekeeper-client-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
// Create a context with timeout for the docker run command
|
||||
dockerCtx, dockerCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer dockerCancel()
|
||||
|
||||
cmd := exec.CommandContext(dockerCtx, "docker", "run", "--rm",
|
||||
"--name", containerName,
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-v", fmt.Sprintf("%s:/work", env.dataDir),
|
||||
"python:3",
|
||||
"/bin/bash", "-c", "pip install boto3 && python /work/lakekeeper_repro.py",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if dockerCtx.Err() == context.DeadlineExceeded {
|
||||
t.Fatalf("Lakekeeper repro client timed out after 5 minutes\nOutput:\n%s", string(output))
|
||||
}
|
||||
t.Fatalf("Lakekeeper repro client failed: %v\nOutput:\n%s", err, string(output))
|
||||
}
|
||||
t.Logf("Lakekeeper repro client output:\n%s", string(output))
|
||||
}
|
||||
@@ -33,8 +33,6 @@ type TestEnvironment struct {
|
||||
secretKey string
|
||||
}
|
||||
|
||||
const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests
|
||||
|
||||
func TestSTSIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
@@ -112,70 +110,17 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
||||
volumePort: volumePort,
|
||||
volumeGrpcPort: volumeGrpcPort,
|
||||
dockerAvailable: testutil.HasDocker(),
|
||||
accessKey: "admin",
|
||||
secretKey: "adminadmin",
|
||||
accessKey: "admin", // Matching default in testutil.WriteIAMConfig
|
||||
secretKey: "admin",
|
||||
}
|
||||
}
|
||||
|
||||
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
|
||||
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
|
||||
iamConfig := fmt.Sprintf(`{
|
||||
"identities": [
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [
|
||||
{ "accessKey": "%s", "secretKey": "%s" }
|
||||
],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
||||
}
|
||||
],
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "%s"
|
||||
},
|
||||
"policy": {
|
||||
"defaultEffect": "Deny",
|
||||
"storeType": "memory"
|
||||
},
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3FullAccessPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestRole",
|
||||
"roleArn": "arn:aws:iam::role/TestRole",
|
||||
"attachedPolicies": ["S3FullAccessPolicy"],
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["sts:AssumeRole"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`, env.accessKey, env.secretKey, testSTSIntegrationSigningKey)
|
||||
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
|
||||
// Create IAM config file
|
||||
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create IAM config: %v", err)
|
||||
}
|
||||
|
||||
@@ -198,8 +143,6 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
||||
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
||||
"-s3.config", iamConfigPath,
|
||||
"-s3.iam.config", iamConfigPath,
|
||||
"-s3.iam.readOnly", "false",
|
||||
"-ip", env.bindIP,
|
||||
"-ip.bind", "0.0.0.0",
|
||||
"-dir", env.dataDir,
|
||||
@@ -247,62 +190,22 @@ func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
|
||||
import boto3
|
||||
import botocore.config
|
||||
from botocore.exceptions import ClientError
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
print("Starting STS inline session policy test...")
|
||||
print("Starting STS test...")
|
||||
|
||||
primary_endpoint = "http://host.docker.internal:%d"
|
||||
fallback_endpoint = "http://%s:%d"
|
||||
endpoint_url = "http://host.docker.internal:%d"
|
||||
access_key = "%s"
|
||||
secret_key = "%s"
|
||||
region = "us-east-1"
|
||||
|
||||
print(f"Connecting to {endpoint_url} with key {access_key}")
|
||||
|
||||
try:
|
||||
def wait_for_endpoint(url, timeout=30):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2):
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def select_endpoint(urls):
|
||||
for url in urls:
|
||||
if wait_for_endpoint(url):
|
||||
return url
|
||||
raise Exception("No reachable S3 endpoint from container")
|
||||
|
||||
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
|
||||
print(f"Using endpoint {endpoint_url}")
|
||||
|
||||
config = botocore.config.Config(
|
||||
retries={'max_attempts': 0},
|
||||
s3={'addressing_style': 'path'}
|
||||
retries={'max_attempts': 0}
|
||||
)
|
||||
admin_s3 = boto3.client(
|
||||
's3',
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
config=config
|
||||
)
|
||||
|
||||
bucket = f"sts-inline-policy-{int(time.time() * 1000)}"
|
||||
key = "allowed.txt"
|
||||
|
||||
print(f"Creating bucket {bucket} with admin credentials")
|
||||
admin_s3.create_bucket(Bucket=bucket)
|
||||
admin_s3.put_object(Bucket=bucket, Key=key, Body=b"ok")
|
||||
|
||||
sts = boto3.client(
|
||||
'sts',
|
||||
endpoint_url=endpoint_url,
|
||||
@@ -312,77 +215,45 @@ try:
|
||||
config=config
|
||||
)
|
||||
|
||||
role_arn = "arn:aws:iam::role/TestRole"
|
||||
role_arn = "arn:aws:iam::000000000000:role/test-role"
|
||||
session_name = "test-session"
|
||||
session_policy = json.dumps({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": [f"arn:aws:s3:::{bucket}"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{bucket}/*"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
print(f"Calling AssumeRole on {role_arn} with inline session policy")
|
||||
print(f"Calling AssumeRole on {role_arn}")
|
||||
|
||||
# This call typically sends parameters in POST body by default in boto3
|
||||
response = sts.assume_role(
|
||||
RoleArn=role_arn,
|
||||
RoleSessionName=session_name,
|
||||
Policy=session_policy
|
||||
RoleSessionName=session_name
|
||||
)
|
||||
|
||||
creds = response['Credentials']
|
||||
vended_s3 = boto3.client(
|
||||
's3',
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=creds['AccessKeyId'],
|
||||
aws_secret_access_key=creds['SecretAccessKey'],
|
||||
aws_session_token=creds['SessionToken'],
|
||||
region_name=region,
|
||||
config=config
|
||||
)
|
||||
|
||||
print("Listing objects (allowed)")
|
||||
list_resp = vended_s3.list_objects_v2(Bucket=bucket)
|
||||
keys = [obj.get('Key') for obj in list_resp.get('Contents', [])]
|
||||
if key not in keys:
|
||||
print(f"FAILED: Expected to see {key} in list_objects_v2 results")
|
||||
sys.exit(1)
|
||||
|
||||
print("Getting object (allowed)")
|
||||
body = vended_s3.get_object(Bucket=bucket, Key=key)['Body'].read()
|
||||
if body != b"ok":
|
||||
print("FAILED: Unexpected object content")
|
||||
sys.exit(1)
|
||||
|
||||
print("Putting object (expected to be denied)")
|
||||
try:
|
||||
vended_s3.put_object(Bucket=bucket, Key="denied.txt", Body=b"no")
|
||||
print("FAILED: PutObject unexpectedly succeeded")
|
||||
sys.exit(1)
|
||||
except ClientError as e:
|
||||
error_code = e.response.get('Error', {}).get('Code', '')
|
||||
if error_code != 'AccessDenied':
|
||||
print(f"FAILED: Expected AccessDenied, got {error_code}")
|
||||
sys.exit(1)
|
||||
print("PutObject correctly denied by inline session policy")
|
||||
|
||||
print("SUCCESS: Inline session policy downscoping verified")
|
||||
sys.exit(0)
|
||||
print("Success! Got credentials:")
|
||||
print(response['Credentials'])
|
||||
|
||||
except ClientError as e:
|
||||
# Print available keys for debugging if needed
|
||||
# print(e.response.keys())
|
||||
|
||||
response_meta = e.response.get('ResponseMetadata', {})
|
||||
http_code = response_meta.get('HTTPStatusCode')
|
||||
|
||||
error_data = e.response.get('Error', {})
|
||||
error_code = error_data.get('Code', 'Unknown')
|
||||
|
||||
print(f"Got error: {http_code} {error_code}")
|
||||
|
||||
# We expect 503 ServiceUnavailable because stsHandlers is nil in weed mini
|
||||
# This confirms the request was routed to STS handler logic (UnifiedPostHandler)
|
||||
# instead of IAM handler (which would return 403 AccessDenied or 501 NotImplemented)
|
||||
if http_code == 503:
|
||||
print("SUCCESS: Got expected 503 Service Unavailable (STS not configured)")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"FAILED: Unexpected error {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
if hasattr(e, 'response'):
|
||||
print(f"Response: {e.response}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
`, env.s3Port, env.bindIP, env.s3Port, env.accessKey, env.secretKey)
|
||||
`, env.s3Port, env.accessKey, env.secretKey)
|
||||
|
||||
scriptPath := filepath.Join(env.dataDir, "sts_test.py")
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
||||
|
||||
@@ -12,57 +12,33 @@ import (
|
||||
"time"
|
||||
|
||||
cryptorand "crypto/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"flag"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/command"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
|
||||
)
|
||||
|
||||
// TestMain starts a single default weed mini cluster for the whole package and
|
||||
// tears it down after all tests have completed. Tests that require a different
|
||||
// cluster configuration (e.g. TestS3TablesCreateBucketIAMPolicy) start their
|
||||
// own cluster independently.
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
// Tests self-skip with t.Skip when -short is set; no cluster needed.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Create a temporary T-less context so we can use t.TempDir-equivalent.
|
||||
testDir, err := os.MkdirTemp("", "seaweed-s3tables-shared-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to create shared temp dir: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cluster, err := startMiniClusterInDir(testDir, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to start shared weed mini cluster: %v\n", err)
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(0)
|
||||
}
|
||||
sharedCluster = cluster
|
||||
|
||||
code := m.Run()
|
||||
|
||||
sharedCluster.Stop()
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
var (
|
||||
miniClusterMutex sync.Mutex
|
||||
)
|
||||
|
||||
func TestS3TablesIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Re-use the shared cluster started by TestMain.
|
||||
client := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
|
||||
// Create and start test cluster
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
// Create S3 Tables client
|
||||
client := NewS3TablesClient(cluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
|
||||
|
||||
// Run test suite
|
||||
t.Run("TableBucketLifecycle", func(t *testing.T) {
|
||||
@@ -94,92 +70,6 @@ func TestS3TablesIntegration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestS3TablesCreateBucketIAMPolicy(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping IAM integration test in short mode")
|
||||
}
|
||||
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "env-admin")
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "env-secret")
|
||||
|
||||
allowedBucket := "tables-allowed"
|
||||
deniedBucket := "tables-denied"
|
||||
iamConfigDir := t.TempDir()
|
||||
iamConfigPath := filepath.Join(iamConfigDir, "iam_config.json")
|
||||
iamConfig := fmt.Sprintf(`{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "%s"
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"id": "%s",
|
||||
"displayName": "tables-integration"
|
||||
}
|
||||
],
|
||||
"identities": [
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "%s",
|
||||
"secretKey": "%s"
|
||||
}
|
||||
],
|
||||
"account": {
|
||||
"id": "%s",
|
||||
"displayName": "tables-integration"
|
||||
},
|
||||
"policyNames": ["S3TablesBucketPolicy"]
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"defaultEffect": "Deny",
|
||||
"storeType": "memory"
|
||||
},
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3TablesBucketPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3tables:CreateTableBucket"],
|
||||
"Resource": [
|
||||
"arn:aws:s3tables:*:*:bucket/%s",
|
||||
"arn:aws:s3:::%s"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`, testIAMSigningKey, testAccountID, testAccessKey, testSecretKey, testAccountID, allowedBucket, allowedBucket)
|
||||
require.NoError(t, os.WriteFile(iamConfigPath, []byte(iamConfig), 0644))
|
||||
|
||||
cluster, err := startMiniClusterWithExtraArgs(t, []string{
|
||||
"-s3.config=" + iamConfigPath,
|
||||
"-s3.iam.config=" + iamConfigPath,
|
||||
})
|
||||
require.NoError(t, err, "failed to start cluster with IAM config")
|
||||
defer cluster.Stop()
|
||||
|
||||
client := NewS3TablesClient(cluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
|
||||
|
||||
_, err = client.CreateTableBucket(deniedBucket, nil)
|
||||
require.Error(t, err, "denied bucket creation should fail")
|
||||
assert.Contains(t, err.Error(), "AccessDenied")
|
||||
|
||||
allowedResp, err := client.CreateTableBucket(allowedBucket, nil)
|
||||
require.NoError(t, err, "allowed bucket creation should succeed")
|
||||
defer func() {
|
||||
_ = client.DeleteTableBucket(allowedResp.ARN)
|
||||
}()
|
||||
}
|
||||
|
||||
func testTableBucketLifecycle(t *testing.T, client *S3TablesClient) {
|
||||
bucketName := "test-bucket-" + randomString(8)
|
||||
|
||||
@@ -618,12 +508,11 @@ func findAvailablePorts(n int) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
// startMiniClusterInDir starts a weed mini instance using testDir as the data
|
||||
// directory. It does not require a *testing.T so it can be called from TestMain.
|
||||
// extraArgs are appended to the default mini command flags.
|
||||
func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, error) {
|
||||
// We need 10 unique ports: Master(2), Volume(2), Filer(2), S3(2), Admin(2)
|
||||
ports, err := findAvailablePorts(10)
|
||||
// startMiniCluster starts a weed mini instance directly without exec
|
||||
func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
// Find available ports
|
||||
// We need 8 unique ports: Master(2), Volume(2), Filer(2), S3(2)
|
||||
ports, err := findAvailablePorts(8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %v", err)
|
||||
}
|
||||
@@ -636,8 +525,8 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
filerGrpcPort := ports[5]
|
||||
s3Port := ports[6]
|
||||
s3GrpcPort := ports[7]
|
||||
adminPort := ports[8]
|
||||
adminGrpcPort := ports[9]
|
||||
// Create temporary directory for test data
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Ensure no configuration file from previous runs
|
||||
configFile := filepath.Join(testDir, "mini.options")
|
||||
@@ -648,6 +537,7 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
|
||||
s3Endpoint := fmt.Sprintf("http://127.0.0.1:%d", s3Port)
|
||||
cluster := &TestCluster{
|
||||
t: t,
|
||||
dataDir: testDir,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -660,25 +550,22 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
|
||||
// Create empty security.toml to disable JWT authentication in tests
|
||||
securityToml := filepath.Join(testDir, "security.toml")
|
||||
if err = os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
|
||||
err = os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create security.toml: %v", err)
|
||||
}
|
||||
|
||||
// Ensure AWS credentials are set (don't use t.Setenv here — we are in TestMain).
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
// Start weed mini in a goroutine by calling the command directly
|
||||
cluster.wg.Add(1)
|
||||
go func() {
|
||||
defer cluster.wg.Done()
|
||||
|
||||
// Save current directory and args, restore on exit.
|
||||
// Protect global state mutation with a mutex
|
||||
miniClusterMutex.Lock()
|
||||
defer miniClusterMutex.Unlock()
|
||||
|
||||
// Save current directory and args
|
||||
oldDir, _ := os.Getwd()
|
||||
oldArgs := os.Args
|
||||
defer func() {
|
||||
@@ -686,24 +573,21 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
os.Args = oldArgs
|
||||
}()
|
||||
|
||||
// Change to test directory so mini picks up security.toml.
|
||||
// Change to test directory so mini picks up security.toml
|
||||
os.Chdir(testDir)
|
||||
|
||||
baseArgs := []string{
|
||||
// Configure args for mini command
|
||||
os.Args = []string{
|
||||
"weed",
|
||||
"-dir=" + testDir,
|
||||
"-master.dir=" + testDir,
|
||||
"-master.port=" + strconv.Itoa(masterPort),
|
||||
"-master.port.grpc=" + strconv.Itoa(masterGrpcPort),
|
||||
"-volume.port=" + strconv.Itoa(volumePort),
|
||||
"-volume.port.grpc=" + strconv.Itoa(volumeGrpcPort),
|
||||
"-volume.port.public=" + strconv.Itoa(volumePort),
|
||||
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
|
||||
"-filer.port=" + strconv.Itoa(filerPort),
|
||||
"-filer.port.grpc=" + strconv.Itoa(filerGrpcPort),
|
||||
"-s3.port=" + strconv.Itoa(s3Port),
|
||||
"-s3.port.grpc=" + strconv.Itoa(s3GrpcPort),
|
||||
"-admin.port=" + strconv.Itoa(adminPort),
|
||||
"-admin.port.grpc=" + strconv.Itoa(adminGrpcPort),
|
||||
"-webdav.port=0", // Disable WebDAV
|
||||
"-admin.ui=false", // Disable admin UI
|
||||
"-master.volumeSizeLimitMB=32", // Small volumes for testing
|
||||
@@ -711,10 +595,6 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
"-master.peers=none", // Faster startup
|
||||
"-s3.iam.readOnly=false", // Enable IAM write operations for tests
|
||||
}
|
||||
if len(extraArgs) > 0 {
|
||||
baseArgs = append(baseArgs, extraArgs...)
|
||||
}
|
||||
os.Args = append([]string{"weed"}, baseArgs...)
|
||||
|
||||
// Suppress most logging during tests
|
||||
glog.MaxSize = 1024 * 1024
|
||||
@@ -733,34 +613,14 @@ func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, er
|
||||
}()
|
||||
|
||||
// Wait for S3 service to be ready
|
||||
if err = waitForS3Ready(cluster.s3Endpoint, 30*time.Second); err != nil {
|
||||
err = waitForS3Ready(cluster.s3Endpoint, 30*time.Second)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("S3 service failed to start: %v", err)
|
||||
}
|
||||
|
||||
cluster.isRunning = true
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// startMiniClusterWithExtraArgs starts a weed mini instance for a single test.
|
||||
// It uses t.TempDir() for data isolation and t.Setenv for credential scoping.
|
||||
func startMiniClusterWithExtraArgs(t *testing.T, extraArgs []string) (*TestCluster, error) {
|
||||
t.Helper()
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Scope credentials to the test so they are restored after test completion.
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
cluster, err := startMiniClusterInDir(testDir, extraArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster.t = t
|
||||
t.Logf("Test cluster started successfully at %s", cluster.s3Endpoint)
|
||||
return cluster, nil
|
||||
}
|
||||
@@ -786,15 +646,22 @@ func (c *TestCluster) Stop() {
|
||||
case <-done:
|
||||
// Goroutine finished
|
||||
case <-timer.C:
|
||||
// Timeout - goroutine doesn't respond to context cancel.
|
||||
// This may indicate the mini cluster didn't shut down cleanly.
|
||||
if c.t != nil {
|
||||
c.t.Log("Warning: Test cluster shutdown timed out after 2 seconds")
|
||||
} else {
|
||||
fmt.Println("Warning: Test cluster shutdown timed out after 2 seconds")
|
||||
}
|
||||
// Timeout - goroutine doesn't respond to context cancel
|
||||
// This may indicate the mini cluster didn't shut down cleanly
|
||||
c.t.Log("Warning: Test cluster shutdown timed out after 2 seconds")
|
||||
}
|
||||
|
||||
// Reset the global cmdMini flags to prevent state leakage to other tests
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" {
|
||||
// Reset flags to defaults
|
||||
cmd.Flag.VisitAll(func(f *flag.Flag) {
|
||||
// Reset to default value
|
||||
f.Value.Set(f.DefValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForS3Ready waits for the S3 service to be ready
|
||||
|
||||
@@ -8,11 +8,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// sharedCluster is the single default TestCluster shared across all tests
|
||||
// that do not require a specialised cluster configuration.
|
||||
// It is initialised by TestMain and must not be modified by individual tests.
|
||||
var sharedCluster *TestCluster
|
||||
|
||||
// TestCluster manages the weed mini instance for integration testing
|
||||
type TestCluster struct {
|
||||
t *testing.T
|
||||
@@ -51,9 +46,8 @@ func NewS3TablesClient(endpoint, region, accessKey, secretKey string) *S3TablesC
|
||||
|
||||
// Test configuration constants
|
||||
const (
|
||||
testRegion = "us-west-2"
|
||||
testAccessKey = "admin"
|
||||
testSecretKey = "admin"
|
||||
testAccountID = "111122223333"
|
||||
testIAMSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
|
||||
testRegion = "us-west-2"
|
||||
testAccessKey = "admin"
|
||||
testSecretKey = "admin"
|
||||
testAccountID = "111122223333"
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
.PHONY: test-volume-server test-volume-server-short
|
||||
|
||||
test-volume-server:
|
||||
go test ./test/volume_server/... -v
|
||||
|
||||
test-volume-server-short:
|
||||
go test ./test/volume_server/... -short -v
|
||||
@@ -1,27 +0,0 @@
|
||||
# Volume Server Integration Tests
|
||||
|
||||
This package contains integration tests for SeaweedFS volume server HTTP and gRPC APIs.
|
||||
|
||||
## Run Tests
|
||||
|
||||
Run tests from repo root:
|
||||
|
||||
```bash
|
||||
go test ./test/volume_server/... -v
|
||||
```
|
||||
|
||||
If a `weed` binary is not found, the harness will build one automatically.
|
||||
|
||||
## Optional environment variables
|
||||
|
||||
- `WEED_BINARY`: explicit path to the `weed` executable (disables auto-build).
|
||||
- `VOLUME_SERVER_IT_KEEP_LOGS=1`: keep temporary test directories and process logs.
|
||||
|
||||
## Current scope (Phase 0)
|
||||
|
||||
- Shared cluster/framework utilities
|
||||
- Matrix profile definitions
|
||||
- Initial HTTP admin endpoint checks
|
||||
- Initial gRPC state/status checks
|
||||
|
||||
More API coverage is tracked in `/Users/chris/dev/seaweedfs2/test/volume_server/DEV_PLAN.md`.
|
||||
@@ -1,442 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWaitTimeout = 30 * time.Second
|
||||
defaultWaitTick = 200 * time.Millisecond
|
||||
testVolumeSizeLimitMB = 32
|
||||
)
|
||||
|
||||
// Cluster is a lightweight SeaweedFS master + one volume server test harness.
|
||||
type Cluster struct {
|
||||
testingTB testing.TB
|
||||
profile matrix.Profile
|
||||
|
||||
weedBinary string
|
||||
baseDir string
|
||||
configDir string
|
||||
logsDir string
|
||||
keepLogs bool
|
||||
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
volumePubPort int
|
||||
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
// StartSingleVolumeCluster boots one master and one volume server.
|
||||
func StartSingleVolumeCluster(t testing.TB, profile matrix.Profile) *Cluster {
|
||||
t.Helper()
|
||||
|
||||
weedBinary, err := FindOrBuildWeedBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weed binary: %v", err)
|
||||
}
|
||||
|
||||
baseDir, keepLogs, err := newWorkDir()
|
||||
if err != nil {
|
||||
t.Fatalf("create temp test directory: %v", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(baseDir, "config")
|
||||
logsDir := filepath.Join(baseDir, "logs")
|
||||
masterDataDir := filepath.Join(baseDir, "master")
|
||||
volumeDataDir := filepath.Join(baseDir, "volume")
|
||||
for _, dir := range []string{configDir, logsDir, masterDataDir, volumeDataDir} {
|
||||
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
||||
t.Fatalf("create %s: %v", dir, mkErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err = writeSecurityConfig(configDir, profile); err != nil {
|
||||
t.Fatalf("write security config: %v", err)
|
||||
}
|
||||
|
||||
masterPort, masterGrpcPort, err := allocateMasterPortPair()
|
||||
if err != nil {
|
||||
t.Fatalf("allocate master port pair: %v", err)
|
||||
}
|
||||
|
||||
ports, err := allocatePorts(3)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate ports: %v", err)
|
||||
}
|
||||
|
||||
c := &Cluster{
|
||||
testingTB: t,
|
||||
profile: profile,
|
||||
weedBinary: weedBinary,
|
||||
baseDir: baseDir,
|
||||
configDir: configDir,
|
||||
logsDir: logsDir,
|
||||
keepLogs: keepLogs,
|
||||
masterPort: masterPort,
|
||||
masterGrpcPort: masterGrpcPort,
|
||||
volumePort: ports[0],
|
||||
volumeGrpcPort: ports[1],
|
||||
volumePubPort: ports[0],
|
||||
}
|
||||
if profile.SplitPublicPort {
|
||||
c.volumePubPort = ports[2]
|
||||
}
|
||||
|
||||
if err = c.startMaster(masterDataDir); err != nil {
|
||||
c.Stop()
|
||||
t.Fatalf("start master: %v", err)
|
||||
}
|
||||
if err = c.waitForHTTP(c.MasterURL() + "/dir/status"); err != nil {
|
||||
masterLog := c.tailLog("master.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for master readiness: %v\nmaster log tail:\n%s", err, masterLog)
|
||||
}
|
||||
|
||||
if err = c.startVolume(volumeDataDir); err != nil {
|
||||
masterLog := c.tailLog("master.log")
|
||||
c.Stop()
|
||||
t.Fatalf("start volume: %v\nmaster log tail:\n%s", err, masterLog)
|
||||
}
|
||||
if err = c.waitForHTTP(c.VolumeAdminURL() + "/status"); err != nil {
|
||||
volumeLog := c.tailLog("volume.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
if err = c.waitForTCP(c.VolumeGRPCAddress()); err != nil {
|
||||
volumeLog := c.tailLog("volume.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
c.Stop()
|
||||
})
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Stop terminates all processes and cleans temporary files.
|
||||
func (c *Cluster) Stop() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cleanupOnce.Do(func() {
|
||||
stopProcess(c.volumeCmd)
|
||||
stopProcess(c.masterCmd)
|
||||
if !c.keepLogs && !c.testingTB.Failed() {
|
||||
_ = os.RemoveAll(c.baseDir)
|
||||
} else if c.baseDir != "" {
|
||||
c.testingTB.Logf("volume server integration logs kept at %s", c.baseDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Cluster) startMaster(dataDir string) error {
|
||||
logFile, err := os.Create(filepath.Join(c.logsDir, "master.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-config_dir=" + c.configDir,
|
||||
"master",
|
||||
"-ip=127.0.0.1",
|
||||
"-port=" + strconv.Itoa(c.masterPort),
|
||||
"-port.grpc=" + strconv.Itoa(c.masterGrpcPort),
|
||||
"-mdir=" + dataDir,
|
||||
"-peers=none",
|
||||
"-volumeSizeLimitMB=" + strconv.Itoa(testVolumeSizeLimitMB),
|
||||
"-defaultReplication=000",
|
||||
}
|
||||
|
||||
c.masterCmd = exec.Command(c.weedBinary, args...)
|
||||
c.masterCmd.Dir = c.baseDir
|
||||
c.masterCmd.Stdout = logFile
|
||||
c.masterCmd.Stderr = logFile
|
||||
return c.masterCmd.Start()
|
||||
}
|
||||
|
||||
func (c *Cluster) startVolume(dataDir string) error {
|
||||
logFile, err := os.Create(filepath.Join(c.logsDir, "volume.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-config_dir=" + c.configDir,
|
||||
"volume",
|
||||
"-ip=127.0.0.1",
|
||||
"-port=" + strconv.Itoa(c.volumePort),
|
||||
"-port.grpc=" + strconv.Itoa(c.volumeGrpcPort),
|
||||
"-port.public=" + strconv.Itoa(c.volumePubPort),
|
||||
"-dir=" + dataDir,
|
||||
"-max=16",
|
||||
"-master=127.0.0.1:" + strconv.Itoa(c.masterPort),
|
||||
"-readMode=" + c.profile.ReadMode,
|
||||
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
|
||||
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
|
||||
}
|
||||
if c.profile.InflightUploadTimeout > 0 {
|
||||
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
|
||||
}
|
||||
if c.profile.InflightDownloadTimeout > 0 {
|
||||
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
|
||||
}
|
||||
|
||||
c.volumeCmd = exec.Command(c.weedBinary, args...)
|
||||
c.volumeCmd.Dir = c.baseDir
|
||||
c.volumeCmd.Stdout = logFile
|
||||
c.volumeCmd.Stderr = logFile
|
||||
return c.volumeCmd.Start()
|
||||
}
|
||||
|
||||
func (c *Cluster) waitForHTTP(url string) error {
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
deadline := time.Now().Add(defaultWaitTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 500 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(defaultWaitTick)
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for %s", url)
|
||||
}
|
||||
|
||||
func (c *Cluster) waitForTCP(addr string) error {
|
||||
deadline := time.Now().Add(defaultWaitTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(defaultWaitTick)
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for tcp %s", addr)
|
||||
}
|
||||
|
||||
func stopProcess(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
func allocatePorts(count int) ([]int, error) {
|
||||
listeners := make([]net.Listener, 0, count)
|
||||
ports := make([]int, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
for _, ll := range listeners {
|
||||
_ = ll.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
ports = append(ports, l.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
for _, l := range listeners {
|
||||
_ = l.Close()
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func allocateMasterPortPair() (int, int, error) {
|
||||
for masterPort := 10000; masterPort <= 55535; masterPort++ {
|
||||
masterGrpcPort := masterPort + 10000
|
||||
l1, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(masterPort)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l2, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(masterGrpcPort)))
|
||||
if err != nil {
|
||||
_ = l1.Close()
|
||||
continue
|
||||
}
|
||||
_ = l2.Close()
|
||||
_ = l1.Close()
|
||||
return masterPort, masterGrpcPort, nil
|
||||
}
|
||||
return 0, 0, errors.New("unable to find available master port pair")
|
||||
}
|
||||
|
||||
func newWorkDir() (dir string, keepLogs bool, err error) {
|
||||
keepLogs = os.Getenv("VOLUME_SERVER_IT_KEEP_LOGS") == "1"
|
||||
dir, err = os.MkdirTemp("", "seaweedfs_volume_server_it_")
|
||||
return dir, keepLogs, err
|
||||
}
|
||||
|
||||
func writeSecurityConfig(configDir string, profile matrix.Profile) error {
|
||||
var b strings.Builder
|
||||
if profile.EnableJWT {
|
||||
if profile.JWTSigningKey == "" || profile.JWTReadKey == "" {
|
||||
return errors.New("jwt profile requires both write and read keys")
|
||||
}
|
||||
b.WriteString("[jwt.signing]\n")
|
||||
b.WriteString("key = \"")
|
||||
b.WriteString(profile.JWTSigningKey)
|
||||
b.WriteString("\"\n")
|
||||
b.WriteString("expires_after_seconds = 60\n\n")
|
||||
|
||||
b.WriteString("[jwt.signing.read]\n")
|
||||
b.WriteString("key = \"")
|
||||
b.WriteString(profile.JWTReadKey)
|
||||
b.WriteString("\"\n")
|
||||
b.WriteString("expires_after_seconds = 60\n")
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
b.WriteString("# optional security config generated for integration tests\n")
|
||||
}
|
||||
return os.WriteFile(filepath.Join(configDir, "security.toml"), []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
// FindOrBuildWeedBinary returns an executable weed binary, building one when needed.
|
||||
func FindOrBuildWeedBinary() (string, error) {
|
||||
if fromEnv := os.Getenv("WEED_BINARY"); fromEnv != "" {
|
||||
if isExecutableFile(fromEnv) {
|
||||
return fromEnv, nil
|
||||
}
|
||||
return "", fmt.Errorf("WEED_BINARY is set but not executable: %s", fromEnv)
|
||||
}
|
||||
|
||||
repoRoot := ""
|
||||
if _, file, _, ok := runtime.Caller(0); ok {
|
||||
repoRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
||||
candidate := filepath.Join(repoRoot, "weed", "weed")
|
||||
if isExecutableFile(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
if repoRoot == "" {
|
||||
return "", errors.New("unable to detect repository root")
|
||||
}
|
||||
|
||||
binDir := filepath.Join(os.TempDir(), "seaweedfs_volume_server_it_bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create binary directory %s: %w", binDir, err)
|
||||
}
|
||||
binPath := filepath.Join(binDir, "weed")
|
||||
if isExecutableFile(binPath) {
|
||||
return binPath, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", binPath, ".")
|
||||
cmd.Dir = filepath.Join(repoRoot, "weed")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("build weed binary: %w\n%s", err, out.String())
|
||||
}
|
||||
if !isExecutableFile(binPath) {
|
||||
return "", fmt.Errorf("built weed binary is not executable: %s", binPath)
|
||||
}
|
||||
return binPath, nil
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
return mode&0o111 != 0
|
||||
}
|
||||
|
||||
func (c *Cluster) tailLog(logName string) string {
|
||||
f, err := os.Open(filepath.Join(c.logsDir, logName))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
lines := make([]string, 0, 40)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
if len(lines) > 40 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (c *Cluster) MasterAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.masterPort))
|
||||
}
|
||||
|
||||
func (c *Cluster) VolumeAdminAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort))
|
||||
}
|
||||
|
||||
func (c *Cluster) VolumePublicAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort))
|
||||
}
|
||||
|
||||
func (c *Cluster) VolumeGRPCAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort))
|
||||
}
|
||||
|
||||
// VolumeServerAddress returns SeaweedFS server address format: ip:httpPort.grpcPort
|
||||
func (c *Cluster) VolumeServerAddress() string {
|
||||
return fmt.Sprintf("%s.%d", c.VolumeAdminAddress(), c.volumeGrpcPort)
|
||||
}
|
||||
|
||||
func (c *Cluster) MasterURL() string {
|
||||
return "http://" + c.MasterAddress()
|
||||
}
|
||||
|
||||
func (c *Cluster) VolumeAdminURL() string {
|
||||
return "http://" + c.VolumeAdminAddress()
|
||||
}
|
||||
|
||||
func (c *Cluster) VolumePublicURL() string {
|
||||
return "http://" + c.VolumePublicAddress()
|
||||
}
|
||||
|
||||
func (c *Cluster) BaseDir() string {
|
||||
return c.baseDir
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
type DualVolumeCluster struct {
|
||||
testingTB testing.TB
|
||||
profile matrix.Profile
|
||||
|
||||
weedBinary string
|
||||
baseDir string
|
||||
configDir string
|
||||
logsDir string
|
||||
keepLogs bool
|
||||
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
|
||||
volumePort0 int
|
||||
volumeGrpcPort0 int
|
||||
volumePubPort0 int
|
||||
volumePort1 int
|
||||
volumeGrpcPort1 int
|
||||
volumePubPort1 int
|
||||
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd0 *exec.Cmd
|
||||
volumeCmd1 *exec.Cmd
|
||||
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
func StartDualVolumeCluster(t testing.TB, profile matrix.Profile) *DualVolumeCluster {
|
||||
t.Helper()
|
||||
|
||||
weedBinary, err := FindOrBuildWeedBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weed binary: %v", err)
|
||||
}
|
||||
|
||||
baseDir, keepLogs, err := newWorkDir()
|
||||
if err != nil {
|
||||
t.Fatalf("create temp test directory: %v", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(baseDir, "config")
|
||||
logsDir := filepath.Join(baseDir, "logs")
|
||||
masterDataDir := filepath.Join(baseDir, "master")
|
||||
volumeDataDir0 := filepath.Join(baseDir, "volume0")
|
||||
volumeDataDir1 := filepath.Join(baseDir, "volume1")
|
||||
for _, dir := range []string{configDir, logsDir, masterDataDir, volumeDataDir0, volumeDataDir1} {
|
||||
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
||||
t.Fatalf("create %s: %v", dir, mkErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err = writeSecurityConfig(configDir, profile); err != nil {
|
||||
t.Fatalf("write security config: %v", err)
|
||||
}
|
||||
|
||||
masterPort, masterGrpcPort, err := allocateMasterPortPair()
|
||||
if err != nil {
|
||||
t.Fatalf("allocate master port pair: %v", err)
|
||||
}
|
||||
|
||||
ports, err := allocatePorts(6)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate volume ports: %v", err)
|
||||
}
|
||||
|
||||
c := &DualVolumeCluster{
|
||||
testingTB: t,
|
||||
profile: profile,
|
||||
weedBinary: weedBinary,
|
||||
baseDir: baseDir,
|
||||
configDir: configDir,
|
||||
logsDir: logsDir,
|
||||
keepLogs: keepLogs,
|
||||
masterPort: masterPort,
|
||||
masterGrpcPort: masterGrpcPort,
|
||||
volumePort0: ports[0],
|
||||
volumeGrpcPort0: ports[1],
|
||||
volumePubPort0: ports[0],
|
||||
volumePort1: ports[2],
|
||||
volumeGrpcPort1: ports[3],
|
||||
volumePubPort1: ports[2],
|
||||
}
|
||||
if profile.SplitPublicPort {
|
||||
c.volumePubPort0 = ports[4]
|
||||
c.volumePubPort1 = ports[5]
|
||||
}
|
||||
|
||||
if err = c.startMaster(masterDataDir); err != nil {
|
||||
c.Stop()
|
||||
t.Fatalf("start master: %v", err)
|
||||
}
|
||||
if err = c.waitForHTTP(c.MasterURL() + "/dir/status"); err != nil {
|
||||
masterLog := c.tailLog("master.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for master readiness: %v\nmaster log tail:\n%s", err, masterLog)
|
||||
}
|
||||
|
||||
if err = c.startVolume(0, volumeDataDir0); err != nil {
|
||||
masterLog := c.tailLog("master.log")
|
||||
c.Stop()
|
||||
t.Fatalf("start first volume server: %v\nmaster log tail:\n%s", err, masterLog)
|
||||
}
|
||||
if err = c.waitForHTTP(c.VolumeAdminURL(0) + "/status"); err != nil {
|
||||
volumeLog := c.tailLog("volume0.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for first volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
if err = c.waitForTCP(c.VolumeGRPCAddress(0)); err != nil {
|
||||
volumeLog := c.tailLog("volume0.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for first volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
|
||||
if err = c.startVolume(1, volumeDataDir1); err != nil {
|
||||
volumeLog := c.tailLog("volume0.log")
|
||||
c.Stop()
|
||||
t.Fatalf("start second volume server: %v\nfirst volume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
if err = c.waitForHTTP(c.VolumeAdminURL(1) + "/status"); err != nil {
|
||||
volumeLog := c.tailLog("volume1.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for second volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
if err = c.waitForTCP(c.VolumeGRPCAddress(1)); err != nil {
|
||||
volumeLog := c.tailLog("volume1.log")
|
||||
c.Stop()
|
||||
t.Fatalf("wait for second volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
c.Stop()
|
||||
})
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) Stop() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cleanupOnce.Do(func() {
|
||||
stopProcess(c.volumeCmd1)
|
||||
stopProcess(c.volumeCmd0)
|
||||
stopProcess(c.masterCmd)
|
||||
if !c.keepLogs && !c.testingTB.Failed() {
|
||||
_ = os.RemoveAll(c.baseDir)
|
||||
} else if c.baseDir != "" {
|
||||
c.testingTB.Logf("volume server integration logs kept at %s", c.baseDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) startMaster(dataDir string) error {
|
||||
logFile, err := os.Create(filepath.Join(c.logsDir, "master.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-config_dir=" + c.configDir,
|
||||
"master",
|
||||
"-ip=127.0.0.1",
|
||||
"-port=" + strconv.Itoa(c.masterPort),
|
||||
"-port.grpc=" + strconv.Itoa(c.masterGrpcPort),
|
||||
"-mdir=" + dataDir,
|
||||
"-peers=none",
|
||||
"-volumeSizeLimitMB=" + strconv.Itoa(testVolumeSizeLimitMB),
|
||||
"-defaultReplication=000",
|
||||
}
|
||||
|
||||
c.masterCmd = exec.Command(c.weedBinary, args...)
|
||||
c.masterCmd.Dir = c.baseDir
|
||||
c.masterCmd.Stdout = logFile
|
||||
c.masterCmd.Stderr = logFile
|
||||
return c.masterCmd.Start()
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) startVolume(index int, dataDir string) error {
|
||||
logName := fmt.Sprintf("volume%d.log", index)
|
||||
logFile, err := os.Create(filepath.Join(c.logsDir, logName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
volumePort := c.volumePort0
|
||||
volumeGrpcPort := c.volumeGrpcPort0
|
||||
volumePubPort := c.volumePubPort0
|
||||
if index == 1 {
|
||||
volumePort = c.volumePort1
|
||||
volumeGrpcPort = c.volumeGrpcPort1
|
||||
volumePubPort = c.volumePubPort1
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-config_dir=" + c.configDir,
|
||||
"volume",
|
||||
"-ip=127.0.0.1",
|
||||
"-port=" + strconv.Itoa(volumePort),
|
||||
"-port.grpc=" + strconv.Itoa(volumeGrpcPort),
|
||||
"-port.public=" + strconv.Itoa(volumePubPort),
|
||||
"-dir=" + dataDir,
|
||||
"-max=16",
|
||||
"-master=127.0.0.1:" + strconv.Itoa(c.masterPort),
|
||||
"-readMode=" + c.profile.ReadMode,
|
||||
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
|
||||
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
|
||||
}
|
||||
if c.profile.InflightUploadTimeout > 0 {
|
||||
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
|
||||
}
|
||||
if c.profile.InflightDownloadTimeout > 0 {
|
||||
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
|
||||
}
|
||||
|
||||
cmd := exec.Command(c.weedBinary, args...)
|
||||
cmd.Dir = c.baseDir
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if index == 1 {
|
||||
c.volumeCmd1 = cmd
|
||||
} else {
|
||||
c.volumeCmd0 = cmd
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) waitForHTTP(url string) error {
|
||||
return (&Cluster{}).waitForHTTP(url)
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) waitForTCP(addr string) error {
|
||||
return (&Cluster{}).waitForTCP(addr)
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) tailLog(logName string) string {
|
||||
return (&Cluster{logsDir: c.logsDir}).tailLog(logName)
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) MasterAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.masterPort))
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) MasterURL() string {
|
||||
return "http://" + c.MasterAddress()
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) VolumeAdminAddress(index int) string {
|
||||
if index == 1 {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort1))
|
||||
}
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort0))
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) VolumePublicAddress(index int) string {
|
||||
if index == 1 {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort1))
|
||||
}
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort0))
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) VolumeGRPCAddress(index int) string {
|
||||
if index == 1 {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort1))
|
||||
}
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort0))
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) VolumeAdminURL(index int) string {
|
||||
return "http://" + c.VolumeAdminAddress(index)
|
||||
}
|
||||
|
||||
func (c *DualVolumeCluster) VolumePublicURL(index int) string {
|
||||
return "http://" + c.VolumePublicAddress(index)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
type ClusterWithFiler struct {
|
||||
*Cluster
|
||||
|
||||
filerCmd *exec.Cmd
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
}
|
||||
|
||||
func StartSingleVolumeClusterWithFiler(t testing.TB, profile matrix.Profile) *ClusterWithFiler {
|
||||
t.Helper()
|
||||
|
||||
baseCluster := StartSingleVolumeCluster(t, profile)
|
||||
|
||||
ports, err := allocatePorts(2)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate filer ports: %v", err)
|
||||
}
|
||||
|
||||
filerDataDir := filepath.Join(baseCluster.baseDir, "filer")
|
||||
if mkErr := os.MkdirAll(filerDataDir, 0o755); mkErr != nil {
|
||||
t.Fatalf("create filer data dir: %v", mkErr)
|
||||
}
|
||||
|
||||
logFile, err := os.Create(filepath.Join(baseCluster.logsDir, "filer.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("create filer log file: %v", err)
|
||||
}
|
||||
|
||||
filerPort := ports[0]
|
||||
filerGrpcPort := ports[1]
|
||||
args := []string{
|
||||
"-config_dir=" + baseCluster.configDir,
|
||||
"filer",
|
||||
"-master=127.0.0.1:" + strconv.Itoa(baseCluster.masterPort),
|
||||
"-ip=127.0.0.1",
|
||||
"-port=" + strconv.Itoa(filerPort),
|
||||
"-port.grpc=" + strconv.Itoa(filerGrpcPort),
|
||||
"-defaultStoreDir=" + filerDataDir,
|
||||
}
|
||||
|
||||
filerCmd := exec.Command(baseCluster.weedBinary, args...)
|
||||
filerCmd.Dir = baseCluster.baseDir
|
||||
filerCmd.Stdout = logFile
|
||||
filerCmd.Stderr = logFile
|
||||
if err = filerCmd.Start(); err != nil {
|
||||
t.Fatalf("start filer: %v", err)
|
||||
}
|
||||
|
||||
if err = baseCluster.waitForTCP(net.JoinHostPort("127.0.0.1", strconv.Itoa(filerGrpcPort))); err != nil {
|
||||
filerLogTail := baseCluster.tailLog("filer.log")
|
||||
stopProcess(filerCmd)
|
||||
t.Fatalf("wait for filer grpc readiness: %v\nfiler log tail:\n%s", err, filerLogTail)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
stopProcess(filerCmd)
|
||||
})
|
||||
|
||||
return &ClusterWithFiler{
|
||||
Cluster: baseCluster,
|
||||
filerCmd: filerCmd,
|
||||
filerPort: filerPort,
|
||||
filerGrpcPort: filerGrpcPort,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClusterWithFiler) FilerAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.filerPort))
|
||||
}
|
||||
|
||||
func (c *ClusterWithFiler) FilerGRPCAddress() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.filerGrpcPort))
|
||||
}
|
||||
|
||||
func (c *ClusterWithFiler) FilerServerAddress() string {
|
||||
return fmt.Sprintf("%s.%d", c.FilerAddress(), c.filerGrpcPort)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package framework
|
||||
|
||||
// Phase 0 placeholder for future fault injection utilities.
|
||||
//
|
||||
// Planned extensions:
|
||||
// - restart/kill selected processes
|
||||
// - temporary network isolation hooks
|
||||
// - master or peer outage helpers for proxy/replication branch coverage
|
||||
@@ -1,28 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func DialVolumeServer(t testing.TB, address string) (*grpc.ClientConn, volume_server_pb.VolumeServerClient) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(ctx, address,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial volume grpc %s: %v", address, err)
|
||||
}
|
||||
|
||||
return conn, volume_server_pb.NewVolumeServerClient(conn)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func DoRequest(t testing.TB, client *http.Client, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("http request %s %s: %v", req.Method, req.URL.String(), err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func ReadAllAndClose(t testing.TB, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
)
|
||||
|
||||
func AllocateVolume(t testing.TB, client volume_server_pb.VolumeServerClient, volumeID uint32, collection string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
Replication: "000",
|
||||
Version: uint32(needle.GetCurrentVersion()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("allocate volume %d: %v", volumeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewFileID(volumeID uint32, key uint64, cookie uint32) string {
|
||||
return needle.NewFileId(needle.VolumeId(volumeID), key, cookie).String()
|
||||
}
|
||||
|
||||
func UploadBytes(t testing.TB, client *http.Client, volumeURL, fid string, data []byte) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", volumeURL, fid), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("build upload request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
return DoRequest(t, client, req)
|
||||
}
|
||||
|
||||
func ReadBytes(t testing.TB, client *http.Client, volumeURL, fid string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", volumeURL, fid), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build read request: %v", err)
|
||||
}
|
||||
return DoRequest(t, client, req)
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVolumeNeedleStatusForUploadedFile(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(21)
|
||||
const needleID = uint64(778899)
|
||||
const cookie = uint32(0xA1B2C3D4)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
client := framework.NewHTTPClient()
|
||||
payload := []byte("needle-status-payload")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
statusResp, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: needleID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeNeedleStatus failed: %v", err)
|
||||
}
|
||||
if statusResp.GetNeedleId() != needleID {
|
||||
t.Fatalf("needle id mismatch: got %d want %d", statusResp.GetNeedleId(), needleID)
|
||||
}
|
||||
if statusResp.GetCookie() != cookie {
|
||||
t.Fatalf("cookie mismatch: got %d want %d", statusResp.GetCookie(), cookie)
|
||||
}
|
||||
if statusResp.GetSize() == 0 {
|
||||
t.Fatalf("expected non-zero needle size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeNeedleStatusViaEcShardsWhenNormalVolumeUnmounted(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(26)
|
||||
const needleID = uint64(778900)
|
||||
const cookie = uint32(0xA1B2C3D5)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
payload := []byte("needle-status-ec-path-payload")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeUnmount failed: %v", err)
|
||||
}
|
||||
|
||||
statusResp, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: needleID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeNeedleStatus via EC shards failed: %v", err)
|
||||
}
|
||||
if statusResp.GetNeedleId() != needleID {
|
||||
t.Fatalf("needle id mismatch: got %d want %d", statusResp.GetNeedleId(), needleID)
|
||||
}
|
||||
if statusResp.GetCookie() != cookie {
|
||||
t.Fatalf("cookie mismatch: got %d want %d", statusResp.GetCookie(), cookie)
|
||||
}
|
||||
if statusResp.GetSize() == 0 {
|
||||
t.Fatalf("expected non-zero needle size from EC-backed needle status")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: needleID + 999999,
|
||||
})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
t.Fatalf("VolumeNeedleStatus via EC shards missing-needle error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeNeedleStatusMissingVolumeAndNeedle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(25)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: 99925,
|
||||
NeedleId: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeNeedleStatus should fail for missing volume")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "volume not found") {
|
||||
t.Fatalf("VolumeNeedleStatus missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: 123456789,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeNeedleStatus should fail for missing needle")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
t.Fatalf("VolumeNeedleStatus missing-needle error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRequest(t testing.TB, method, url string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request %s %s: %v", method, url, err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestVolumeConfigureInvalidReplication(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(22)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
|
||||
VolumeId: volumeID,
|
||||
Replication: "bad-replication",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeConfigure returned grpc error: %v", err)
|
||||
}
|
||||
if resp.GetError() == "" {
|
||||
t.Fatalf("VolumeConfigure expected response error for invalid replication")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(resp.GetError()), "replication") {
|
||||
t.Fatalf("VolumeConfigure error should mention replication, got: %q", resp.GetError())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeConfigureSuccessAndMissingRollbackPath(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(24)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
successResp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
|
||||
VolumeId: volumeID,
|
||||
Replication: "000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeConfigure success path returned grpc error: %v", err)
|
||||
}
|
||||
if successResp.GetError() != "" {
|
||||
t.Fatalf("VolumeConfigure success path expected empty response error, got: %q", successResp.GetError())
|
||||
}
|
||||
|
||||
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus after successful configure failed: %v", err)
|
||||
}
|
||||
if statusResp.GetIsReadOnly() {
|
||||
t.Fatalf("VolumeStatus after configure expected writable volume")
|
||||
}
|
||||
|
||||
missingResp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
|
||||
VolumeId: 99024,
|
||||
Replication: "000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeConfigure missing-volume branch should return response error, got grpc error: %v", err)
|
||||
}
|
||||
if missingResp.GetError() == "" {
|
||||
t.Fatalf("VolumeConfigure missing-volume expected non-empty response error")
|
||||
}
|
||||
lower := strings.ToLower(missingResp.GetError())
|
||||
if !strings.Contains(lower, "not found on disk") {
|
||||
t.Fatalf("VolumeConfigure missing-volume error should mention not found on disk, got: %q", missingResp.GetError())
|
||||
}
|
||||
if !strings.Contains(lower, "failed to restore mount") {
|
||||
t.Fatalf("VolumeConfigure missing-volume error should include remount rollback failure, got: %q", missingResp.GetError())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingVolumeTargetAndLeaveAffectsHealthz(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pingResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: cluster.VolumeServerType,
|
||||
Target: clusterHarness.VolumeServerAddress(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Ping target volume server failed: %v", err)
|
||||
}
|
||||
if pingResp.GetRemoteTimeNs() == 0 {
|
||||
t.Fatalf("expected remote timestamp from ping target volume server")
|
||||
}
|
||||
|
||||
if _, err = grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
|
||||
t.Fatalf("VolumeServerLeave failed: %v", err)
|
||||
}
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
healthURL := clusterHarness.VolumeAdminURL() + "/healthz"
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, healthURL))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected healthz to return 503 after leave, got %d", resp.StatusCode)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeServerLeaveIsIdempotent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
|
||||
t.Fatalf("first VolumeServerLeave failed: %v", err)
|
||||
}
|
||||
if _, err := grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
|
||||
t.Fatalf("second VolumeServerLeave should be idempotent success, got: %v", err)
|
||||
}
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
healthURL := clusterHarness.VolumeAdminURL() + "/healthz"
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, healthURL))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected healthz to stay 503 after repeated leave, got %d", resp.StatusCode)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingUnknownAndUnreachableTargetPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
unknownResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: "unknown-type",
|
||||
Target: "127.0.0.1:12345",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Ping unknown target type should not return grpc error, got: %v", err)
|
||||
}
|
||||
if unknownResp.GetRemoteTimeNs() != 0 {
|
||||
t.Fatalf("Ping unknown target type expected remote_time_ns=0, got %d", unknownResp.GetRemoteTimeNs())
|
||||
}
|
||||
if unknownResp.GetStopTimeNs() < unknownResp.GetStartTimeNs() {
|
||||
t.Fatalf("Ping unknown target type expected stop_time_ns >= start_time_ns")
|
||||
}
|
||||
|
||||
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: cluster.MasterType,
|
||||
Target: "127.0.0.1:1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Ping master target should fail when target is unreachable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ping master") {
|
||||
t.Fatalf("Ping master unreachable error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: cluster.FilerType,
|
||||
Target: "127.0.0.1:1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Ping filer target should fail when target is unreachable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ping filer") {
|
||||
t.Fatalf("Ping filer unreachable error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingMasterTargetSuccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: cluster.MasterType,
|
||||
Target: clusterHarness.MasterAddress(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Ping master target success path failed: %v", err)
|
||||
}
|
||||
if resp.GetRemoteTimeNs() == 0 {
|
||||
t.Fatalf("Ping master target expected non-zero remote time")
|
||||
}
|
||||
if resp.GetStopTimeNs() < resp.GetStartTimeNs() {
|
||||
t.Fatalf("Ping master target expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingFilerTargetSuccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeClusterWithFiler(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
|
||||
TargetType: cluster.FilerType,
|
||||
Target: clusterHarness.FilerServerAddress(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Ping filer target success path failed: %v", err)
|
||||
}
|
||||
if resp.GetRemoteTimeNs() == 0 {
|
||||
t.Fatalf("Ping filer target expected non-zero remote time")
|
||||
}
|
||||
if resp.GetStopTimeNs() < resp.GetStartTimeNs() {
|
||||
t.Fatalf("Ping filer target expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestVolumeAdminLifecycleRPCs(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const volumeID = uint32(11)
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
statusResp, err := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus failed: %v", err)
|
||||
}
|
||||
if statusResp.GetFileCount() != 0 {
|
||||
t.Fatalf("new volume should be empty, got file_count=%d", statusResp.GetFileCount())
|
||||
}
|
||||
|
||||
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
|
||||
t.Fatalf("VolumeUnmount failed: %v", err)
|
||||
}
|
||||
if _, err = client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: volumeID}); err != nil {
|
||||
t.Fatalf("VolumeMount failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err = client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true}); err != nil {
|
||||
t.Fatalf("VolumeDelete failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeStatus should fail after delete")
|
||||
}
|
||||
if st, ok := status.FromError(err); !ok || st.Code() == codes.OK {
|
||||
t.Fatalf("VolumeStatus error should be a non-OK grpc status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeDeleteOnlyEmptyVariants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(13)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 66001, 0x11223344)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("volume-delete-only-empty"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "volume not empty") {
|
||||
t.Fatalf("VolumeDelete only_empty=true expected volume-not-empty error, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: false})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeDelete only_empty=false failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeStatus should fail after non-empty delete with only_empty=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceModeRejectsAllocateVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{VolumeId: 12, Replication: "000"})
|
||||
if err == nil {
|
||||
t.Fatalf("AllocateVolume should fail when maintenance mode is enabled")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("expected maintenance mode error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateDuplicateAndMountUnmountMissingVariants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const missingVolumeID = uint32(99331)
|
||||
const volumeID = uint32(14)
|
||||
|
||||
if _, err := client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: missingVolumeID}); err != nil {
|
||||
t.Fatalf("VolumeUnmount missing volume should be idempotent success, got: %v", err)
|
||||
}
|
||||
|
||||
_, err := client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: missingVolumeID})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeMount missing volume should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found on disk") {
|
||||
t.Fatalf("VolumeMount missing volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
_, err = client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Replication: "000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("AllocateVolume duplicate should fail")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
t.Fatalf("AllocateVolume duplicate error mismatch: %v", err)
|
||||
}
|
||||
|
||||
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
|
||||
t.Fatalf("VolumeUnmount existing volume failed: %v", err)
|
||||
}
|
||||
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
|
||||
t.Fatalf("VolumeUnmount already-unmounted volume should be idempotent success, got: %v", err)
|
||||
}
|
||||
if _, err = client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: volumeID}); err != nil {
|
||||
t.Fatalf("VolumeMount remount failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceModeRejectsVolumeDelete(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(15)
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeDelete should fail when maintenance mode is enabled")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("expected maintenance mode error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVolumeMarkReadonlyAndWritableLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(72)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
|
||||
VolumeId: volumeID,
|
||||
Persist: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeMarkReadonly failed: %v", err)
|
||||
}
|
||||
|
||||
readOnlyStatus, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus after readonly failed: %v", err)
|
||||
}
|
||||
if !readOnlyStatus.GetIsReadOnly() {
|
||||
t.Fatalf("VolumeStatus expected readonly=true after VolumeMarkReadonly")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeMarkWritable failed: %v", err)
|
||||
}
|
||||
|
||||
writableStatus, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus after writable failed: %v", err)
|
||||
}
|
||||
if writableStatus.GetIsReadOnly() {
|
||||
t.Fatalf("VolumeStatus expected readonly=false after VolumeMarkWritable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeMarkReadonlyPersistTrue(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(74)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
|
||||
VolumeId: volumeID,
|
||||
Persist: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeMarkReadonly persist=true failed: %v", err)
|
||||
}
|
||||
|
||||
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus after persist readonly failed: %v", err)
|
||||
}
|
||||
if !statusResp.GetIsReadOnly() {
|
||||
t.Fatalf("VolumeStatus expected readonly=true after persist readonly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeMarkReadonlyWritableErrorPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 98771, Persist: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeMarkReadonly missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 98772})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeMarkWritable missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: stateResp.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 1, Persist: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeMarkReadonly maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeMarkWritable maintenance error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCollectionRemovesVolumeAndIsIdempotent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(73)
|
||||
const collection = "it-delete-collection"
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeStatus before DeleteCollection failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.DeleteCollection(ctx, &volume_server_pb.DeleteCollectionRequest{Collection: collection})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteCollection existing collection failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeStatus should fail after collection delete")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("VolumeStatus after DeleteCollection error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.DeleteCollection(ctx, &volume_server_pb.DeleteCollectionRequest{Collection: collection})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteCollection idempotent retry failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestBatchDeleteInvalidFidAndMaintenanceMode(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{"bad-fid"}})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete invalid fid should return response, got error: %v", err)
|
||||
}
|
||||
if len(resp.GetResults()) != 1 {
|
||||
t.Fatalf("expected one batch delete result, got %d", len(resp.GetResults()))
|
||||
}
|
||||
if got := resp.GetResults()[0].GetStatus(); got != 400 {
|
||||
t.Fatalf("invalid fid expected status 400, got %d", got)
|
||||
}
|
||||
|
||||
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{"1,1234567890ab"}})
|
||||
if err == nil {
|
||||
t.Fatalf("BatchDelete should fail when maintenance mode is enabled")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("expected maintenance mode error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDeleteCookieMismatchAndSkipCheck(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(31)
|
||||
const needleID = uint64(900001)
|
||||
const correctCookie = uint32(0x1122AABB)
|
||||
const wrongCookie = uint32(0x1122AABC)
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, correctCookie)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(), fid, []byte("batch-delete-cookie-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
wrongCookieFid := framework.NewFileID(volumeID, needleID, wrongCookie)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mismatchResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
|
||||
FileIds: []string{wrongCookieFid},
|
||||
SkipCookieCheck: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete with cookie check failed: %v", err)
|
||||
}
|
||||
if len(mismatchResp.GetResults()) != 1 {
|
||||
t.Fatalf("BatchDelete cookie mismatch expected 1 result, got %d", len(mismatchResp.GetResults()))
|
||||
}
|
||||
if mismatchResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
|
||||
t.Fatalf("BatchDelete cookie mismatch expected status 400, got %d", mismatchResp.GetResults()[0].GetStatus())
|
||||
}
|
||||
|
||||
skipCheckResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
|
||||
FileIds: []string{wrongCookieFid},
|
||||
SkipCookieCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete skip cookie check failed: %v", err)
|
||||
}
|
||||
if len(skipCheckResp.GetResults()) != 1 {
|
||||
t.Fatalf("BatchDelete skip check expected 1 result, got %d", len(skipCheckResp.GetResults()))
|
||||
}
|
||||
if skipCheckResp.GetResults()[0].GetStatus() != http.StatusAccepted {
|
||||
t.Fatalf("BatchDelete skip check expected status 202, got %d", skipCheckResp.GetResults()[0].GetStatus())
|
||||
}
|
||||
|
||||
readAfterDelete := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, readAfterDelete)
|
||||
if readAfterDelete.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("read after skip-check batch delete expected 404, got %d", readAfterDelete.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDeleteMixedStatusesAndMismatchStopsProcessing(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(32)
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
const needleA = uint64(910001)
|
||||
const needleB = uint64(910002)
|
||||
const needleC = uint64(910003)
|
||||
const cookieA = uint32(0x11111111)
|
||||
const cookieB = uint32(0x22222222)
|
||||
const cookieC = uint32(0x33333333)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fidA := framework.NewFileID(volumeID, needleA, cookieA)
|
||||
fidB := framework.NewFileID(volumeID, needleB, cookieB)
|
||||
fidC := framework.NewFileID(volumeID, needleC, cookieC)
|
||||
|
||||
for _, tc := range []struct {
|
||||
fid string
|
||||
body string
|
||||
}{
|
||||
{fid: fidA, body: "batch-delete-mixed-a"},
|
||||
{fid: fidB, body: "batch-delete-mixed-b"},
|
||||
{fid: fidC, body: "batch-delete-mixed-c"},
|
||||
} {
|
||||
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(), tc.fid, []byte(tc.body))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload %s expected 201, got %d", tc.fid, uploadResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
missingFid := framework.NewFileID(volumeID, 919999, 0x44444444)
|
||||
mixedResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
|
||||
FileIds: []string{"bad-fid", fidA, missingFid},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete mixed status request failed: %v", err)
|
||||
}
|
||||
if len(mixedResp.GetResults()) != 3 {
|
||||
t.Fatalf("BatchDelete mixed status expected 3 results, got %d", len(mixedResp.GetResults()))
|
||||
}
|
||||
if mixedResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
|
||||
t.Fatalf("BatchDelete mixed result[0] expected 400, got %d", mixedResp.GetResults()[0].GetStatus())
|
||||
}
|
||||
if mixedResp.GetResults()[1].GetStatus() != http.StatusAccepted {
|
||||
t.Fatalf("BatchDelete mixed result[1] expected 202, got %d", mixedResp.GetResults()[1].GetStatus())
|
||||
}
|
||||
if mixedResp.GetResults()[2].GetStatus() != http.StatusNotFound {
|
||||
t.Fatalf("BatchDelete mixed result[2] expected 404, got %d", mixedResp.GetResults()[2].GetStatus())
|
||||
}
|
||||
|
||||
readDeletedA := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidA)
|
||||
_ = framework.ReadAllAndClose(t, readDeletedA)
|
||||
if readDeletedA.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("fidA should be deleted after batch delete, got status %d", readDeletedA.StatusCode)
|
||||
}
|
||||
|
||||
wrongCookieB := framework.NewFileID(volumeID, needleB, cookieB+1)
|
||||
stopResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
|
||||
FileIds: []string{wrongCookieB, fidC},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete mismatch-stop request failed: %v", err)
|
||||
}
|
||||
if len(stopResp.GetResults()) != 1 {
|
||||
t.Fatalf("BatchDelete mismatch-stop expected 1 result due early break, got %d", len(stopResp.GetResults()))
|
||||
}
|
||||
if stopResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
|
||||
t.Fatalf("BatchDelete mismatch-stop expected 400, got %d", stopResp.GetResults()[0].GetStatus())
|
||||
}
|
||||
|
||||
readB := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidB)
|
||||
_ = framework.ReadAllAndClose(t, readB)
|
||||
if readB.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fidB should remain after cookie mismatch path, got %d", readB.StatusCode)
|
||||
}
|
||||
|
||||
readC := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidC)
|
||||
_ = framework.ReadAllAndClose(t, readC)
|
||||
if readC.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fidC should remain when batch processing stops on mismatch, got %d", readC.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDeleteRejectsChunkManifestNeedles(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(33)
|
||||
framework.AllocateVolume(t, client, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 920001, 0x5555AAAA)
|
||||
req, err := http.NewRequest(http.MethodPost, cluster.VolumeAdminURL()+"/"+fid+"?cm=true", bytes.NewReader([]byte("manifest-placeholder-payload")))
|
||||
if err != nil {
|
||||
t.Fatalf("create chunk manifest upload request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
uploadResp := framework.DoRequest(t, httpClient, req)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("chunk manifest upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{fid}})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchDelete chunk manifest should return response, got grpc error: %v", err)
|
||||
}
|
||||
if len(resp.GetResults()) != 1 {
|
||||
t.Fatalf("BatchDelete chunk manifest expected one result, got %d", len(resp.GetResults()))
|
||||
}
|
||||
if resp.GetResults()[0].GetStatus() != http.StatusNotAcceptable {
|
||||
t.Fatalf("BatchDelete chunk manifest expected status 406, got %d", resp.GetResults()[0].GetStatus())
|
||||
}
|
||||
if !strings.Contains(resp.GetResults()[0].GetError(), "ChunkManifest") {
|
||||
t.Fatalf("BatchDelete chunk manifest expected error mentioning ChunkManifest, got %q", resp.GetResults()[0].GetError())
|
||||
}
|
||||
|
||||
readResp := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("chunk manifest should not be deleted by BatchDelete reject path, got %d", readResp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVolumeIncrementalCopyDataAndNoDataPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(91)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 770001, 0x1122AABB)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("incremental-copy-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dataStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{
|
||||
VolumeId: volumeID,
|
||||
SinceNs: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeIncrementalCopy start failed: %v", err)
|
||||
}
|
||||
|
||||
totalBytes := 0
|
||||
for {
|
||||
msg, recvErr := dataStream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("VolumeIncrementalCopy recv failed: %v", recvErr)
|
||||
}
|
||||
totalBytes += len(msg.GetFileContent())
|
||||
}
|
||||
if totalBytes == 0 {
|
||||
t.Fatalf("VolumeIncrementalCopy expected streamed bytes for since_ns=0")
|
||||
}
|
||||
|
||||
noDataStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{
|
||||
VolumeId: volumeID,
|
||||
SinceNs: math.MaxUint64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeIncrementalCopy no-data start failed: %v", err)
|
||||
}
|
||||
_, err = noDataStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("VolumeIncrementalCopy no-data expected EOF, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileIgnoreNotFoundAndStopOffsetZeroPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(92)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
missingNoIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".definitely-missing",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
IgnoreSourceFileNotFound: false,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = missingNoIgnore.Recv()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("CopyFile should fail for missing source file when ignore_source_file_not_found=false")
|
||||
}
|
||||
|
||||
missingIgnored, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".definitely-missing",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
IgnoreSourceFileNotFound: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile ignore-not-found start failed: %v", err)
|
||||
}
|
||||
_, err = missingIgnored.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("CopyFile ignore-not-found expected EOF, got: %v", err)
|
||||
}
|
||||
|
||||
stopZeroStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".definitely-missing",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 0,
|
||||
IgnoreSourceFileNotFound: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile stop_offset=0 start failed: %v", err)
|
||||
}
|
||||
_, err = stopZeroStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("CopyFile stop_offset=0 expected EOF, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileCompactionRevisionMismatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(94)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".idx",
|
||||
CompactionRevision: 1, // fresh volume starts at revision 0
|
||||
StopOffset: 1,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = stream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "is compacted") {
|
||||
t.Fatalf("CopyFile compaction mismatch error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveFileProtocolViolationResponses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
contentFirstStream, err := grpcClient.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile stream create failed: %v", err)
|
||||
}
|
||||
if err = contentFirstStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{
|
||||
FileContent: []byte("content-before-info"),
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send content-first failed: %v", err)
|
||||
}
|
||||
contentFirstResp, err := contentFirstStream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile content-first close failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(contentFirstResp.GetError(), "file info must be sent first") {
|
||||
t.Fatalf("ReceiveFile content-first response mismatch: %+v", contentFirstResp)
|
||||
}
|
||||
|
||||
unknownTypeStream, err := grpcClient.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile stream create for unknown-type failed: %v", err)
|
||||
}
|
||||
if err = unknownTypeStream.Send(&volume_server_pb.ReceiveFileRequest{}); err != nil {
|
||||
t.Fatalf("ReceiveFile send unknown-type request failed: %v", err)
|
||||
}
|
||||
unknownTypeResp, err := unknownTypeStream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile unknown-type close failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(unknownTypeResp.GetError(), "unknown message type") {
|
||||
t.Fatalf("ReceiveFile unknown-type response mismatch: %+v", unknownTypeResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveFileSuccessForRegularVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(95)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
payloadA := []byte("receive-file-chunk-a:")
|
||||
payloadB := []byte("receive-file-chunk-b")
|
||||
expected := append(append([]byte{}, payloadA...), payloadB...)
|
||||
|
||||
receiveStream, err := grpcClient.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile stream create failed: %v", err)
|
||||
}
|
||||
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_Info{
|
||||
Info: &volume_server_pb.ReceiveFileInfo{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".tmprecv",
|
||||
Collection: "",
|
||||
IsEcVolume: false,
|
||||
FileSize: uint64(len(expected)),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send info failed: %v", err)
|
||||
}
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadA},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send payloadA failed: %v", err)
|
||||
}
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadB},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send payloadB failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := receiveStream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile close failed: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("ReceiveFile unexpected error response: %+v", resp)
|
||||
}
|
||||
if resp.GetBytesWritten() != uint64(len(expected)) {
|
||||
t.Fatalf("ReceiveFile bytes_written mismatch: got %d want %d", resp.GetBytesWritten(), len(expected))
|
||||
}
|
||||
|
||||
copyStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".tmprecv",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: uint64(len(expected)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile for received data start failed: %v", err)
|
||||
}
|
||||
|
||||
var copied []byte
|
||||
for {
|
||||
msg, recvErr := copyStream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("CopyFile for received data recv failed: %v", recvErr)
|
||||
}
|
||||
copied = append(copied, msg.GetFileContent()...)
|
||||
}
|
||||
|
||||
if string(copied) != string(expected) {
|
||||
t.Fatalf("received file data mismatch: got %q want %q", string(copied), string(expected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveFileSuccessForEcVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const volumeID = uint32(96)
|
||||
const collection = "ec-receive-success"
|
||||
const ext = ".ec00"
|
||||
|
||||
payloadA := []byte("receive-ec-file-chunk-a:")
|
||||
payloadB := []byte("receive-ec-file-chunk-b")
|
||||
expected := append(append([]byte{}, payloadA...), payloadB...)
|
||||
|
||||
receiveStream, err := grpcClient.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile stream create failed: %v", err)
|
||||
}
|
||||
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_Info{
|
||||
Info: &volume_server_pb.ReceiveFileInfo{
|
||||
VolumeId: volumeID,
|
||||
Ext: ext,
|
||||
Collection: collection,
|
||||
IsEcVolume: true,
|
||||
ShardId: 0,
|
||||
FileSize: uint64(len(expected)),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send EC info failed: %v", err)
|
||||
}
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadA},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send EC payloadA failed: %v", err)
|
||||
}
|
||||
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadB},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReceiveFile send EC payloadB failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := receiveStream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile EC close failed: %v", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
t.Fatalf("ReceiveFile EC unexpected error response: %+v", resp)
|
||||
}
|
||||
if resp.GetBytesWritten() != uint64(len(expected)) {
|
||||
t.Fatalf("ReceiveFile EC bytes_written mismatch: got %d want %d", resp.GetBytesWritten(), len(expected))
|
||||
}
|
||||
|
||||
copyStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
IsEcVolume: true,
|
||||
Ext: ext,
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: uint64(len(expected)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile for received EC data start failed: %v", err)
|
||||
}
|
||||
|
||||
var copied []byte
|
||||
for {
|
||||
msg, recvErr := copyStream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("CopyFile for received EC data recv failed: %v", recvErr)
|
||||
}
|
||||
copied = append(copied, msg.GetFileContent()...)
|
||||
}
|
||||
|
||||
if string(copied) != string(expected) {
|
||||
t.Fatalf("received EC file data mismatch: got %q want %q", string(copied), string(expected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileEcVolumeIgnoreMissingSourcePaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
streamNoIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: 99601,
|
||||
Collection: "ec-copy-missing",
|
||||
IsEcVolume: true,
|
||||
Ext: ".ec00",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
IgnoreSourceFileNotFound: false,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = streamNoIgnore.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found ec volume id") {
|
||||
t.Fatalf("CopyFile EC missing source error mismatch: %v", err)
|
||||
}
|
||||
|
||||
streamIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: 99602,
|
||||
Collection: "ec-copy-missing",
|
||||
IsEcVolume: true,
|
||||
Ext: ".ec00",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
IgnoreSourceFileNotFound: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile EC ignore-missing start failed: %v", err)
|
||||
}
|
||||
_, err = streamIgnore.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("CopyFile EC ignore-missing expected EOF, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVolumeSyncStatusAndReadVolumeFileStatus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(41)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
syncResp, err := grpcClient.VolumeSyncStatus(ctx, &volume_server_pb.VolumeSyncStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeSyncStatus failed: %v", err)
|
||||
}
|
||||
if syncResp.GetVolumeId() != volumeID {
|
||||
t.Fatalf("VolumeSyncStatus volume id mismatch: got %d want %d", syncResp.GetVolumeId(), volumeID)
|
||||
}
|
||||
|
||||
statusResp, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVolumeFileStatus failed: %v", err)
|
||||
}
|
||||
if statusResp.GetVolumeId() != volumeID {
|
||||
t.Fatalf("ReadVolumeFileStatus volume id mismatch: got %d want %d", statusResp.GetVolumeId(), volumeID)
|
||||
}
|
||||
if statusResp.GetVersion() == 0 {
|
||||
t.Fatalf("ReadVolumeFileStatus expected non-zero version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAndStreamMethodsMissingVolumePaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeSyncStatus(ctx, &volume_server_pb.VolumeSyncStatusRequest{VolumeId: 98761})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeSyncStatus should fail for missing volume")
|
||||
}
|
||||
|
||||
incrementalStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{VolumeId: 98762, SinceNs: 0})
|
||||
if err == nil {
|
||||
_, err = incrementalStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("VolumeIncrementalCopy missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
readAllStream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{VolumeIds: []uint32{98763}})
|
||||
if err == nil {
|
||||
_, err = readAllStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("ReadAllNeedles missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
copyFileStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{VolumeId: 98764, Ext: ".dat", StopOffset: 1})
|
||||
if err == nil {
|
||||
_, err = copyFileStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("CopyFile missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: 98765})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("ReadVolumeFileStatus missing-volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeCopyAndReceiveFileMaintenanceRejection(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
copyStream, err := grpcClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{VolumeId: 1, SourceDataNode: "127.0.0.1:1234"})
|
||||
if err == nil {
|
||||
_, err = copyStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeCopy maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
receiveClient, err := grpcClient.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveFile client creation failed: %v", err)
|
||||
}
|
||||
_ = receiveClient.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_Info{
|
||||
Info: &volume_server_pb.ReceiveFileInfo{VolumeId: 1, Ext: ".dat"},
|
||||
},
|
||||
})
|
||||
_, err = receiveClient.CloseAndRecv()
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("ReceiveFile maintenance error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeCopySuccessFromPeerAndMountsDestination(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
|
||||
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer sourceConn.Close()
|
||||
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer destConn.Close()
|
||||
|
||||
const volumeID = uint32(42)
|
||||
framework.AllocateVolume(t, sourceClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 880001, 0x12345678)
|
||||
payload := []byte("volume-copy-success-payload")
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload to source expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
SourceDataNode: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeCopy start failed: %v", err)
|
||||
}
|
||||
|
||||
sawFinalAppendTimestamp := false
|
||||
for {
|
||||
msg, recvErr := copyStream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("VolumeCopy recv failed: %v", recvErr)
|
||||
}
|
||||
if msg.GetLastAppendAtNs() > 0 {
|
||||
sawFinalAppendTimestamp = true
|
||||
}
|
||||
}
|
||||
if !sawFinalAppendTimestamp {
|
||||
t.Fatalf("VolumeCopy expected final response with last_append_at_ns")
|
||||
}
|
||||
|
||||
destReadResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
|
||||
destReadBody := framework.ReadAllAndClose(t, destReadResp)
|
||||
if destReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("read from copied destination expected 200, got %d", destReadResp.StatusCode)
|
||||
}
|
||||
if string(destReadBody) != string(payload) {
|
||||
t.Fatalf("destination copied payload mismatch: got %q want %q", string(destReadBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeCopyOverwritesExistingDestinationVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
|
||||
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer sourceConn.Close()
|
||||
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer destConn.Close()
|
||||
|
||||
const volumeID = uint32(43)
|
||||
framework.AllocateVolume(t, sourceClient, volumeID, "")
|
||||
framework.AllocateVolume(t, destClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 880002, 0x23456789)
|
||||
sourcePayload := []byte("volume-copy-overwrite-source")
|
||||
destPayload := []byte("volume-copy-overwrite-destination-old")
|
||||
|
||||
sourceUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, sourcePayload)
|
||||
_ = framework.ReadAllAndClose(t, sourceUploadResp)
|
||||
if sourceUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload to source expected 201, got %d", sourceUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
destUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid, destPayload)
|
||||
_ = framework.ReadAllAndClose(t, destUploadResp)
|
||||
if destUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload to destination expected 201, got %d", destUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
destReadBeforeResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
|
||||
destReadBeforeBody := framework.ReadAllAndClose(t, destReadBeforeResp)
|
||||
if destReadBeforeResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("destination pre-copy read expected 200, got %d", destReadBeforeResp.StatusCode)
|
||||
}
|
||||
if string(destReadBeforeBody) != string(destPayload) {
|
||||
t.Fatalf("destination pre-copy payload mismatch: got %q want %q", string(destReadBeforeBody), string(destPayload))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
SourceDataNode: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeCopy overwrite start failed: %v", err)
|
||||
}
|
||||
|
||||
sawFinalAppendTimestamp := false
|
||||
for {
|
||||
msg, recvErr := copyStream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("VolumeCopy overwrite recv failed: %v", recvErr)
|
||||
}
|
||||
if msg.GetLastAppendAtNs() > 0 {
|
||||
sawFinalAppendTimestamp = true
|
||||
}
|
||||
}
|
||||
if !sawFinalAppendTimestamp {
|
||||
t.Fatalf("VolumeCopy overwrite expected final response with last_append_at_ns")
|
||||
}
|
||||
|
||||
destReadAfterResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
|
||||
destReadAfterBody := framework.ReadAllAndClose(t, destReadAfterResp)
|
||||
if destReadAfterResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("destination post-copy read expected 200, got %d", destReadAfterResp.StatusCode)
|
||||
}
|
||||
if string(destReadAfterBody) != string(sourcePayload) {
|
||||
t.Fatalf("destination post-copy payload mismatch: got %q want %q", string(destReadAfterBody), string(sourcePayload))
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestReadNeedleBlobAndMetaMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
|
||||
VolumeId: 99111,
|
||||
Offset: 0,
|
||||
Size: 16,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ReadNeedleBlob should fail for missing volume")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("ReadNeedleBlob missing volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
|
||||
VolumeId: 99112,
|
||||
NeedleId: 1,
|
||||
Offset: 0,
|
||||
Size: 16,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ReadNeedleMeta should fail for missing volume")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("ReadNeedleMeta missing volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNeedleBlobMaintenanceAndMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
|
||||
VolumeId: 99113,
|
||||
NeedleId: 1,
|
||||
NeedleBlob: []byte("abc"),
|
||||
Size: 3,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("WriteNeedleBlob should fail for missing volume")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("WriteNeedleBlob missing volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
|
||||
VolumeId: 1,
|
||||
NeedleId: 2,
|
||||
NeedleBlob: []byte("def"),
|
||||
Size: 3,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("WriteNeedleBlob should fail in maintenance mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("WriteNeedleBlob maintenance mode error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNeedleBlobAndMetaInvalidOffsets(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(92)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 880001, 0xCCDD1122)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("invalid-offset-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
|
||||
VolumeId: volumeID,
|
||||
Offset: 1 << 40,
|
||||
Size: 64,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ReadNeedleBlob should fail for invalid offset")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "read needle blob") {
|
||||
t.Fatalf("ReadNeedleBlob invalid offset error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: 880001,
|
||||
Offset: 1 << 40,
|
||||
Size: 64,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ReadNeedleMeta should fail for invalid offset")
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
)
|
||||
|
||||
func TestReadWriteNeedleBlobAndMetaRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(83)
|
||||
const sourceNeedleID = uint64(333333)
|
||||
const sourceCookie = uint32(0xABCD0102)
|
||||
const clonedNeedleID = uint64(333334)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
payload := []byte("blob-roundtrip-content")
|
||||
fid := framework.NewFileID(volumeID, sourceNeedleID, sourceCookie)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fileStatus, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVolumeFileStatus failed: %v", err)
|
||||
}
|
||||
if fileStatus.GetIdxFileSize() == 0 {
|
||||
t.Fatalf("expected non-zero idx file size after upload")
|
||||
}
|
||||
|
||||
idxBytes := copyFileBytes(t, grpcClient, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Ext: ".idx",
|
||||
CompactionRevision: fileStatus.GetCompactionRevision(),
|
||||
StopOffset: fileStatus.GetIdxFileSize(),
|
||||
})
|
||||
offset, size := findNeedleOffsetAndSize(t, idxBytes, sourceNeedleID)
|
||||
|
||||
blobResp, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
|
||||
VolumeId: volumeID,
|
||||
Offset: offset,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNeedleBlob failed: %v", err)
|
||||
}
|
||||
if len(blobResp.GetNeedleBlob()) == 0 {
|
||||
t.Fatalf("ReadNeedleBlob returned empty blob")
|
||||
}
|
||||
|
||||
metaResp, err := grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: sourceNeedleID,
|
||||
Offset: offset,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNeedleMeta failed: %v", err)
|
||||
}
|
||||
if metaResp.GetCookie() != sourceCookie {
|
||||
t.Fatalf("ReadNeedleMeta cookie mismatch: got %d want %d", metaResp.GetCookie(), sourceCookie)
|
||||
}
|
||||
|
||||
_, err = grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: clonedNeedleID,
|
||||
Size: size,
|
||||
NeedleBlob: blobResp.GetNeedleBlob(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteNeedleBlob failed: %v", err)
|
||||
}
|
||||
|
||||
clonedStatus, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: clonedNeedleID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeNeedleStatus for cloned needle failed: %v", err)
|
||||
}
|
||||
if clonedStatus.GetNeedleId() != sourceNeedleID {
|
||||
t.Fatalf("cloned needle status id mismatch: got %d want %d", clonedStatus.GetNeedleId(), sourceNeedleID)
|
||||
}
|
||||
if clonedStatus.GetCookie() != sourceCookie {
|
||||
t.Fatalf("cloned needle cookie mismatch: got %d want %d", clonedStatus.GetCookie(), sourceCookie)
|
||||
}
|
||||
|
||||
clonedReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, clonedNeedleID, sourceCookie))
|
||||
clonedReadBody := framework.ReadAllAndClose(t, clonedReadResp)
|
||||
if clonedReadResp.StatusCode != 200 {
|
||||
t.Fatalf("cloned needle GET expected 200, got %d", clonedReadResp.StatusCode)
|
||||
}
|
||||
if string(clonedReadBody) != string(payload) {
|
||||
t.Fatalf("cloned needle body mismatch: got %q want %q", string(clonedReadBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllNeedlesStreamsUploadedRecords(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(84)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
expected := map[uint64]string{
|
||||
444441: "read-all-needle-one",
|
||||
444442: "read-all-needle-two",
|
||||
}
|
||||
for key, body := range expected {
|
||||
resp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, key, 0xA0B0C0D0), []byte(body))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("upload for key %d expected 201, got %d", key, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{VolumeIds: []uint32{volumeID}})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAllNeedles start failed: %v", err)
|
||||
}
|
||||
|
||||
seen := map[uint64]string{}
|
||||
for {
|
||||
msg, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("ReadAllNeedles recv failed: %v", recvErr)
|
||||
}
|
||||
if _, wanted := expected[msg.GetNeedleId()]; wanted {
|
||||
seen[msg.GetNeedleId()] = string(msg.GetNeedleBlob())
|
||||
}
|
||||
}
|
||||
|
||||
for key, body := range expected {
|
||||
got, found := seen[key]
|
||||
if !found {
|
||||
t.Fatalf("ReadAllNeedles missing key %d in stream", key)
|
||||
}
|
||||
if got != body {
|
||||
t.Fatalf("ReadAllNeedles body mismatch for key %d: got %q want %q", key, got, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllNeedlesExistingThenMissingVolumeAbortsStream(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const existingVolumeID = uint32(85)
|
||||
const missingVolumeID = uint32(98585)
|
||||
const needleID = uint64(445551)
|
||||
framework.AllocateVolume(t, grpcClient, existingVolumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(existingVolumeID, needleID, 0xAA11BB22)
|
||||
payload := "read-all-existing-then-missing"
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte(payload))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{
|
||||
VolumeIds: []uint32{existingVolumeID, missingVolumeID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAllNeedles start failed: %v", err)
|
||||
}
|
||||
|
||||
seenUploadedNeedle := false
|
||||
for {
|
||||
msg, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
t.Fatalf("ReadAllNeedles expected stream error for missing volume, got EOF")
|
||||
}
|
||||
if recvErr != nil {
|
||||
if !strings.Contains(recvErr.Error(), "not found volume id") {
|
||||
t.Fatalf("ReadAllNeedles missing-volume error mismatch: %v", recvErr)
|
||||
}
|
||||
break
|
||||
}
|
||||
if msg.GetNeedleId() == needleID && string(msg.GetNeedleBlob()) == payload {
|
||||
seenUploadedNeedle = true
|
||||
}
|
||||
}
|
||||
|
||||
if !seenUploadedNeedle {
|
||||
t.Fatalf("ReadAllNeedles should stream entries from existing volume before missing-volume abort")
|
||||
}
|
||||
}
|
||||
|
||||
func copyFileBytes(t testing.TB, grpcClient volume_server_pb.VolumeServerClient, req *volume_server_pb.CopyFileRequest) []byte {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.CopyFile(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile start failed: %v", err)
|
||||
}
|
||||
|
||||
var out []byte
|
||||
for {
|
||||
msg, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
return out
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("CopyFile recv failed: %v", recvErr)
|
||||
}
|
||||
out = append(out, msg.GetFileContent()...)
|
||||
}
|
||||
}
|
||||
|
||||
func findNeedleOffsetAndSize(t testing.TB, idxBytes []byte, needleID uint64) (offset int64, size int32) {
|
||||
t.Helper()
|
||||
|
||||
for i := 0; i+types.NeedleMapEntrySize <= len(idxBytes); i += types.NeedleMapEntrySize {
|
||||
key, entryOffset, entrySize := idx.IdxFileEntry(idxBytes[i : i+types.NeedleMapEntrySize])
|
||||
if uint64(key) != needleID {
|
||||
continue
|
||||
}
|
||||
if entryOffset.IsZero() || entrySize <= 0 {
|
||||
continue
|
||||
}
|
||||
return entryOffset.ToActualOffset(), int32(entrySize)
|
||||
}
|
||||
|
||||
t.Fatalf("needle id %d not found in idx entries", needleID)
|
||||
return 0, 0
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestEcMaintenanceModeRejections(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: stateResp.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{VolumeId: 1, Collection: ""})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeEcShardsGenerate maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: 1,
|
||||
Collection: "",
|
||||
SourceDataNode: "127.0.0.1:1",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeEcShardsCopy maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: 1,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeEcShardsDelete maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
|
||||
VolumeId: 1,
|
||||
Collection: "",
|
||||
FileKey: 1,
|
||||
Version: 3,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeEcBlobDelete maintenance error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: 1,
|
||||
Collection: "",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeEcShardsToVolume maintenance error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcMissingInvalidAndNoopPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: 98791,
|
||||
Collection: "",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeEcShardsGenerate missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
rebuildResp, err := grpcClient.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
|
||||
VolumeId: 98792,
|
||||
Collection: "ec-rebuild",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsRebuild missing-volume should return empty success, got: %v", err)
|
||||
}
|
||||
if len(rebuildResp.GetRebuiltShardIds()) != 0 {
|
||||
t.Fatalf("VolumeEcShardsRebuild expected no rebuilt shards for missing volume, got %v", rebuildResp.GetRebuiltShardIds())
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: 98793,
|
||||
Collection: "ec-copy",
|
||||
SourceDataNode: "127.0.0.1:1",
|
||||
ShardIds: []uint32{0},
|
||||
DiskId: 99,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid disk_id") {
|
||||
t.Fatalf("VolumeEcShardsCopy invalid-disk error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: 98794,
|
||||
Collection: "ec-delete",
|
||||
ShardIds: []uint32{0, 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsDelete missing-volume should be no-op success, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: 98795,
|
||||
Collection: "ec-mount",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeEcShardsMount should fail for missing EC shards")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
|
||||
VolumeId: 98796,
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsUnmount missing shards should be no-op success, got: %v", err)
|
||||
}
|
||||
|
||||
readStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
|
||||
VolumeId: 98797,
|
||||
ShardId: 0,
|
||||
Offset: 0,
|
||||
Size: 1,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = readStream.Recv()
|
||||
}
|
||||
if err == nil || err == io.EOF {
|
||||
t.Fatalf("VolumeEcShardRead should fail for missing EC volume")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
|
||||
VolumeId: 98798,
|
||||
Collection: "ec-blob",
|
||||
FileKey: 1,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcBlobDelete missing local EC volume should be no-op success, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: 98799,
|
||||
Collection: "ec-to-volume",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeEcShardsToVolume missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
|
||||
VolumeId: 98800,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeEcShardsInfo missing-volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcGenerateMountInfoUnmountLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(115)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 990001, 0x1234ABCD)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-generate-lifecycle-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate success path failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount success path failed: %v", err)
|
||||
}
|
||||
|
||||
infoResp, err := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsInfo after mount failed: %v", err)
|
||||
}
|
||||
if len(infoResp.GetEcShardInfos()) == 0 {
|
||||
t.Fatalf("VolumeEcShardsInfo expected non-empty shard infos after mount")
|
||||
}
|
||||
if infoResp.GetVolumeSize() == 0 {
|
||||
t.Fatalf("VolumeEcShardsInfo expected non-zero volume size after mount")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
|
||||
VolumeId: volumeID,
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsUnmount success path failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeEcShardsInfo after unmount expected not-found error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcShardReadAndBlobDeleteLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(116)
|
||||
const fileKey = uint64(990002)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, fileKey, 0x2233CCDD)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-shard-read-delete-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount failed: %v", err)
|
||||
}
|
||||
|
||||
readStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
|
||||
VolumeId: volumeID,
|
||||
ShardId: 0,
|
||||
Offset: 0,
|
||||
Size: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardRead start failed: %v", err)
|
||||
}
|
||||
firstChunk, err := readStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardRead recv failed: %v", err)
|
||||
}
|
||||
if len(firstChunk.GetData()) == 0 {
|
||||
t.Fatalf("VolumeEcShardRead expected non-empty data chunk before deletion")
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
FileKey: fileKey,
|
||||
Version: uint32(needle.GetCurrentVersion()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcBlobDelete first delete failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
FileKey: fileKey,
|
||||
Version: uint32(needle.GetCurrentVersion()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcBlobDelete second delete should be idempotent success, got: %v", err)
|
||||
}
|
||||
|
||||
deletedStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
|
||||
VolumeId: volumeID,
|
||||
ShardId: 0,
|
||||
FileKey: fileKey,
|
||||
Offset: 0,
|
||||
Size: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardRead deleted-check start failed: %v", err)
|
||||
}
|
||||
deletedMsg, err := deletedStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardRead deleted-check recv failed: %v", err)
|
||||
}
|
||||
if !deletedMsg.GetIsDeleted() {
|
||||
t.Fatalf("VolumeEcShardRead expected IsDeleted=true after blob delete")
|
||||
}
|
||||
_, err = deletedStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("VolumeEcShardRead deleted-check expected EOF after deleted marker, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcRebuildMissingShardLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(117)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 990003, 0x3344DDEE)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-rebuild-shard-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsDelete shard 0 failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeEcShardsMount should fail when shard 0 has been deleted")
|
||||
}
|
||||
|
||||
rebuildResp, err := grpcClient.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsRebuild failed: %v", err)
|
||||
}
|
||||
if len(rebuildResp.GetRebuiltShardIds()) == 0 {
|
||||
t.Fatalf("VolumeEcShardsRebuild expected rebuilt shard ids")
|
||||
}
|
||||
foundShard0 := false
|
||||
for _, shardID := range rebuildResp.GetRebuiltShardIds() {
|
||||
if shardID == 0 {
|
||||
foundShard0 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundShard0 {
|
||||
t.Fatalf("VolumeEcShardsRebuild expected shard 0 to be rebuilt, got %v", rebuildResp.GetRebuiltShardIds())
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount shard 0 after rebuild failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcShardsToVolumeMissingShardAndNoLiveEntries(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Run("missing shard returns error", func(t *testing.T) {
|
||||
const volumeID = uint32(118)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 990004, 0x4455EEFF)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-to-volume-missing-shard-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsDelete shard 0 failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount shard 1 failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing shard 0") {
|
||||
t.Fatalf("VolumeEcShardsToVolume missing-shard error mismatch: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no live entries returns failed precondition", func(t *testing.T) {
|
||||
const volumeID = uint32(119)
|
||||
const needleID = uint64(990005)
|
||||
const cookie = uint32(0x5566FF11)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-no-live-entries-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, httpClient, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("VolumeEcShardsToVolume expected failed-precondition error when no live entries")
|
||||
}
|
||||
if status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("VolumeEcShardsToVolume no-live-entries expected FailedPrecondition, got %v (%v)", status.Code(err), err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), erasure_coding.EcNoLiveEntriesSubstring) {
|
||||
t.Fatalf("VolumeEcShardsToVolume no-live-entries error should mention %q, got %v", erasure_coding.EcNoLiveEntriesSubstring, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEcShardsToVolumeSuccessRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(120)
|
||||
const needleID = uint64(990006)
|
||||
const cookie = uint32(0x66771122)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
payload := []byte("ec-shards-to-volume-success-roundtrip-content")
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsToVolume success path failed: %v", err)
|
||||
}
|
||||
|
||||
readResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid)
|
||||
readBody := framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("post-conversion read expected 200, got %d", readResp.StatusCode)
|
||||
}
|
||||
if string(readBody) != string(payload) {
|
||||
t.Fatalf("post-conversion payload mismatch: got %q want %q", string(readBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcShardsDeleteLastShardRemovesEcx(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(121)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 990007, 0x77882233)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-delete-all-shards-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify .ecx is present before deleting all shards.
|
||||
ecxBeforeDelete, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
IsEcVolume: true,
|
||||
Ext: ".ecx",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFile .ecx before shard deletion start failed: %v", err)
|
||||
}
|
||||
if _, err = ecxBeforeDelete.Recv(); err != nil {
|
||||
t.Fatalf("CopyFile .ecx before shard deletion recv failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeEcShardsDelete all shards failed: %v", err)
|
||||
}
|
||||
|
||||
ecxAfterDelete, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
IsEcVolume: true,
|
||||
Ext: ".ecx",
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = ecxAfterDelete.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found ec volume id") {
|
||||
t.Fatalf("CopyFile .ecx after deleting all shards should fail not-found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcShardsCopyFromPeerSuccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
|
||||
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer sourceConn.Close()
|
||||
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer destConn.Close()
|
||||
|
||||
const volumeID = uint32(122)
|
||||
framework.AllocateVolume(t, sourceClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 990008, 0x88993344)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, []byte("ec-copy-from-peer-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("source upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := sourceClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("source VolumeEcShardsGenerate failed: %v", err)
|
||||
}
|
||||
|
||||
sourceDataNode := clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1]
|
||||
_, err = destClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
SourceDataNode: sourceDataNode,
|
||||
ShardIds: []uint32{0},
|
||||
CopyEcxFile: true,
|
||||
CopyVifFile: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("destination VolumeEcShardsCopy success path failed: %v", err)
|
||||
}
|
||||
|
||||
for _, ext := range []string{".ec00", ".ecx", ".vif"} {
|
||||
copyStream, copyErr := destClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "",
|
||||
IsEcVolume: true,
|
||||
Ext: ext,
|
||||
CompactionRevision: math.MaxUint32,
|
||||
StopOffset: 1,
|
||||
})
|
||||
if copyErr != nil {
|
||||
t.Fatalf("destination CopyFile %s start failed: %v", ext, copyErr)
|
||||
}
|
||||
if _, copyErr = copyStream.Recv(); copyErr != nil {
|
||||
t.Fatalf("destination CopyFile %s recv failed: %v", ext, copyErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcShardsCopyFailsWhenSourceUnavailable(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: 12345,
|
||||
Collection: "",
|
||||
SourceDataNode: "127.0.0.1:1.1",
|
||||
ShardIds: []uint32{0},
|
||||
CopyEcxFile: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "VolumeEcShardsCopy volume") {
|
||||
t.Fatalf("VolumeEcShardsCopy source-unavailable error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestStateAndStatusRPCs(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
initialState, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
if initialState.GetState() == nil {
|
||||
t.Fatalf("GetState returned nil state")
|
||||
}
|
||||
|
||||
setResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: initialState.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState(maintenance=true) failed: %v", err)
|
||||
}
|
||||
if !setResp.GetState().GetMaintenance() {
|
||||
t.Fatalf("expected maintenance=true after SetState")
|
||||
}
|
||||
|
||||
setResp, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: false,
|
||||
Version: setResp.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState(maintenance=false) failed: %v", err)
|
||||
}
|
||||
if setResp.GetState().GetMaintenance() {
|
||||
t.Fatalf("expected maintenance=false after SetState")
|
||||
}
|
||||
|
||||
statusResp, err := client.VolumeServerStatus(ctx, &volume_server_pb.VolumeServerStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeServerStatus failed: %v", err)
|
||||
}
|
||||
if statusResp.GetVersion() == "" {
|
||||
t.Fatalf("VolumeServerStatus returned empty version")
|
||||
}
|
||||
if len(statusResp.GetDiskStatuses()) == 0 {
|
||||
t.Fatalf("VolumeServerStatus returned no disk statuses")
|
||||
}
|
||||
if statusResp.GetState() == nil {
|
||||
t.Fatalf("VolumeServerStatus returned nil state")
|
||||
}
|
||||
if statusResp.GetMemoryStatus() == nil {
|
||||
t.Fatalf("VolumeServerStatus returned nil memory status")
|
||||
}
|
||||
if statusResp.GetMemoryStatus().GetGoroutines() <= 0 {
|
||||
t.Fatalf("VolumeServerStatus memory status should report goroutines, got %d", statusResp.GetMemoryStatus().GetGoroutines())
|
||||
}
|
||||
|
||||
pingResp, err := client.Ping(ctx, &volume_server_pb.PingRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("Ping failed: %v", err)
|
||||
}
|
||||
if pingResp.GetStartTimeNs() == 0 || pingResp.GetStopTimeNs() == 0 {
|
||||
t.Fatalf("Ping timestamps should be non-zero: %+v", pingResp)
|
||||
}
|
||||
if pingResp.GetStopTimeNs() < pingResp.GetStartTimeNs() {
|
||||
t.Fatalf("Ping stop time should be >= start time: %+v", pingResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStateVersionMismatchAndNilStateNoop(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
initialState, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
initialVersion := initialState.GetState().GetVersion()
|
||||
|
||||
staleResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: initialVersion + 1,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("SetState with stale version should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "version mismatch") {
|
||||
t.Fatalf("SetState stale version error mismatch: %v", err)
|
||||
}
|
||||
if staleResp.GetState().GetVersion() != initialVersion {
|
||||
t.Fatalf("SetState stale version should not mutate server version: got %d want %d", staleResp.GetState().GetVersion(), initialVersion)
|
||||
}
|
||||
if staleResp.GetState().GetMaintenance() != initialState.GetState().GetMaintenance() {
|
||||
t.Fatalf("SetState stale version should not mutate maintenance flag")
|
||||
}
|
||||
|
||||
nilResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState nil-state request should be no-op success: %v", err)
|
||||
}
|
||||
if nilResp.GetState().GetVersion() != initialVersion {
|
||||
t.Fatalf("SetState nil-state should keep version unchanged: got %d want %d", nilResp.GetState().GetVersion(), initialVersion)
|
||||
}
|
||||
if nilResp.GetState().GetMaintenance() != initialState.GetState().GetMaintenance() {
|
||||
t.Fatalf("SetState nil-state should keep maintenance unchanged")
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestScrubVolumeIndexAndUnsupportedMode(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(61)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
indexResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
|
||||
VolumeIds: []uint32{volumeID},
|
||||
Mode: volume_server_pb.VolumeScrubMode_INDEX,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubVolume index mode failed: %v", err)
|
||||
}
|
||||
if indexResp.GetTotalVolumes() != 1 {
|
||||
t.Fatalf("ScrubVolume expected total_volumes=1, got %d", indexResp.GetTotalVolumes())
|
||||
}
|
||||
|
||||
_, err = grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
|
||||
VolumeIds: []uint32{volumeID},
|
||||
Mode: volume_server_pb.VolumeScrubMode(99),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ScrubVolume should fail for unsupported mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported volume scrub mode") {
|
||||
t.Fatalf("ScrubVolume unsupported mode error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubEcVolumeMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.ScrubEcVolume(ctx, &volume_server_pb.ScrubEcVolumeRequest{
|
||||
VolumeIds: []uint32{98765},
|
||||
Mode: volume_server_pb.VolumeScrubMode_INDEX,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ScrubEcVolume should fail for missing EC volume")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "EC volume id") {
|
||||
t.Fatalf("ScrubEcVolume missing-volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubEcVolumeAutoSelectNoEcVolumes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.ScrubEcVolume(ctx, &volume_server_pb.ScrubEcVolumeRequest{
|
||||
Mode: volume_server_pb.VolumeScrubMode_INDEX,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubEcVolume auto-select failed: %v", err)
|
||||
}
|
||||
if resp.GetTotalVolumes() != 0 {
|
||||
t.Fatalf("ScrubEcVolume auto-select expected total_volumes=0 without EC data, got %d", resp.GetTotalVolumes())
|
||||
}
|
||||
if len(resp.GetBrokenVolumeIds()) != 0 {
|
||||
t.Fatalf("ScrubEcVolume auto-select expected no broken volumes, got %v", resp.GetBrokenVolumeIds())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryInvalidAndMissingFileIDPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
invalidStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{"bad-fid"},
|
||||
Selections: []string{"name"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
_, err = invalidStream.Recv()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Query should fail for invalid file id")
|
||||
}
|
||||
|
||||
missingFid := framework.NewFileID(98766, 1, 1)
|
||||
missingStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{missingFid},
|
||||
Selections: []string{"name"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
_, err = missingStream.Recv()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Query should fail for missing file id volume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubVolumeAutoSelectAndAllModes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeIDA = uint32(62)
|
||||
const volumeIDB = uint32(63)
|
||||
framework.AllocateVolume(t, grpcClient, volumeIDA, "")
|
||||
framework.AllocateVolume(t, grpcClient, volumeIDB, "")
|
||||
|
||||
// upload some data so index files are not zero-sized
|
||||
httpClient := framework.NewHTTPClient()
|
||||
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeIDA, 1, 1), []byte("test data A"))
|
||||
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeIDB, 2, 2), []byte("test data B"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
autoResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
|
||||
Mode: volume_server_pb.VolumeScrubMode_INDEX,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubVolume auto-select failed: %v", err)
|
||||
}
|
||||
if autoResp.GetTotalVolumes() < 2 {
|
||||
t.Fatalf("ScrubVolume auto-select expected at least 2 volumes, got %d", autoResp.GetTotalVolumes())
|
||||
}
|
||||
|
||||
localResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
|
||||
VolumeIds: []uint32{volumeIDA},
|
||||
Mode: volume_server_pb.VolumeScrubMode_LOCAL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubVolume local mode failed: %v", err)
|
||||
}
|
||||
if localResp.GetTotalVolumes() != 1 {
|
||||
t.Fatalf("ScrubVolume local mode expected total_volumes=1, got %d", localResp.GetTotalVolumes())
|
||||
}
|
||||
if len(localResp.GetBrokenVolumeIds()) != 0 {
|
||||
t.Fatalf("ScrubVolume local mode expected no broken volumes, got %v: %v", localResp.GetBrokenVolumeIds(), localResp.GetDetails())
|
||||
}
|
||||
|
||||
fullResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
|
||||
VolumeIds: []uint32{volumeIDA},
|
||||
Mode: volume_server_pb.VolumeScrubMode_FULL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScrubVolume full mode failed: %v", err)
|
||||
}
|
||||
if fullResp.GetTotalVolumes() != 1 {
|
||||
t.Fatalf("ScrubVolume full mode expected total_volumes=1, got %d", fullResp.GetTotalVolumes())
|
||||
}
|
||||
if len(fullResp.GetBrokenVolumeIds()) != 0 {
|
||||
t.Fatalf("ScrubVolume full mode expected no broken volumes, got %v: %v", fullResp.GetBrokenVolumeIds(), fullResp.GetDetails())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryJsonSuccessAndCsvNoOutput(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(64)
|
||||
const needleID = uint64(777001)
|
||||
const cookie = uint32(0xAABBCCDD)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
jsonLines := []byte("{\"score\":3}\n{\"score\":12}\n{\"score\":18}\n")
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
queryStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{fid},
|
||||
Selections: []string{"score"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{
|
||||
Field: "score",
|
||||
Operand: ">",
|
||||
Value: "10",
|
||||
},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Query json start failed: %v", err)
|
||||
}
|
||||
|
||||
firstStripe, err := queryStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Query json recv failed: %v", err)
|
||||
}
|
||||
records := string(firstStripe.GetRecords())
|
||||
if !strings.Contains(records, "score:12") || !strings.Contains(records, "score:18") {
|
||||
t.Fatalf("Query json records missing expected filtered scores: %q", records)
|
||||
}
|
||||
if strings.Contains(records, "score:3") {
|
||||
t.Fatalf("Query json records should not include filtered-out score: %q", records)
|
||||
}
|
||||
_, err = queryStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("Query json expected EOF after first stripe, got: %v", err)
|
||||
}
|
||||
|
||||
csvStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{fid},
|
||||
Selections: []string{"score"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
CsvInput: &volume_server_pb.QueryRequest_InputSerialization_CSVInput{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Query csv start failed: %v", err)
|
||||
}
|
||||
_, err = csvStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("Query csv expected EOF with no rows, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryJsonNoMatchReturnsEmptyStripe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(65)
|
||||
const needleID = uint64(777002)
|
||||
const cookie = uint32(0xABABCDCD)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
jsonLines := []byte("{\"score\":1}\n{\"score\":2}\n")
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
queryStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{fid},
|
||||
Selections: []string{"score"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{
|
||||
Field: "score",
|
||||
Operand: ">",
|
||||
Value: "100",
|
||||
},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Query json no-match start failed: %v", err)
|
||||
}
|
||||
|
||||
firstStripe, err := queryStream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Query json no-match recv failed: %v", err)
|
||||
}
|
||||
if len(firstStripe.GetRecords()) != 0 {
|
||||
t.Fatalf("Query json no-match expected empty records stripe, got: %q", string(firstStripe.GetRecords()))
|
||||
}
|
||||
|
||||
_, err = queryStream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("Query json no-match expected EOF after first empty stripe, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryCookieMismatchReturnsEOFNoResults(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(66)
|
||||
const needleID = uint64(777003)
|
||||
const cookie = uint32(0xCDCDABAB)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
jsonLines := []byte("{\"score\":7}\n{\"score\":8}\n")
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != 201 {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
wrongCookieFid := framework.NewFileID(volumeID, needleID, cookie+1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
|
||||
FromFileIds: []string{wrongCookieFid},
|
||||
Selections: []string{"score"},
|
||||
Filter: &volume_server_pb.QueryRequest_Filter{
|
||||
Field: "score",
|
||||
Operand: ">",
|
||||
Value: "0",
|
||||
},
|
||||
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
|
||||
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Query start for cookie mismatch should not fail immediately, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = stream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("Query cookie mismatch expected EOF with no streamed records, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVolumeTailSenderMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{VolumeId: 77777, SinceNs: 0, IdleTimeoutSeconds: 1})
|
||||
if err == nil {
|
||||
_, err = stream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume") {
|
||||
t.Fatalf("VolumeTailSender missing-volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTailSenderHeartbeatThenEOF(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(71)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{
|
||||
VolumeId: volumeID,
|
||||
SinceNs: 0,
|
||||
IdleTimeoutSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeTailSender start failed: %v", err)
|
||||
}
|
||||
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeTailSender first recv failed: %v", err)
|
||||
}
|
||||
if !msg.GetIsLastChunk() {
|
||||
t.Fatalf("expected first tail message to be heartbeat IsLastChunk=true")
|
||||
}
|
||||
|
||||
_, err = stream.Recv()
|
||||
if err != io.EOF {
|
||||
t.Fatalf("expected EOF after idle timeout drain, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTailReceiverMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{VolumeId: 88888, SourceVolumeServer: clusterHarness.VolumeServerAddress(), SinceNs: 0, IdleTimeoutSeconds: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "receiver not found volume") {
|
||||
t.Fatalf("VolumeTailReceiver missing-volume error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTailReceiverReplicatesSourceUpdates(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
|
||||
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer sourceConn.Close()
|
||||
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer destConn.Close()
|
||||
|
||||
const volumeID = uint32(72)
|
||||
framework.AllocateVolume(t, sourceClient, volumeID, "")
|
||||
framework.AllocateVolume(t, destClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 880003, 0x3456789A)
|
||||
payload := []byte("tail-receiver-replicates-source-updates")
|
||||
|
||||
sourceUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, sourceUploadResp)
|
||||
if sourceUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("source upload expected 201, got %d", sourceUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := destClient.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{
|
||||
VolumeId: volumeID,
|
||||
SourceVolumeServer: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
|
||||
SinceNs: 0,
|
||||
IdleTimeoutSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeTailReceiver success path failed: %v", err)
|
||||
}
|
||||
|
||||
destReadResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
|
||||
destReadBody := framework.ReadAllAndClose(t, destReadResp)
|
||||
if destReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("destination read after tail receive expected 200, got %d", destReadResp.StatusCode)
|
||||
}
|
||||
if string(destReadBody) != string(payload) {
|
||||
t.Fatalf("destination tail-received payload mismatch: got %q want %q", string(destReadBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTailSenderLargeNeedleChunking(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(73)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 880004, 0x456789AB)
|
||||
largePayload := bytes.Repeat([]byte("L"), 2*1024*1024+128*1024)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{
|
||||
VolumeId: volumeID,
|
||||
SinceNs: 0,
|
||||
IdleTimeoutSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VolumeTailSender start failed: %v", err)
|
||||
}
|
||||
|
||||
dataChunkCount := 0
|
||||
sawNonLastDataChunk := false
|
||||
sawLastDataChunk := false
|
||||
for {
|
||||
msg, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("VolumeTailSender recv failed: %v", recvErr)
|
||||
}
|
||||
if len(msg.GetNeedleBody()) == 0 {
|
||||
continue
|
||||
}
|
||||
dataChunkCount++
|
||||
if msg.GetIsLastChunk() {
|
||||
sawLastDataChunk = true
|
||||
} else {
|
||||
sawNonLastDataChunk = true
|
||||
}
|
||||
}
|
||||
|
||||
if dataChunkCount < 2 {
|
||||
t.Fatalf("VolumeTailSender expected multiple chunks for large needle, got %d", dataChunkCount)
|
||||
}
|
||||
if !sawNonLastDataChunk {
|
||||
t.Fatalf("VolumeTailSender expected at least one non-last data chunk")
|
||||
}
|
||||
if !sawLastDataChunk {
|
||||
t.Fatalf("VolumeTailSender expected a final data chunk marked IsLastChunk=true")
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestFetchAndWriteNeedleMaintenanceAndMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
|
||||
VolumeId: 98781,
|
||||
NeedleId: 1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found volume id") {
|
||||
t.Fatalf("FetchAndWriteNeedle missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: stateResp.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
|
||||
VolumeId: 1,
|
||||
NeedleId: 1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("FetchAndWriteNeedle maintenance error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAndWriteNeedleInvalidRemoteConfig(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(88)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
|
||||
VolumeId: volumeID,
|
||||
NeedleId: 1,
|
||||
Cookie: 1,
|
||||
Size: 1,
|
||||
RemoteConf: &remote_pb.RemoteConf{
|
||||
Name: "it-invalid-remote",
|
||||
Type: "does-not-exist",
|
||||
},
|
||||
RemoteLocation: &remote_pb.RemoteStorageLocation{
|
||||
Name: "it-invalid-remote",
|
||||
Path: "/test",
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "get remote client") {
|
||||
t.Fatalf("FetchAndWriteNeedle invalid-remote error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTierMoveDatToRemoteErrorPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(85)
|
||||
const collection = "tier-collection"
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
missingStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
|
||||
VolumeId: 98782,
|
||||
Collection: collection,
|
||||
DestinationBackendName: "dummy",
|
||||
})
|
||||
if err == nil {
|
||||
_, err = missingStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeTierMoveDatToRemote missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
mismatchStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "wrong-collection",
|
||||
DestinationBackendName: "dummy",
|
||||
})
|
||||
if err == nil {
|
||||
_, err = mismatchStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected input") {
|
||||
t.Fatalf("VolumeTierMoveDatToRemote collection mismatch error mismatch: %v", err)
|
||||
}
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{
|
||||
Maintenance: true,
|
||||
Version: stateResp.GetState().GetVersion(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
maintenanceStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
DestinationBackendName: "dummy",
|
||||
})
|
||||
if err == nil {
|
||||
_, err = maintenanceStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("VolumeTierMoveDatToRemote maintenance error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTierMoveDatToRemoteMissingBackend(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(89)
|
||||
const collection = "tier-missing-backend"
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
DestinationBackendName: "definitely-missing-backend",
|
||||
})
|
||||
if err == nil {
|
||||
_, err = stream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "destination definitely-missing-backend not found") {
|
||||
t.Fatalf("VolumeTierMoveDatToRemote missing-backend error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTierMoveDatFromRemoteErrorPaths(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(86)
|
||||
const collection = "tier-download-collection"
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
missingStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
|
||||
VolumeId: 98783,
|
||||
Collection: collection,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = missingStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("VolumeTierMoveDatFromRemote missing-volume error mismatch: %v", err)
|
||||
}
|
||||
|
||||
mismatchStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: "wrong-collection",
|
||||
})
|
||||
if err == nil {
|
||||
_, err = mismatchStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected input") {
|
||||
t.Fatalf("VolumeTierMoveDatFromRemote collection mismatch error mismatch: %v", err)
|
||||
}
|
||||
|
||||
localDiskStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = localDiskStream.Recv()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already on local disk") {
|
||||
t.Fatalf("VolumeTierMoveDatFromRemote local-disk error mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package volume_server_grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
)
|
||||
|
||||
func TestVacuumVolumeCheckSuccessAndMissingVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(31)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.VacuumVolumeCheck(ctx, &volume_server_pb.VacuumVolumeCheckRequest{VolumeId: volumeID})
|
||||
if err != nil {
|
||||
t.Fatalf("VacuumVolumeCheck existing volume failed: %v", err)
|
||||
}
|
||||
if resp.GetGarbageRatio() < 0 || resp.GetGarbageRatio() > 1 {
|
||||
t.Fatalf("unexpected garbage ratio: %f", resp.GetGarbageRatio())
|
||||
}
|
||||
|
||||
_, err = grpcClient.VacuumVolumeCheck(ctx, &volume_server_pb.VacuumVolumeCheckRequest{VolumeId: 99999})
|
||||
if err == nil {
|
||||
t.Fatalf("VacuumVolumeCheck should fail for missing volume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumMaintenanceModeRejections(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetState failed: %v", err)
|
||||
}
|
||||
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
|
||||
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetState maintenance=true failed: %v", err)
|
||||
}
|
||||
|
||||
assertMaintenanceErr := func(name string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s should fail in maintenance mode", name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maintenance mode") {
|
||||
t.Fatalf("%s expected maintenance mode error, got: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
compactStream, err := grpcClient.VacuumVolumeCompact(ctx, &volume_server_pb.VacuumVolumeCompactRequest{VolumeId: 31})
|
||||
if err == nil {
|
||||
_, err = compactStream.Recv()
|
||||
}
|
||||
assertMaintenanceErr("VacuumVolumeCompact", err)
|
||||
|
||||
_, err = grpcClient.VacuumVolumeCommit(ctx, &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: 31})
|
||||
assertMaintenanceErr("VacuumVolumeCommit", err)
|
||||
|
||||
_, err = grpcClient.VacuumVolumeCleanup(ctx, &volume_server_pb.VacuumVolumeCleanupRequest{VolumeId: 31})
|
||||
assertMaintenanceErr("VacuumVolumeCleanup", err)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
)
|
||||
|
||||
func TestAdminStatusAndHealthz(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
statusReq, err := http.NewRequest(http.MethodGet, cluster.VolumeAdminURL()+"/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create status request: %v", err)
|
||||
}
|
||||
statusReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-1")
|
||||
|
||||
statusResp := framework.DoRequest(t, client, statusReq)
|
||||
statusBody := framework.ReadAllAndClose(t, statusResp)
|
||||
|
||||
if statusResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected /status code 200, got %d, body: %s", statusResp.StatusCode, string(statusBody))
|
||||
}
|
||||
if got := statusResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
|
||||
t.Fatalf("expected /status Server header to contain SeaweedFS Volume, got %q", got)
|
||||
}
|
||||
if got := statusResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-1" {
|
||||
t.Fatalf("expected echoed request id, got %q", got)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(statusBody, &payload); err != nil {
|
||||
t.Fatalf("decode status response: %v", err)
|
||||
}
|
||||
for _, field := range []string{"Version", "DiskStatuses", "Volumes"} {
|
||||
if _, found := payload[field]; !found {
|
||||
t.Fatalf("status payload missing field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
healthReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/healthz")
|
||||
healthReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-2")
|
||||
healthResp := framework.DoRequest(t, client, healthReq)
|
||||
_ = framework.ReadAllAndClose(t, healthResp)
|
||||
if healthResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected /healthz code 200, got %d", healthResp.StatusCode)
|
||||
}
|
||||
if got := healthResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
|
||||
t.Fatalf("expected /healthz Server header to contain SeaweedFS Volume, got %q", got)
|
||||
}
|
||||
if got := healthResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-2" {
|
||||
t.Fatalf("expected /healthz echoed request id, got %q", got)
|
||||
}
|
||||
|
||||
uiResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/ui/index.html"))
|
||||
uiBody := framework.ReadAllAndClose(t, uiResp)
|
||||
if uiResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected /ui/index.html code 200, got %d, body: %s", uiResp.StatusCode, string(uiBody))
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(string(uiBody)), "volume") {
|
||||
t.Fatalf("ui page does not look like volume status page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsMethodsByPort(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
adminResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodOptions, cluster.VolumeAdminURL()+"/"))
|
||||
_ = framework.ReadAllAndClose(t, adminResp)
|
||||
if adminResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin OPTIONS expected 200, got %d", adminResp.StatusCode)
|
||||
}
|
||||
adminAllowed := adminResp.Header.Get("Access-Control-Allow-Methods")
|
||||
for _, expected := range []string{"PUT", "POST", "GET", "DELETE", "OPTIONS"} {
|
||||
if !strings.Contains(adminAllowed, expected) {
|
||||
t.Fatalf("admin allow methods missing %q, got %q", expected, adminAllowed)
|
||||
}
|
||||
}
|
||||
if adminResp.Header.Get("Access-Control-Allow-Headers") != "*" {
|
||||
t.Fatalf("admin allow headers expected '*', got %q", adminResp.Header.Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
|
||||
publicResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodOptions, cluster.VolumePublicURL()+"/"))
|
||||
_ = framework.ReadAllAndClose(t, publicResp)
|
||||
if publicResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public OPTIONS expected 200, got %d", publicResp.StatusCode)
|
||||
}
|
||||
publicAllowed := publicResp.Header.Get("Access-Control-Allow-Methods")
|
||||
if !strings.Contains(publicAllowed, "GET") || !strings.Contains(publicAllowed, "OPTIONS") {
|
||||
t.Fatalf("public allow methods expected GET and OPTIONS, got %q", publicAllowed)
|
||||
}
|
||||
if strings.Contains(publicAllowed, "POST") {
|
||||
t.Fatalf("public allow methods should not include POST, got %q", publicAllowed)
|
||||
}
|
||||
if publicResp.Header.Get("Access-Control-Allow-Headers") != "*" {
|
||||
t.Fatalf("public allow headers expected '*', got %q", publicResp.Header.Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsWithOriginIncludesCorsHeaders(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
adminReq := mustNewRequest(t, http.MethodOptions, cluster.VolumeAdminURL()+"/")
|
||||
adminReq.Header.Set("Origin", "https://example.com")
|
||||
adminResp := framework.DoRequest(t, client, adminReq)
|
||||
_ = framework.ReadAllAndClose(t, adminResp)
|
||||
if adminResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin OPTIONS expected 200, got %d", adminResp.StatusCode)
|
||||
}
|
||||
if adminResp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("admin OPTIONS expected Access-Control-Allow-Origin=*, got %q", adminResp.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if adminResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("admin OPTIONS expected Access-Control-Allow-Credentials=true, got %q", adminResp.Header.Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
publicReq := mustNewRequest(t, http.MethodOptions, cluster.VolumePublicURL()+"/")
|
||||
publicReq.Header.Set("Origin", "https://example.com")
|
||||
publicResp := framework.DoRequest(t, client, publicReq)
|
||||
_ = framework.ReadAllAndClose(t, publicResp)
|
||||
if publicResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public OPTIONS expected 200, got %d", publicResp.StatusCode)
|
||||
}
|
||||
if publicResp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("public OPTIONS expected Access-Control-Allow-Origin=*, got %q", publicResp.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if publicResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("public OPTIONS expected Access-Control-Allow-Credentials=true, got %q", publicResp.Header.Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUiIndexNotExposedWhenJwtSigningEnabled(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P3())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/ui/index.html"))
|
||||
body := framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected /ui/index.html to be gated by auth under JWT profile (401), got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRequest(t testing.TB, method, url string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request %s %s: %v", method, url, err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
)
|
||||
|
||||
func TestJWTAuthForWriteAndRead(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(51)
|
||||
const needleID = uint64(123456)
|
||||
const cookie = uint32(0xABCDEF12)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
payload := []byte("jwt-protected-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
unauthWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
unauthWriteResp := framework.DoRequest(t, client, unauthWrite)
|
||||
_ = framework.ReadAllAndClose(t, unauthWriteResp)
|
||||
if unauthWriteResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized write expected 401, got %d", unauthWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
invalidWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
invalidWrite.Header.Set("Authorization", "Bearer invalid")
|
||||
invalidWriteResp := framework.DoRequest(t, client, invalidWrite)
|
||||
_ = framework.ReadAllAndClose(t, invalidWriteResp)
|
||||
if invalidWriteResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid write token expected 401, got %d", invalidWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
writeToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
authWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
authWrite.Header.Set("Authorization", "Bearer "+string(writeToken))
|
||||
authWriteResp := framework.DoRequest(t, client, authWrite)
|
||||
_ = framework.ReadAllAndClose(t, authWriteResp)
|
||||
if authWriteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("authorized write expected 201, got %d", authWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
unauthReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
unauthReadResp := framework.DoRequest(t, client, unauthReadReq)
|
||||
_ = framework.ReadAllAndClose(t, unauthReadResp)
|
||||
if unauthReadResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized read expected 401, got %d", unauthReadResp.StatusCode)
|
||||
}
|
||||
|
||||
readToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
|
||||
authReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
authReadReq.Header.Set("Authorization", "Bearer "+string(readToken))
|
||||
authReadResp := framework.DoRequest(t, client, authReadReq)
|
||||
authReadBody := framework.ReadAllAndClose(t, authReadResp)
|
||||
if authReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authorized read expected 200, got %d", authReadResp.StatusCode)
|
||||
}
|
||||
if string(authReadBody) != string(payload) {
|
||||
t.Fatalf("authorized read content mismatch: got %q want %q", string(authReadBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAuthRejectsFidMismatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(52)
|
||||
const needleID = uint64(223344)
|
||||
const cookie = uint32(0x10203040)
|
||||
const otherNeedleID = uint64(223345)
|
||||
const otherCookie = uint32(0x50607080)
|
||||
const wrongCookie = uint32(0x10203041)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
otherFid := framework.NewFileID(volumeID, otherNeedleID, otherCookie)
|
||||
payload := []byte("jwt-fid-mismatch-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
writeTokenForOtherFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFid)
|
||||
mismatchedWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
mismatchedWrite.Header.Set("Authorization", "Bearer "+string(writeTokenForOtherFid))
|
||||
mismatchedWriteResp := framework.DoRequest(t, client, mismatchedWrite)
|
||||
_ = framework.ReadAllAndClose(t, mismatchedWriteResp)
|
||||
if mismatchedWriteResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("write with mismatched fid token expected 401, got %d", mismatchedWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
wrongCookieFid := framework.NewFileID(volumeID, needleID, wrongCookie)
|
||||
writeTokenWrongCookie := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, wrongCookieFid)
|
||||
wrongCookieWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
wrongCookieWrite.Header.Set("Authorization", "Bearer "+string(writeTokenWrongCookie))
|
||||
wrongCookieWriteResp := framework.DoRequest(t, client, wrongCookieWrite)
|
||||
_ = framework.ReadAllAndClose(t, wrongCookieWriteResp)
|
||||
if wrongCookieWriteResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("write with wrong-cookie fid token expected 401, got %d", wrongCookieWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
writeTokenForFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
validWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
validWrite.Header.Set("Authorization", "Bearer "+string(writeTokenForFid))
|
||||
validWriteResp := framework.DoRequest(t, client, validWrite)
|
||||
_ = framework.ReadAllAndClose(t, validWriteResp)
|
||||
if validWriteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("authorized write expected 201, got %d", validWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
readTokenForOtherFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFid)
|
||||
mismatchedReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
mismatchedReadReq.Header.Set("Authorization", "Bearer "+string(readTokenForOtherFid))
|
||||
mismatchedReadResp := framework.DoRequest(t, client, mismatchedReadReq)
|
||||
_ = framework.ReadAllAndClose(t, mismatchedReadResp)
|
||||
if mismatchedReadResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("read with mismatched fid token expected 401, got %d", mismatchedReadResp.StatusCode)
|
||||
}
|
||||
|
||||
readTokenWrongCookie := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, wrongCookieFid)
|
||||
wrongCookieReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
wrongCookieReadReq.Header.Set("Authorization", "Bearer "+string(readTokenWrongCookie))
|
||||
wrongCookieReadResp := framework.DoRequest(t, client, wrongCookieReadReq)
|
||||
_ = framework.ReadAllAndClose(t, wrongCookieReadResp)
|
||||
if wrongCookieReadResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("read with wrong-cookie fid token expected 401, got %d", wrongCookieReadResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func newUploadRequest(t testing.TB, url string, payload []byte) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("create upload request %s: %v", url, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestJWTAuthRejectsExpiredTokens(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(53)
|
||||
const needleID = uint64(334455)
|
||||
const cookie = uint32(0x22334455)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
payload := []byte("expired-token-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
expiredWriteToken := mustGenExpiredToken(t, []byte(profile.JWTSigningKey), fid)
|
||||
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
writeReq.Header.Set("Authorization", "Bearer "+expiredWriteToken)
|
||||
writeResp := framework.DoRequest(t, client, writeReq)
|
||||
_ = framework.ReadAllAndClose(t, writeResp)
|
||||
if writeResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expired write token expected 401, got %d", writeResp.StatusCode)
|
||||
}
|
||||
|
||||
// Seed data with a valid token so read auth path can be exercised against existing content.
|
||||
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
validWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
validWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
|
||||
validWriteResp := framework.DoRequest(t, client, validWriteReq)
|
||||
_ = framework.ReadAllAndClose(t, validWriteResp)
|
||||
if validWriteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("valid write expected 201, got %d", validWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
expiredReadToken := mustGenExpiredToken(t, []byte(profile.JWTReadKey), fid)
|
||||
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
readReq.Header.Set("Authorization", "Bearer "+expiredReadToken)
|
||||
readResp := framework.DoRequest(t, client, readReq)
|
||||
_ = framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expired read token expected 401, got %d", readResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAuthViaQueryParamAndCookie(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(54)
|
||||
const needleID = uint64(445566)
|
||||
const cookie = uint32(0x31415926)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
payload := []byte("jwt-query-cookie-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
writeToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(writeToken), payload)
|
||||
writeResp := framework.DoRequest(t, client, writeReq)
|
||||
_ = framework.ReadAllAndClose(t, writeResp)
|
||||
if writeResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("query-jwt write expected 201, got %d", writeResp.StatusCode)
|
||||
}
|
||||
|
||||
readToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
|
||||
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(readToken)})
|
||||
readResp := framework.DoRequest(t, client, readReq)
|
||||
readBody := framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("cookie-jwt read expected 200, got %d", readResp.StatusCode)
|
||||
}
|
||||
if string(readBody) != string(payload) {
|
||||
t.Fatalf("cookie-jwt read body mismatch: got %q want %q", string(readBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTTokenSourcePrecedenceQueryOverHeader(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(55)
|
||||
const needleID = uint64(556677)
|
||||
const cookie = uint32(0x99887766)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
|
||||
payload := []byte("jwt-precedence-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
invalidWriteQueryToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
|
||||
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidWriteQueryToken), payload)
|
||||
writeReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
|
||||
writeResp := framework.DoRequest(t, client, writeReq)
|
||||
_ = framework.ReadAllAndClose(t, writeResp)
|
||||
if writeResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("query token should take precedence over header token for write, expected 401 got %d", writeResp.StatusCode)
|
||||
}
|
||||
|
||||
// Seed data with valid write token, then exercise read precedence.
|
||||
seedWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
seedWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
|
||||
seedWriteResp := framework.DoRequest(t, client, seedWriteReq)
|
||||
_ = framework.ReadAllAndClose(t, seedWriteResp)
|
||||
if seedWriteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("seed write expected 201, got %d", seedWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
|
||||
invalidReadQueryToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
|
||||
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidReadQueryToken))
|
||||
readReq.Header.Set("Authorization", "Bearer "+string(validReadToken))
|
||||
readResp := framework.DoRequest(t, client, readReq)
|
||||
_ = framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("query token should take precedence over header token for read, expected 401 got %d", readResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTTokenSourcePrecedenceHeaderOverCookie(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(56)
|
||||
const needleID = uint64(667788)
|
||||
const cookie = uint32(0x11229988)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
|
||||
payload := []byte("jwt-precedence-header-cookie")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
invalidCookieWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
|
||||
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
writeReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
|
||||
writeReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieWriteToken)})
|
||||
writeResp := framework.DoRequest(t, client, writeReq)
|
||||
_ = framework.ReadAllAndClose(t, writeResp)
|
||||
if writeResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("header token should take precedence over cookie token for write, expected 201 got %d", writeResp.StatusCode)
|
||||
}
|
||||
|
||||
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
|
||||
invalidCookieReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
|
||||
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
readReq.Header.Set("Authorization", "Bearer "+string(validReadToken))
|
||||
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieReadToken)})
|
||||
readResp := framework.DoRequest(t, client, readReq)
|
||||
readBody := framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("header token should take precedence over cookie token for read, expected 200 got %d", readResp.StatusCode)
|
||||
}
|
||||
if string(readBody) != string(payload) {
|
||||
t.Fatalf("header-over-cookie read body mismatch: got %q want %q", string(readBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTTokenSourcePrecedenceQueryOverCookie(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P3()
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(57)
|
||||
const needleID = uint64(778899)
|
||||
const cookie = uint32(0x88776655)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
|
||||
payload := []byte("jwt-precedence-query-cookie")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
|
||||
invalidQueryWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
|
||||
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidQueryWriteToken), payload)
|
||||
writeReq.AddCookie(&http.Cookie{Name: "AT", Value: string(validWriteToken)})
|
||||
writeResp := framework.DoRequest(t, client, writeReq)
|
||||
_ = framework.ReadAllAndClose(t, writeResp)
|
||||
if writeResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("query token should take precedence over cookie token for write, expected 401 got %d", writeResp.StatusCode)
|
||||
}
|
||||
|
||||
// Seed data with valid write token so read precedence can be exercised.
|
||||
seedWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
seedWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
|
||||
seedWriteResp := framework.DoRequest(t, client, seedWriteReq)
|
||||
_ = framework.ReadAllAndClose(t, seedWriteResp)
|
||||
if seedWriteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("seed write expected 201, got %d", seedWriteResp.StatusCode)
|
||||
}
|
||||
|
||||
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
|
||||
invalidQueryReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
|
||||
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidQueryReadToken))
|
||||
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(validReadToken)})
|
||||
readResp := framework.DoRequest(t, client, readReq)
|
||||
_ = framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("query token should take precedence over cookie token for read, expected 401 got %d", readResp.StatusCode)
|
||||
}
|
||||
|
||||
// Validate positive path: valid query token should succeed even if cookie token is invalid.
|
||||
validQueryReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(validReadToken))
|
||||
invalidCookieReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
|
||||
validQueryReadReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieReadToken)})
|
||||
validQueryReadResp := framework.DoRequest(t, client, validQueryReadReq)
|
||||
validQueryReadBody := framework.ReadAllAndClose(t, validQueryReadResp)
|
||||
if validQueryReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("valid query token should succeed over invalid cookie token, expected 200 got %d", validQueryReadResp.StatusCode)
|
||||
}
|
||||
if string(validQueryReadBody) != string(payload) {
|
||||
t.Fatalf("query-over-cookie read body mismatch: got %q want %q", string(validQueryReadBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func mustGenExpiredToken(t testing.TB, key []byte, fid string) string {
|
||||
t.Helper()
|
||||
claims := security.SeaweedFileIdClaims{
|
||||
Fid: fid,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign expired token: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation"
|
||||
)
|
||||
|
||||
func TestChunkManifestExpansionAndBypass(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(102)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
chunkFID := framework.NewFileID(volumeID, 772005, 0x5E6F7081)
|
||||
chunkPayload := []byte("chunk-manifest-expanded-content")
|
||||
chunkUploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID, chunkPayload)
|
||||
_ = framework.ReadAllAndClose(t, chunkUploadResp)
|
||||
if chunkUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("chunk upload expected 201, got %d", chunkUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
manifest := &operation.ChunkManifest{
|
||||
Name: "manifest.bin",
|
||||
Mime: "application/octet-stream",
|
||||
Size: int64(len(chunkPayload)),
|
||||
Chunks: []*operation.ChunkInfo{
|
||||
{
|
||||
Fid: chunkFID,
|
||||
Offset: 0,
|
||||
Size: int64(len(chunkPayload)),
|
||||
},
|
||||
},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk manifest: %v", err)
|
||||
}
|
||||
|
||||
manifestFID := framework.NewFileID(volumeID, 772006, 0x6F708192)
|
||||
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("create manifest upload request: %v", err)
|
||||
}
|
||||
manifestUploadReq.Header.Set("Content-Type", "application/json")
|
||||
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
|
||||
_ = framework.ReadAllAndClose(t, manifestUploadResp)
|
||||
if manifestUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
expandedReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), manifestFID)
|
||||
expandedReadBody := framework.ReadAllAndClose(t, expandedReadResp)
|
||||
if expandedReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("manifest expanded read expected 200, got %d", expandedReadResp.StatusCode)
|
||||
}
|
||||
if string(expandedReadBody) != string(chunkPayload) {
|
||||
t.Fatalf("manifest expanded read mismatch: got %q want %q", string(expandedReadBody), string(chunkPayload))
|
||||
}
|
||||
if expandedReadResp.Header.Get("X-File-Store") != "chunked" {
|
||||
t.Fatalf("manifest expanded read expected X-File-Store=chunked, got %q", expandedReadResp.Header.Get("X-File-Store"))
|
||||
}
|
||||
|
||||
bypassReadResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=false"))
|
||||
bypassReadBody := framework.ReadAllAndClose(t, bypassReadResp)
|
||||
if bypassReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("manifest bypass read expected 200, got %d", bypassReadResp.StatusCode)
|
||||
}
|
||||
if bypassReadResp.Header.Get("X-File-Store") != "" {
|
||||
t.Fatalf("manifest bypass read expected empty X-File-Store header, got %q", bypassReadResp.Header.Get("X-File-Store"))
|
||||
}
|
||||
|
||||
var gotManifest operation.ChunkManifest
|
||||
if err = json.Unmarshal(bypassReadBody, &gotManifest); err != nil {
|
||||
t.Fatalf("manifest bypass read expected JSON payload, got decode error: %v body=%q", err, string(bypassReadBody))
|
||||
}
|
||||
if len(gotManifest.Chunks) != 1 || gotManifest.Chunks[0].Fid != chunkFID {
|
||||
t.Fatalf("manifest bypass read payload mismatch: %+v", gotManifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkManifestDeleteRemovesChildChunks(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(104)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
chunkFID := framework.NewFileID(volumeID, 772008, 0x8192A3B4)
|
||||
chunkPayload := []byte("chunk-manifest-delete-content")
|
||||
chunkUploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID, chunkPayload)
|
||||
_ = framework.ReadAllAndClose(t, chunkUploadResp)
|
||||
if chunkUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("chunk upload expected 201, got %d", chunkUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
manifest := &operation.ChunkManifest{
|
||||
Name: "manifest-delete.bin",
|
||||
Mime: "application/octet-stream",
|
||||
Size: int64(len(chunkPayload)),
|
||||
Chunks: []*operation.ChunkInfo{
|
||||
{
|
||||
Fid: chunkFID,
|
||||
Offset: 0,
|
||||
Size: int64(len(chunkPayload)),
|
||||
},
|
||||
},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk manifest: %v", err)
|
||||
}
|
||||
|
||||
manifestFID := framework.NewFileID(volumeID, 772009, 0x92A3B4C5)
|
||||
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("create manifest upload request: %v", err)
|
||||
}
|
||||
manifestUploadReq.Header.Set("Content-Type", "application/json")
|
||||
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
|
||||
_ = framework.ReadAllAndClose(t, manifestUploadResp)
|
||||
if manifestUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+manifestFID))
|
||||
deleteBody := framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("manifest delete expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
var deleteResult map[string]int64
|
||||
if err = json.Unmarshal(deleteBody, &deleteResult); err != nil {
|
||||
t.Fatalf("decode manifest delete response: %v body=%q", err, string(deleteBody))
|
||||
}
|
||||
if deleteResult["size"] != int64(len(chunkPayload)) {
|
||||
t.Fatalf("manifest delete expected size=%d, got %d", len(chunkPayload), deleteResult["size"])
|
||||
}
|
||||
|
||||
manifestReadAfterDelete := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), manifestFID)
|
||||
_ = framework.ReadAllAndClose(t, manifestReadAfterDelete)
|
||||
if manifestReadAfterDelete.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("manifest read after delete expected 404, got %d", manifestReadAfterDelete.StatusCode)
|
||||
}
|
||||
|
||||
chunkReadAfterDelete := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID)
|
||||
_ = framework.ReadAllAndClose(t, chunkReadAfterDelete)
|
||||
if chunkReadAfterDelete.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("chunk read after manifest delete expected 404, got %d", chunkReadAfterDelete.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkManifestDeleteFailsWhenChildDeletionFails(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(105)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
manifest := &operation.ChunkManifest{
|
||||
Name: "manifest-delete-failure.bin",
|
||||
Mime: "application/octet-stream",
|
||||
Size: 1,
|
||||
Chunks: []*operation.ChunkInfo{
|
||||
{
|
||||
Fid: "not-a-valid-fid",
|
||||
Offset: 0,
|
||||
Size: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk manifest: %v", err)
|
||||
}
|
||||
|
||||
manifestFID := framework.NewFileID(volumeID, 772010, 0xA3B4C5D6)
|
||||
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("create manifest upload request: %v", err)
|
||||
}
|
||||
manifestUploadReq.Header.Set("Content-Type", "application/json")
|
||||
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
|
||||
_ = framework.ReadAllAndClose(t, manifestUploadResp)
|
||||
if manifestUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+manifestFID))
|
||||
deleteBody := framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("manifest delete with invalid child fid expected 500, got %d body=%q", deleteResp.StatusCode, string(deleteBody))
|
||||
}
|
||||
|
||||
manifestBypassRead := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=false"))
|
||||
manifestBypassBody := framework.ReadAllAndClose(t, manifestBypassRead)
|
||||
if manifestBypassRead.StatusCode != http.StatusOK {
|
||||
t.Fatalf("manifest bypass read after failed delete expected 200, got %d", manifestBypassRead.StatusCode)
|
||||
}
|
||||
var gotManifest operation.ChunkManifest
|
||||
if err = json.Unmarshal(manifestBypassBody, &gotManifest); err != nil {
|
||||
t.Fatalf("manifest bypass read expected JSON payload, got decode error: %v body=%q", err, string(manifestBypassBody))
|
||||
}
|
||||
if len(gotManifest.Chunks) != 1 || gotManifest.Chunks[0].Fid != "not-a-valid-fid" {
|
||||
t.Fatalf("manifest payload mismatch after failed delete: %+v", gotManifest)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func gzipData(t testing.TB, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
t.Fatalf("gzip write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func gunzipData(t testing.TB, data []byte) []byte {
|
||||
t.Helper()
|
||||
zr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip new reader: %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
out, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip read: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCompressedReadAcceptEncodingMatrix(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(103)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 772007, 0x708192A3)
|
||||
plainPayload := []byte("compressed-read-accept-encoding-matrix-content-compressed-read-accept-encoding-matrix-content")
|
||||
compressedPayload := gzipData(t, plainPayload)
|
||||
|
||||
uploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+fid, bytes.NewReader(compressedPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("create compressed upload request: %v", err)
|
||||
}
|
||||
uploadReq.Header.Set("Content-Type", "text/plain")
|
||||
uploadReq.Header.Set("Content-Encoding", "gzip")
|
||||
uploadResp := framework.DoRequest(t, client, uploadReq)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("compressed upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
gzipReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
gzipReadReq.Header.Set("Accept-Encoding", "gzip")
|
||||
gzipReadResp := framework.DoRequest(t, client, gzipReadReq)
|
||||
gzipReadBody := framework.ReadAllAndClose(t, gzipReadResp)
|
||||
if gzipReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("gzip-accepted read expected 200, got %d", gzipReadResp.StatusCode)
|
||||
}
|
||||
if gzipReadResp.Header.Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("gzip-accepted read expected Content-Encoding=gzip, got %q", gzipReadResp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
if string(gunzipData(t, gzipReadBody)) != string(plainPayload) {
|
||||
t.Fatalf("gzip-accepted read body mismatch after gunzip")
|
||||
}
|
||||
|
||||
identityReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
identityReadReq.Header.Set("Accept-Encoding", "identity")
|
||||
identityReadResp := framework.DoRequest(t, client, identityReadReq)
|
||||
identityReadBody := framework.ReadAllAndClose(t, identityReadResp)
|
||||
if identityReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("identity read expected 200, got %d", identityReadResp.StatusCode)
|
||||
}
|
||||
if identityReadResp.Header.Get("Content-Encoding") != "" {
|
||||
t.Fatalf("identity read expected no Content-Encoding header, got %q", identityReadResp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
if string(identityReadBody) != string(plainPayload) {
|
||||
t.Fatalf("identity read body mismatch: got %q want %q", string(identityReadBody), string(plainPayload))
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestReadPassthroughHeadersAndDownloadDisposition(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(96)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fullFileID := framework.NewFileID(volumeID, 661122, 0x55667788)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fullFileID, []byte("passthrough-header-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(fullFileID, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("unexpected file id format: %q", fullFileID)
|
||||
}
|
||||
fidOnly := parts[1]
|
||||
|
||||
url := fmt.Sprintf("%s/%d/%s/%s?response-content-type=text/plain&response-cache-control=no-store&dl=true",
|
||||
clusterHarness.VolumeAdminURL(),
|
||||
volumeID,
|
||||
fidOnly,
|
||||
"report.txt",
|
||||
)
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, url))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("passthrough read expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp.Header.Get("Content-Type") != "text/plain" {
|
||||
t.Fatalf("response-content-type override mismatch: %q", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response-cache-control override mismatch: %q", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if !strings.Contains(contentDisposition, "attachment") || !strings.Contains(contentDisposition, "report.txt") {
|
||||
t.Fatalf("download disposition header mismatch: %q", contentDisposition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssetEndpoints(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
faviconResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/favicon.ico"))
|
||||
_ = framework.ReadAllAndClose(t, faviconResp)
|
||||
if faviconResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/favicon.ico expected 200, got %d", faviconResp.StatusCode)
|
||||
}
|
||||
|
||||
staticResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/seaweedfsstatic/seaweed50x50.png"))
|
||||
_ = framework.ReadAllAndClose(t, staticResp)
|
||||
if staticResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/seaweedfsstatic/seaweed50x50.png expected 200, got %d", staticResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssetEndpointsOnPublicPort(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
faviconResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/favicon.ico"))
|
||||
_ = framework.ReadAllAndClose(t, faviconResp)
|
||||
if faviconResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public /favicon.ico expected 200, got %d", faviconResp.StatusCode)
|
||||
}
|
||||
|
||||
staticResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/seaweedfsstatic/seaweed50x50.png"))
|
||||
_ = framework.ReadAllAndClose(t, staticResp)
|
||||
if staticResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public /seaweedfsstatic/seaweed50x50.png expected 200, got %d", staticResp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func makePNGFixture(t testing.TB, width, height int) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x * 20), G: uint8(y * 20), B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png fixture: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decodeImageConfig(t testing.TB, data []byte) image.Config {
|
||||
t.Helper()
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode image config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestImageResizeAndCropReadVariants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(101)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fullFileID := framework.NewFileID(volumeID, 772004, 0x4D5E6F70)
|
||||
uploadReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fullFileID, makePNGFixture(t, 6, 4))
|
||||
uploadReq.Header.Set("Content-Type", "image/png")
|
||||
uploadResp := framework.DoRequest(t, client, uploadReq)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("image upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(fullFileID, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("unexpected file id format: %q", fullFileID)
|
||||
}
|
||||
fidOnly := parts[1]
|
||||
|
||||
resizeURL := fmt.Sprintf("%s/%d/%s/%s?width=2&height=1", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "fixture.png")
|
||||
resizeResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, resizeURL))
|
||||
resizeBody := framework.ReadAllAndClose(t, resizeResp)
|
||||
if resizeResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("image resize read expected 200, got %d", resizeResp.StatusCode)
|
||||
}
|
||||
resizeCfg := decodeImageConfig(t, resizeBody)
|
||||
if resizeCfg.Width > 2 || resizeCfg.Height > 1 {
|
||||
t.Fatalf("image resize expected dimensions <= 2x1, got %dx%d", resizeCfg.Width, resizeCfg.Height)
|
||||
}
|
||||
|
||||
cropURL := fmt.Sprintf("%s/%d/%s/%s?crop_x1=1&crop_y1=1&crop_x2=4&crop_y2=3", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "fixture.png")
|
||||
cropResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cropURL))
|
||||
cropBody := framework.ReadAllAndClose(t, cropResp)
|
||||
if cropResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("image crop read expected 200, got %d", cropResp.StatusCode)
|
||||
}
|
||||
cropCfg := decodeImageConfig(t, cropBody)
|
||||
if cropCfg.Width != 3 || cropCfg.Height != 2 {
|
||||
t.Fatalf("image crop expected 3x2, got %dx%d", cropCfg.Width, cropCfg.Height)
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestPublicPortReadOnlyMethodBehavior(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(81)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 123321, 0x01020304)
|
||||
originalData := []byte("public-port-original")
|
||||
replacementData := []byte("public-port-replacement")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, originalData)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("admin upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
publicReadResp := framework.ReadBytes(t, client, clusterHarness.VolumePublicURL(), fid)
|
||||
publicReadBody := framework.ReadAllAndClose(t, publicReadResp)
|
||||
if publicReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public GET expected 200, got %d", publicReadResp.StatusCode)
|
||||
}
|
||||
if string(publicReadBody) != string(originalData) {
|
||||
t.Fatalf("public GET body mismatch: got %q want %q", string(publicReadBody), string(originalData))
|
||||
}
|
||||
|
||||
publicPostReq := newUploadRequest(t, clusterHarness.VolumePublicURL()+"/"+fid, replacementData)
|
||||
publicPostResp := framework.DoRequest(t, client, publicPostReq)
|
||||
_ = framework.ReadAllAndClose(t, publicPostResp)
|
||||
if publicPostResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public POST expected passthrough 200, got %d", publicPostResp.StatusCode)
|
||||
}
|
||||
|
||||
publicDeleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumePublicURL()+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, publicDeleteResp)
|
||||
if publicDeleteResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public DELETE expected passthrough 200, got %d", publicDeleteResp.StatusCode)
|
||||
}
|
||||
|
||||
adminReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
adminReadBody := framework.ReadAllAndClose(t, adminReadResp)
|
||||
if adminReadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin GET after public POST/DELETE expected 200, got %d", adminReadResp.StatusCode)
|
||||
}
|
||||
if string(adminReadBody) != string(originalData) {
|
||||
t.Fatalf("public port should not mutate data: got %q want %q", string(adminReadBody), string(originalData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsAndUnsupportedMethodBehavior(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(82)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 789789, 0x0A0B0C0D)
|
||||
client := framework.NewHTTPClient()
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("cors-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("admin upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
adminOriginReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
adminOriginReq.Header.Set("Origin", "https://example.com")
|
||||
adminOriginResp := framework.DoRequest(t, client, adminOriginReq)
|
||||
_ = framework.ReadAllAndClose(t, adminOriginResp)
|
||||
if adminOriginResp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("admin GET origin header mismatch: %q", adminOriginResp.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if adminOriginResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("admin GET credentials header mismatch: %q", adminOriginResp.Header.Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
publicOriginReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/"+fid)
|
||||
publicOriginReq.Header.Set("Origin", "https://example.com")
|
||||
publicOriginResp := framework.DoRequest(t, client, publicOriginReq)
|
||||
_ = framework.ReadAllAndClose(t, publicOriginResp)
|
||||
if publicOriginResp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("public GET origin header mismatch: %q", publicOriginResp.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if publicOriginResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("public GET credentials header mismatch: %q", publicOriginResp.Header.Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
adminPatchReq, err := http.NewRequest(http.MethodPatch, clusterHarness.VolumeAdminURL()+"/"+fid, bytes.NewReader([]byte("patch")))
|
||||
if err != nil {
|
||||
t.Fatalf("create admin PATCH request: %v", err)
|
||||
}
|
||||
adminPatchResp := framework.DoRequest(t, client, adminPatchReq)
|
||||
_ = framework.ReadAllAndClose(t, adminPatchResp)
|
||||
if adminPatchResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("admin PATCH expected 400, got %d", adminPatchResp.StatusCode)
|
||||
}
|
||||
|
||||
publicPatchReq, err := http.NewRequest(http.MethodPatch, clusterHarness.VolumePublicURL()+"/"+fid, bytes.NewReader([]byte("patch")))
|
||||
if err != nil {
|
||||
t.Fatalf("create public PATCH request: %v", err)
|
||||
}
|
||||
publicPatchResp := framework.DoRequest(t, client, publicPatchReq)
|
||||
_ = framework.ReadAllAndClose(t, publicPatchResp)
|
||||
if publicPatchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public PATCH expected passthrough 200, got %d", publicPatchResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedMethodTraceParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(83)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 123999, 0x01010101)
|
||||
client := framework.NewHTTPClient()
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("trace-method-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
adminTraceReq := mustNewRequest(t, http.MethodTrace, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
adminTraceResp := framework.DoRequest(t, client, adminTraceReq)
|
||||
_ = framework.ReadAllAndClose(t, adminTraceResp)
|
||||
if adminTraceResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("admin TRACE expected 400, got %d", adminTraceResp.StatusCode)
|
||||
}
|
||||
|
||||
publicTraceReq := mustNewRequest(t, http.MethodTrace, clusterHarness.VolumePublicURL()+"/"+fid)
|
||||
publicTraceResp := framework.DoRequest(t, client, publicTraceReq)
|
||||
_ = framework.ReadAllAndClose(t, publicTraceResp)
|
||||
if publicTraceResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public TRACE expected passthrough 200, got %d", publicTraceResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedMethodPropfindParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(84)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 124000, 0x02020202)
|
||||
client := framework.NewHTTPClient()
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("propfind-method-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
adminReq := mustNewRequest(t, "PROPFIND", clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
adminResp := framework.DoRequest(t, client, adminReq)
|
||||
_ = framework.ReadAllAndClose(t, adminResp)
|
||||
if adminResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("admin PROPFIND expected 400, got %d", adminResp.StatusCode)
|
||||
}
|
||||
|
||||
publicReq := mustNewRequest(t, "PROPFIND", clusterHarness.VolumePublicURL()+"/"+fid)
|
||||
publicResp := framework.DoRequest(t, client, publicReq)
|
||||
_ = framework.ReadAllAndClose(t, publicResp)
|
||||
if publicResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public PROPFIND expected passthrough 200, got %d", publicResp.StatusCode)
|
||||
}
|
||||
|
||||
verifyResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
verifyBody := framework.ReadAllAndClose(t, verifyResp)
|
||||
if verifyResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify GET expected 200, got %d", verifyResp.StatusCode)
|
||||
}
|
||||
if string(verifyBody) != "propfind-method-check" {
|
||||
t.Fatalf("PROPFIND should not mutate data, got %q", string(verifyBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedMethodConnectParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(85)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 124001, 0x03030303)
|
||||
client := framework.NewHTTPClient()
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("connect-method-check"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
adminReq := mustNewRequest(t, "CONNECT", clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
adminResp := framework.DoRequest(t, client, adminReq)
|
||||
_ = framework.ReadAllAndClose(t, adminResp)
|
||||
if adminResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("admin CONNECT expected 400, got %d", adminResp.StatusCode)
|
||||
}
|
||||
|
||||
publicReq := mustNewRequest(t, "CONNECT", clusterHarness.VolumePublicURL()+"/"+fid)
|
||||
publicResp := framework.DoRequest(t, client, publicReq)
|
||||
_ = framework.ReadAllAndClose(t, publicResp)
|
||||
if publicResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public CONNECT expected passthrough 200, got %d", publicResp.StatusCode)
|
||||
}
|
||||
|
||||
verifyResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
verifyBody := framework.ReadAllAndClose(t, verifyResp)
|
||||
if verifyResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify GET expected 200, got %d", verifyResp.StatusCode)
|
||||
}
|
||||
if string(verifyBody) != "connect-method-check" {
|
||||
t.Fatalf("CONNECT should not mutate data, got %q", string(verifyBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPortHeadReadParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(86)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 124002, 0x04040404)
|
||||
payload := []byte("public-head-parity-content")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, clusterHarness.VolumePublicURL()+"/"+fid))
|
||||
headBody := framework.ReadAllAndClose(t, headResp)
|
||||
if headResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("public HEAD expected 200, got %d", headResp.StatusCode)
|
||||
}
|
||||
if got := headResp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) {
|
||||
t.Fatalf("public HEAD content-length mismatch: got %q want %d", got, len(payload))
|
||||
}
|
||||
if len(headBody) != 0 {
|
||||
t.Fatalf("public HEAD body should be empty, got %d bytes", len(headBody))
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestMultiRangeReadReturnsMultipartPayload(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(97)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 771999, 0x0A1B2C3D)
|
||||
payload := []byte("0123456789abcdef")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
multiRangeReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
multiRangeReq.Header.Set("Range", "bytes=0-1,4-5")
|
||||
multiRangeResp := framework.DoRequest(t, client, multiRangeReq)
|
||||
multiRangeBody := framework.ReadAllAndClose(t, multiRangeResp)
|
||||
if multiRangeResp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("multi-range expected 206, got %d", multiRangeResp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(multiRangeResp.Header.Get("Content-Type"), "multipart/byteranges") {
|
||||
t.Fatalf("multi-range content-type mismatch: %q", multiRangeResp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
bodyText := string(multiRangeBody)
|
||||
if !strings.Contains(bodyText, "01") || !strings.Contains(bodyText, "45") {
|
||||
t.Fatalf("multi-range body missing expected segments: %q", bodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedCombinedRangesAreIgnored(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(100)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 772003, 0x3C4D5E6F)
|
||||
payload := []byte("0123456789abcdef")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
// Range bytes sum is 22 (> payload size 16), which exercises the oversized-range guard path.
|
||||
oversizedRangeReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
oversizedRangeReq.Header.Set("Range", "bytes=0-10,5-15")
|
||||
oversizedRangeResp := framework.DoRequest(t, client, oversizedRangeReq)
|
||||
oversizedRangeBody := framework.ReadAllAndClose(t, oversizedRangeResp)
|
||||
if oversizedRangeResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("oversized combined range expected 200, got %d", oversizedRangeResp.StatusCode)
|
||||
}
|
||||
if len(oversizedRangeBody) != 0 {
|
||||
t.Fatalf("oversized combined range expected empty body, got %d bytes", len(oversizedRangeBody))
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestReadDeletedQueryReturnsDeletedNeedleData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(94)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 551234, 0xCAFE1234)
|
||||
payload := []byte("read-deleted-needle-payload")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
normalRead := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, normalRead)
|
||||
if normalRead.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("normal read after delete expected 404, got %d", normalRead.StatusCode)
|
||||
}
|
||||
|
||||
readDeletedReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?readDeleted=true")
|
||||
readDeletedResp := framework.DoRequest(t, client, readDeletedReq)
|
||||
readDeletedBody := framework.ReadAllAndClose(t, readDeletedResp)
|
||||
if readDeletedResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("read with readDeleted=true expected 200, got %d", readDeletedResp.StatusCode)
|
||||
}
|
||||
if string(readDeletedBody) != string(payload) {
|
||||
t.Fatalf("readDeleted body mismatch: got %q want %q", string(readDeletedBody), string(payload))
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestReadModeProxyMissingLocalVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "proxy"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(101)
|
||||
framework.AllocateVolume(t, grpc0, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120001, 0x0102ABCD)
|
||||
payload := []byte("proxy-read-mode-forwarded-content")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
|
||||
var finalBody []byte
|
||||
if !waitForHTTPStatus(t, client, readURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
|
||||
finalBody = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("proxy read mode did not return 200 from non-owning volume server within deadline")
|
||||
}
|
||||
if string(finalBody) != string(payload) {
|
||||
t.Fatalf("proxy read mode body mismatch: got %q want %q", string(finalBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModeRedirectMissingLocalVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "redirect"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(102)
|
||||
framework.AllocateVolume(t, grpc0, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120002, 0x0102DCBA)
|
||||
payload := []byte("redirect-read-mode-content")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
noRedirectClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
|
||||
var redirectLocation string
|
||||
if !waitForHTTPStatus(t, noRedirectClient, readURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
|
||||
redirectLocation = resp.Header.Get("Location")
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("redirect read mode did not return 301 from non-owning volume server within deadline")
|
||||
}
|
||||
if redirectLocation == "" {
|
||||
t.Fatalf("redirect response missing Location header")
|
||||
}
|
||||
if !strings.Contains(redirectLocation, "proxied=true") {
|
||||
t.Fatalf("redirect Location should include proxied=true, got %q", redirectLocation)
|
||||
}
|
||||
|
||||
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, redirectLocation))
|
||||
followBody := framework.ReadAllAndClose(t, followResp)
|
||||
if followResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("following redirect expected 200, got %d", followResp.StatusCode)
|
||||
}
|
||||
if string(followBody) != string(payload) {
|
||||
t.Fatalf("redirect-follow body mismatch: got %q want %q", string(followBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModeLocalMissingLocalVolumeReturnsNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "local"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(103)
|
||||
framework.AllocateVolume(t, grpc0, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120003, 0x0102BEEF)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, []byte("local-read-mode-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
readResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(1), fid)
|
||||
_ = framework.ReadAllAndClose(t, readResp)
|
||||
if readResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("local read mode expected 404 on non-owning server, got %d", readResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDeletedProxyModeOnMissingLocalVolume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "proxy"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(104)
|
||||
framework.AllocateVolume(t, grpc0, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120004, 0x0102CAFE)
|
||||
payload := []byte("proxy-readDeleted-missing-local-content")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL(0)+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?readDeleted=true"
|
||||
var proxiedBody []byte
|
||||
if !waitForHTTPStatus(t, client, readURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
|
||||
proxiedBody = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("proxy readDeleted path did not return 200 from non-owning volume server within deadline")
|
||||
}
|
||||
if string(proxiedBody) != string(payload) {
|
||||
t.Fatalf("proxy readDeleted body mismatch: got %q want %q", string(proxiedBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDeletedRedirectModeDropsQueryParameterParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "redirect"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(105)
|
||||
framework.AllocateVolume(t, grpc0, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120005, 0x0102FACE)
|
||||
payload := []byte("redirect-readDeleted-query-drop-parity")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL(0)+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
noRedirectClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
redirectURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?readDeleted=true"
|
||||
var location string
|
||||
if !waitForHTTPStatus(t, noRedirectClient, redirectURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
|
||||
location = resp.Header.Get("Location")
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("redirect readDeleted path did not return 301 from non-owning volume server within deadline")
|
||||
}
|
||||
if location == "" {
|
||||
t.Fatalf("redirect readDeleted response missing Location header")
|
||||
}
|
||||
if !strings.Contains(location, "proxied=true") {
|
||||
t.Fatalf("redirect readDeleted Location should include proxied=true, got %q", location)
|
||||
}
|
||||
if strings.Contains(location, "readDeleted=true") {
|
||||
t.Fatalf("redirect readDeleted Location should reflect current query-drop behavior, got %q", location)
|
||||
}
|
||||
|
||||
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, location))
|
||||
_ = framework.ReadAllAndClose(t, followResp)
|
||||
if followResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("redirect-follow without readDeleted query expected 404 for deleted needle, got %d", followResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModeRedirectPreservesCollectionQuery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P1()
|
||||
profile.ReadMode = "redirect"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
|
||||
const volumeID = uint32(109)
|
||||
const collection = "redirect-collection"
|
||||
framework.AllocateVolume(t, grpc0, volumeID, collection)
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 120006, 0x0102F00D)
|
||||
payload := []byte("redirect-collection-preserve-content")
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
noRedirectClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
redirectURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?collection=" + collection
|
||||
var location string
|
||||
if !waitForHTTPStatus(t, noRedirectClient, redirectURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
|
||||
location = resp.Header.Get("Location")
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("redirect collection path did not return 301 from non-owning volume server within deadline")
|
||||
}
|
||||
if location == "" {
|
||||
t.Fatalf("redirect collection response missing Location header")
|
||||
}
|
||||
if !strings.Contains(location, "proxied=true") {
|
||||
t.Fatalf("redirect collection Location should include proxied=true, got %q", location)
|
||||
}
|
||||
if !strings.Contains(location, "collection="+collection) {
|
||||
t.Fatalf("redirect collection Location should preserve collection query, got %q", location)
|
||||
}
|
||||
|
||||
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, location))
|
||||
followBody := framework.ReadAllAndClose(t, followResp)
|
||||
if followResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("redirect-follow expected 200, got %d", followResp.StatusCode)
|
||||
}
|
||||
if string(followBody) != string(payload) {
|
||||
t.Fatalf("redirect-follow body mismatch: got %q want %q", string(followBody), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForHTTPStatus(t testing.TB, client *http.Client, url string, expectedStatus int, timeout time.Duration, onMatch func(resp *http.Response)) bool {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, url))
|
||||
if resp.StatusCode == expectedStatus {
|
||||
onMatch(resp)
|
||||
return true
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestReadPathShapesAndIfModifiedSince(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(93)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fullFileID := framework.NewFileID(volumeID, 771234, 0xBEEFCACE)
|
||||
uploadPayload := []byte("read-path-shape-content")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fullFileID, uploadPayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(fullFileID, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("unexpected file id format: %q", fullFileID)
|
||||
}
|
||||
fidOnly := parts[1]
|
||||
|
||||
readByVidFid := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, fmt.Sprintf("%s/%d/%s", clusterHarness.VolumeAdminURL(), volumeID, fidOnly)))
|
||||
readByVidFidBody := framework.ReadAllAndClose(t, readByVidFid)
|
||||
if readByVidFid.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /{vid}/{fid} expected 200, got %d", readByVidFid.StatusCode)
|
||||
}
|
||||
if string(readByVidFidBody) != string(uploadPayload) {
|
||||
t.Fatalf("GET /{vid}/{fid} body mismatch: got %q want %q", string(readByVidFidBody), string(uploadPayload))
|
||||
}
|
||||
|
||||
readWithFilename := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, fmt.Sprintf("%s/%d/%s/%s", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "named.bin")))
|
||||
readWithFilenameBody := framework.ReadAllAndClose(t, readWithFilename)
|
||||
if readWithFilename.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /{vid}/{fid}/{filename} expected 200, got %d", readWithFilename.StatusCode)
|
||||
}
|
||||
if string(readWithFilenameBody) != string(uploadPayload) {
|
||||
t.Fatalf("GET /{vid}/{fid}/{filename} body mismatch: got %q want %q", string(readWithFilenameBody), string(uploadPayload))
|
||||
}
|
||||
|
||||
lastModified := readWithFilename.Header.Get("Last-Modified")
|
||||
if lastModified == "" {
|
||||
t.Fatalf("expected Last-Modified header on read response")
|
||||
}
|
||||
|
||||
ifModifiedSinceReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fullFileID)
|
||||
ifModifiedSinceReq.Header.Set("If-Modified-Since", lastModified)
|
||||
ifModifiedSinceResp := framework.DoRequest(t, client, ifModifiedSinceReq)
|
||||
_ = framework.ReadAllAndClose(t, ifModifiedSinceResp)
|
||||
if ifModifiedSinceResp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("If-Modified-Since expected 304, got %d", ifModifiedSinceResp.StatusCode)
|
||||
}
|
||||
|
||||
headIfModifiedSinceReq := mustNewRequest(t, http.MethodHead, clusterHarness.VolumeAdminURL()+"/"+fullFileID)
|
||||
headIfModifiedSinceReq.Header.Set("If-Modified-Since", lastModified)
|
||||
headIfModifiedSinceResp := framework.DoRequest(t, client, headIfModifiedSinceReq)
|
||||
headIfModifiedSinceBody := framework.ReadAllAndClose(t, headIfModifiedSinceResp)
|
||||
if headIfModifiedSinceResp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("HEAD If-Modified-Since expected 304, got %d", headIfModifiedSinceResp.StatusCode)
|
||||
}
|
||||
if len(headIfModifiedSinceBody) != 0 {
|
||||
t.Fatalf("HEAD If-Modified-Since expected empty body, got %d bytes", len(headIfModifiedSinceBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedVidFidPathReturnsBadRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/not-a-vid/not-a-fid"))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("malformed /{vid}/{fid} expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWrongCookieReturnsNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(95)
|
||||
const needleID = uint64(771235)
|
||||
const cookie = uint32(0xBEEFCACF)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, needleID, cookie)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("read-cookie-mismatch-content"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
wrongCookieFid := framework.NewFileID(volumeID, needleID, cookie+1)
|
||||
getResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), wrongCookieFid)
|
||||
_ = framework.ReadAllAndClose(t, getResp)
|
||||
if getResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET with wrong cookie expected 404, got %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, clusterHarness.VolumeAdminURL()+"/"+wrongCookieFid))
|
||||
headBody := framework.ReadAllAndClose(t, headResp)
|
||||
if headResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("HEAD with wrong cookie expected 404, got %d", headResp.StatusCode)
|
||||
}
|
||||
if len(headBody) != 0 {
|
||||
t.Fatalf("HEAD wrong-cookie response body should be empty, got %d bytes", len(headBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionalHeaderPrecedenceAndInvalidIfModifiedSince(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(99)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 772002, 0x2B3C4D5E)
|
||||
payload := []byte("conditional-precedence-content")
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
baselineResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, baselineResp)
|
||||
if baselineResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("baseline read expected 200, got %d", baselineResp.StatusCode)
|
||||
}
|
||||
lastModified := baselineResp.Header.Get("Last-Modified")
|
||||
if lastModified == "" {
|
||||
t.Fatalf("baseline read expected Last-Modified header")
|
||||
}
|
||||
|
||||
precedenceReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
precedenceReq.Header.Set("If-Modified-Since", lastModified)
|
||||
precedenceReq.Header.Set("If-None-Match", "\"definitely-different-etag\"")
|
||||
precedenceResp := framework.DoRequest(t, client, precedenceReq)
|
||||
precedenceBody := framework.ReadAllAndClose(t, precedenceResp)
|
||||
if precedenceResp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("conditional precedence expected 304, got %d", precedenceResp.StatusCode)
|
||||
}
|
||||
if len(precedenceBody) != 0 {
|
||||
t.Fatalf("conditional precedence expected empty body, got %d bytes", len(precedenceBody))
|
||||
}
|
||||
|
||||
invalidIMSReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
|
||||
invalidIMSReq.Header.Set("If-Modified-Since", "not-a-valid-http-date")
|
||||
invalidIMSReq.Header.Set("If-None-Match", "\"definitely-different-etag\"")
|
||||
invalidIMSResp := framework.DoRequest(t, client, invalidIMSReq)
|
||||
invalidIMSBody := framework.ReadAllAndClose(t, invalidIMSResp)
|
||||
if invalidIMSResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("invalid If-Modified-Since with mismatched etag expected 200, got %d", invalidIMSResp.StatusCode)
|
||||
}
|
||||
if string(invalidIMSBody) != string(payload) {
|
||||
t.Fatalf("invalid If-Modified-Since fallback body mismatch: got %q want %q", string(invalidIMSBody), string(payload))
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestUploadReadRangeHeadDeleteRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(7)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
fid := framework.NewFileID(volumeID, 123456, 0xA1B2C3D4)
|
||||
data := []byte("hello-volume-server-integration")
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
uploadResp := framework.UploadBytes(t, client, cluster.VolumeAdminURL(), fid, data)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
getResp := framework.ReadBytes(t, client, cluster.VolumeAdminURL(), fid)
|
||||
getBody := framework.ReadAllAndClose(t, getResp)
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status: expected 200, got %d", getResp.StatusCode)
|
||||
}
|
||||
if string(getBody) != string(data) {
|
||||
t.Fatalf("get body mismatch: got %q want %q", string(getBody), string(data))
|
||||
}
|
||||
etag := getResp.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatalf("expected ETag header from GET response")
|
||||
}
|
||||
|
||||
notModifiedReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
|
||||
notModifiedReq.Header.Set("If-None-Match", etag)
|
||||
notModifiedResp := framework.DoRequest(t, client, notModifiedReq)
|
||||
_ = framework.ReadAllAndClose(t, notModifiedResp)
|
||||
if notModifiedResp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("if-none-match expected 304, got %d", notModifiedResp.StatusCode)
|
||||
}
|
||||
|
||||
rangeReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
|
||||
rangeReq.Header.Set("Range", "bytes=0-4")
|
||||
rangeResp := framework.DoRequest(t, client, rangeReq)
|
||||
rangeBody := framework.ReadAllAndClose(t, rangeResp)
|
||||
if rangeResp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("range status: expected 206, got %d", rangeResp.StatusCode)
|
||||
}
|
||||
if got, want := string(rangeBody), "hello"; got != want {
|
||||
t.Fatalf("range body mismatch: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
invalidRangeReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
|
||||
invalidRangeReq.Header.Set("Range", "bytes=9999-10000")
|
||||
invalidRangeResp := framework.DoRequest(t, client, invalidRangeReq)
|
||||
_ = framework.ReadAllAndClose(t, invalidRangeResp)
|
||||
if invalidRangeResp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("invalid range expected 416, got %d", invalidRangeResp.StatusCode)
|
||||
}
|
||||
|
||||
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, cluster.VolumeAdminURL()+"/"+fid))
|
||||
headBody := framework.ReadAllAndClose(t, headResp)
|
||||
if headResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("head status: expected 200, got %d", headResp.StatusCode)
|
||||
}
|
||||
if got := headResp.Header.Get("Content-Length"); got != strconv.Itoa(len(data)) {
|
||||
t.Fatalf("head content-length mismatch: got %q want %d", got, len(data))
|
||||
}
|
||||
if len(headBody) != 0 {
|
||||
t.Fatalf("head body should be empty, got %d bytes", len(headBody))
|
||||
}
|
||||
|
||||
headNotModifiedReq := mustNewRequest(t, http.MethodHead, cluster.VolumeAdminURL()+"/"+fid)
|
||||
headNotModifiedReq.Header.Set("If-None-Match", etag)
|
||||
headNotModifiedResp := framework.DoRequest(t, client, headNotModifiedReq)
|
||||
headNotModifiedBody := framework.ReadAllAndClose(t, headNotModifiedResp)
|
||||
if headNotModifiedResp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("head if-none-match expected 304, got %d", headNotModifiedResp.StatusCode)
|
||||
}
|
||||
if len(headNotModifiedBody) != 0 {
|
||||
t.Fatalf("head if-none-match body should be empty, got %d bytes", len(headNotModifiedBody))
|
||||
}
|
||||
|
||||
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, cluster.VolumeAdminURL()+"/"+fid))
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete status: expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
notFoundResp := framework.ReadBytes(t, client, cluster.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, notFoundResp)
|
||||
if notFoundResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("read after delete: expected 404, got %d", notFoundResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidReadPathReturnsBadRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/invalid,needle"))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid read expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,730 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
)
|
||||
|
||||
type pausableReader struct {
|
||||
remaining int64
|
||||
pauseAfter int64
|
||||
paused bool
|
||||
unblock <-chan struct{}
|
||||
}
|
||||
|
||||
func (r *pausableReader) Read(p []byte) (int, error) {
|
||||
if r.remaining <= 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if !r.paused && r.pauseAfter > 0 {
|
||||
n := int64(len(p))
|
||||
if n > r.pauseAfter {
|
||||
n = r.pauseAfter
|
||||
}
|
||||
for i := int64(0); i < n; i++ {
|
||||
p[i] = 'a'
|
||||
}
|
||||
r.remaining -= n
|
||||
r.pauseAfter -= n
|
||||
if r.pauseAfter == 0 {
|
||||
r.paused = true
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
if r.paused {
|
||||
<-r.unblock
|
||||
r.paused = false
|
||||
}
|
||||
n := int64(len(p))
|
||||
if n > r.remaining {
|
||||
n = r.remaining
|
||||
}
|
||||
for i := int64(0); i < n; i++ {
|
||||
p[i] = 'b'
|
||||
}
|
||||
r.remaining -= n
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func TestUploadLimitTimeoutAndReplicateBypass(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(98)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
const blockedUploadSize = 2 * 1024 * 1024 // over 1MB P8 upload limit
|
||||
|
||||
unblockFirstUpload := make(chan struct{})
|
||||
firstUploadDone := make(chan error, 1)
|
||||
firstFID := framework.NewFileID(volumeID, 880001, 0x1A2B3C4D)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
|
||||
remaining: blockedUploadSize,
|
||||
pauseAfter: 1,
|
||||
unblock: unblockFirstUpload,
|
||||
})
|
||||
if err != nil {
|
||||
firstUploadDone <- err
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.ContentLength = blockedUploadSize
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if resp != nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
firstUploadDone <- err
|
||||
}()
|
||||
|
||||
// Give the first upload time to pass limit checks and block in body processing.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
replicateFID := framework.NewFileID(volumeID, 880002, 0x5E6F7A8B)
|
||||
replicateReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+replicateFID+"?type=replicate", bytes.NewReader([]byte("replicate")))
|
||||
if err != nil {
|
||||
t.Fatalf("create replicate request: %v", err)
|
||||
}
|
||||
replicateReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
replicateReq.ContentLength = int64(len("replicate"))
|
||||
replicateResp, err := framework.NewHTTPClient().Do(replicateReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replicate request failed: %v", err)
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, replicateResp)
|
||||
if replicateResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("replicate request expected 201 bypassing limit, got %d", replicateResp.StatusCode)
|
||||
}
|
||||
|
||||
normalFID := framework.NewFileID(volumeID, 880003, 0x9C0D1E2F)
|
||||
normalReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+normalFID, bytes.NewReader([]byte("normal")))
|
||||
if err != nil {
|
||||
t.Fatalf("create normal request: %v", err)
|
||||
}
|
||||
normalReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
normalReq.ContentLength = int64(len("normal"))
|
||||
|
||||
timeoutClient := &http.Client{Timeout: 10 * time.Second}
|
||||
normalResp, err := timeoutClient.Do(normalReq)
|
||||
if err != nil {
|
||||
t.Fatalf("normal upload request failed: %v", err)
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, normalResp)
|
||||
if normalResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("normal upload expected 429 while limit blocked, got %d", normalResp.StatusCode)
|
||||
}
|
||||
|
||||
close(unblockFirstUpload)
|
||||
select {
|
||||
case <-firstUploadDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for blocked upload to finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadLimitWaitThenProceed(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(111)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
const blockedUploadSize = 2 * 1024 * 1024
|
||||
|
||||
unblockFirstUpload := make(chan struct{})
|
||||
firstUploadDone := make(chan error, 1)
|
||||
firstFID := framework.NewFileID(volumeID, 880601, 0x6A2B3C4D)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
|
||||
remaining: blockedUploadSize,
|
||||
pauseAfter: 1,
|
||||
unblock: unblockFirstUpload,
|
||||
})
|
||||
if err != nil {
|
||||
firstUploadDone <- err
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.ContentLength = blockedUploadSize
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if resp != nil {
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}
|
||||
firstUploadDone <- err
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
type uploadResult struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
}
|
||||
secondUploadDone := make(chan uploadResult, 1)
|
||||
secondFID := framework.NewFileID(volumeID, 880602, 0x6A2B3C4E)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+secondFID, bytes.NewReader([]byte("wait-then-proceed")))
|
||||
if err != nil {
|
||||
secondUploadDone <- uploadResult{err: err}
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.ContentLength = int64(len("wait-then-proceed"))
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
secondUploadDone <- uploadResult{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
close(unblockFirstUpload)
|
||||
|
||||
select {
|
||||
case firstErr := <-firstUploadDone:
|
||||
if firstErr != nil {
|
||||
t.Fatalf("first blocked upload failed: %v", firstErr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for first upload completion")
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-secondUploadDone:
|
||||
if result.err != nil {
|
||||
t.Fatalf("second upload failed: %v", result.err)
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, result.resp)
|
||||
if result.resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("second upload expected 201 after waiting for slot, got %d", result.resp.StatusCode)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for second upload completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadLimitTimeoutThenRecovery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(113)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
const blockedUploadSize = 2 * 1024 * 1024
|
||||
|
||||
unblockFirstUpload := make(chan struct{})
|
||||
firstUploadDone := make(chan error, 1)
|
||||
firstFID := framework.NewFileID(volumeID, 880801, 0x7A2B3C4D)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
|
||||
remaining: blockedUploadSize,
|
||||
pauseAfter: 1,
|
||||
unblock: unblockFirstUpload,
|
||||
})
|
||||
if err != nil {
|
||||
firstUploadDone <- err
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.ContentLength = blockedUploadSize
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if resp != nil {
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}
|
||||
firstUploadDone <- err
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
timeoutFID := framework.NewFileID(volumeID, 880802, 0x7A2B3C4E)
|
||||
timeoutResp := framework.UploadBytes(t, &http.Client{Timeout: 10 * time.Second}, clusterHarness.VolumeAdminURL(), timeoutFID, []byte("should-timeout"))
|
||||
_ = framework.ReadAllAndClose(t, timeoutResp)
|
||||
if timeoutResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("second upload under blocked pressure expected 429, got %d", timeoutResp.StatusCode)
|
||||
}
|
||||
|
||||
close(unblockFirstUpload)
|
||||
select {
|
||||
case firstErr := <-firstUploadDone:
|
||||
if firstErr != nil {
|
||||
t.Fatalf("first blocked upload failed: %v", firstErr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for first upload completion")
|
||||
}
|
||||
|
||||
recoveryFID := framework.NewFileID(volumeID, 880803, 0x7A2B3C4F)
|
||||
recoveryResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), recoveryFID, []byte("recovered-upload"))
|
||||
_ = framework.ReadAllAndClose(t, recoveryResp)
|
||||
if recoveryResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("recovery upload expected 201, got %d", recoveryResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitTimeoutReturnsTooManyRequests(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(99)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024) // over 1MB P8 download limit
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
downloadFID := framework.NewFileID(volumeID, 880101, 0x10203040)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), downloadFID, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+downloadFID))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
// Keep first response body unread so server write path stays in-flight.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
secondClient := &http.Client{Timeout: 10 * time.Second}
|
||||
secondResp, err := secondClient.Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+downloadFID))
|
||||
if err != nil {
|
||||
t.Fatalf("second GET failed: %v", err)
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, secondResp)
|
||||
if secondResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("second GET expected 429 while first download holds limit, got %d", secondResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitWaitThenProceedWithoutReplica(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(112)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880701, 0x60708090)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
}
|
||||
secondReadDone := make(chan readResult, 1)
|
||||
go func() {
|
||||
resp, readErr := (&http.Client{Timeout: 10 * time.Second}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
secondReadDone <- readResult{resp: resp, err: readErr}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_ = firstResp.Body.Close()
|
||||
|
||||
select {
|
||||
case result := <-secondReadDone:
|
||||
if result.err != nil {
|
||||
t.Fatalf("second GET failed: %v", result.err)
|
||||
}
|
||||
secondBody := framework.ReadAllAndClose(t, result.resp)
|
||||
if result.resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second GET expected 200 after waiting for slot, got %d", result.resp.StatusCode)
|
||||
}
|
||||
if len(secondBody) != len(largePayload) {
|
||||
t.Fatalf("second GET body size mismatch: got %d want %d", len(secondBody), len(largePayload))
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for second GET completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitTimeoutThenRecovery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(114)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880901, 0x708090A0)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
timeoutResp := framework.ReadBytes(t, &http.Client{Timeout: 10 * time.Second}, clusterHarness.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, timeoutResp)
|
||||
if timeoutResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("second GET under blocked pressure expected 429, got %d", timeoutResp.StatusCode)
|
||||
}
|
||||
|
||||
_ = firstResp.Body.Close()
|
||||
|
||||
recoveryResp := framework.ReadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid)
|
||||
recoveryBody := framework.ReadAllAndClose(t, recoveryResp)
|
||||
if recoveryResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("recovery GET expected 200, got %d", recoveryResp.StatusCode)
|
||||
}
|
||||
if len(recoveryBody) != len(largePayload) {
|
||||
t.Fatalf("recovery GET body size mismatch: got %d want %d", len(recoveryBody), len(largePayload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitOverageProxiesToReplica(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P8()
|
||||
profile.ReadMode = "proxy"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
conn1, grpc1 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer conn1.Close()
|
||||
|
||||
const volumeID = uint32(100)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req := &volume_server_pb.AllocateVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Replication: "001",
|
||||
Version: uint32(needle.GetCurrentVersion()),
|
||||
}
|
||||
if _, err := grpc0.AllocateVolume(ctx, req); err != nil {
|
||||
t.Fatalf("allocate replicated volume on node0: %v", err)
|
||||
}
|
||||
if _, err := grpc1.AllocateVolume(ctx, req); err != nil {
|
||||
t.Fatalf("allocate replicated volume on node1: %v", err)
|
||||
}
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880201, 0x0A0B0C0D)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(0), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("replicated large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
replicaReadURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
|
||||
if !waitForHTTPStatus(t, framework.NewHTTPClient(), replicaReadURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("replica did not become readable within deadline")
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
secondResp, err := framework.NewHTTPClient().Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("second GET failed: %v", err)
|
||||
}
|
||||
secondBody := framework.ReadAllAndClose(t, secondResp)
|
||||
if secondResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second GET expected 200 via replica proxy fallback, got %d", secondResp.StatusCode)
|
||||
}
|
||||
if len(secondBody) != len(largePayload) {
|
||||
t.Fatalf("second GET proxied body size mismatch: got %d want %d", len(secondBody), len(largePayload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitProxiedRequestSkipsReplicaFallbackAndTimesOut(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := matrix.P8()
|
||||
profile.ReadMode = "proxy"
|
||||
clusterHarness := framework.StartDualVolumeCluster(t, profile)
|
||||
|
||||
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
|
||||
defer conn0.Close()
|
||||
conn1, grpc1 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
|
||||
defer conn1.Close()
|
||||
|
||||
const volumeID = uint32(106)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req := &volume_server_pb.AllocateVolumeRequest{
|
||||
VolumeId: volumeID,
|
||||
Replication: "001",
|
||||
Version: uint32(needle.GetCurrentVersion()),
|
||||
}
|
||||
if _, err := grpc0.AllocateVolume(ctx, req); err != nil {
|
||||
t.Fatalf("allocate replicated volume on node0: %v", err)
|
||||
}
|
||||
if _, err := grpc1.AllocateVolume(ctx, req); err != nil {
|
||||
t.Fatalf("allocate replicated volume on node1: %v", err)
|
||||
}
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880202, 0x0A0B0D0E)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(0), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("replicated large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
// Ensure replica path is actually available, so a non-proxied request would proxy.
|
||||
replicaReadURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
|
||||
if !waitForHTTPStatus(t, framework.NewHTTPClient(), replicaReadURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}) {
|
||||
t.Fatalf("replica did not become readable within deadline")
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// proxied=true should bypass replica fallback and hit wait/timeout branch.
|
||||
secondResp, err := framework.NewHTTPClient().Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid+"?proxied=true"))
|
||||
if err != nil {
|
||||
t.Fatalf("second GET failed: %v", err)
|
||||
}
|
||||
_ = framework.ReadAllAndClose(t, secondResp)
|
||||
if secondResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("second GET with proxied=true expected 429 timeout path, got %d", secondResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadLimitDisabledAllowsConcurrentUploads(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(107)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
const blockedUploadSize = 2 * 1024 * 1024
|
||||
unblockFirstUpload := make(chan struct{})
|
||||
firstUploadDone := make(chan error, 1)
|
||||
firstFID := framework.NewFileID(volumeID, 880301, 0x1A2B3C5D)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
|
||||
remaining: blockedUploadSize,
|
||||
pauseAfter: 1,
|
||||
unblock: unblockFirstUpload,
|
||||
})
|
||||
if err != nil {
|
||||
firstUploadDone <- err
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.ContentLength = blockedUploadSize
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if resp != nil {
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
}
|
||||
firstUploadDone <- err
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
secondFID := framework.NewFileID(volumeID, 880302, 0x1A2B3C5E)
|
||||
secondResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), secondFID, []byte("no-limit-second-upload"))
|
||||
_ = framework.ReadAllAndClose(t, secondResp)
|
||||
if secondResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("second upload with disabled limit expected 201, got %d", secondResp.StatusCode)
|
||||
}
|
||||
|
||||
close(unblockFirstUpload)
|
||||
select {
|
||||
case <-firstUploadDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for first upload completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitDisabledAllowsConcurrentDownloads(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(108)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880401, 0x20304050)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
secondResp := framework.ReadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid)
|
||||
secondBody := framework.ReadAllAndClose(t, secondResp)
|
||||
if secondResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second GET with disabled limit expected 200, got %d", secondResp.StatusCode)
|
||||
}
|
||||
if len(secondBody) != len(largePayload) {
|
||||
t.Fatalf("second GET body size mismatch: got %d want %d", len(secondBody), len(largePayload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLimitInvalidVidWhileOverLimitReturnsBadRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(110)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
largePayload := make([]byte, 12*1024*1024)
|
||||
for i := range largePayload {
|
||||
largePayload[i] = byte(i % 251)
|
||||
}
|
||||
fid := framework.NewFileID(volumeID, 880501, 0x50607080)
|
||||
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
|
||||
if err != nil {
|
||||
t.Fatalf("first GET failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
_ = framework.ReadAllAndClose(t, firstResp)
|
||||
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
invalidReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/not-a-vid,1234567890ab")
|
||||
invalidResp := framework.DoRequest(t, framework.NewHTTPClient(), invalidReq)
|
||||
_ = framework.ReadAllAndClose(t, invalidResp)
|
||||
if invalidResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid vid while over limit expected 400, got %d", invalidResp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestWriteUnchangedAndDeleteEdgeVariants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(87)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
const key = uint64(999001)
|
||||
const cookie = uint32(0xDEADBEEF)
|
||||
fid := framework.NewFileID(volumeID, key, cookie)
|
||||
client := framework.NewHTTPClient()
|
||||
payload := []byte("unchanged-write-content")
|
||||
|
||||
firstUpload := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
firstUploadResp := framework.DoRequest(t, client, firstUpload)
|
||||
_ = framework.ReadAllAndClose(t, firstUploadResp)
|
||||
if firstUploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload expected 201, got %d", firstUploadResp.StatusCode)
|
||||
}
|
||||
|
||||
secondUpload := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
|
||||
secondUploadResp := framework.DoRequest(t, client, secondUpload)
|
||||
_ = framework.ReadAllAndClose(t, secondUploadResp)
|
||||
if secondUploadResp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("second unchanged upload expected 204, got %d", secondUploadResp.StatusCode)
|
||||
}
|
||||
if secondUploadResp.Header.Get("ETag") == "" {
|
||||
t.Fatalf("second unchanged upload expected ETag header")
|
||||
}
|
||||
|
||||
wrongCookieFid := framework.NewFileID(volumeID, key, cookie+1)
|
||||
wrongCookieDelete := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+wrongCookieFid))
|
||||
_ = framework.ReadAllAndClose(t, wrongCookieDelete)
|
||||
if wrongCookieDelete.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("delete with mismatched cookie expected 400, got %d", wrongCookieDelete.StatusCode)
|
||||
}
|
||||
|
||||
missingDelete := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+framework.NewFileID(volumeID, key+1, cookie)))
|
||||
missingDeleteBody := framework.ReadAllAndClose(t, missingDelete)
|
||||
if missingDelete.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("delete missing needle expected 404, got %d", missingDelete.StatusCode)
|
||||
}
|
||||
|
||||
var payloadMap map[string]int64
|
||||
if err := json.Unmarshal(missingDeleteBody, &payloadMap); err != nil {
|
||||
t.Fatalf("decode delete missing response: %v", err)
|
||||
}
|
||||
if payloadMap["size"] != 0 {
|
||||
t.Fatalf("delete missing needle expected size=0, got %d", payloadMap["size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTimestampOverrideKeepsReadDeletedLastModifiedParity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(88)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 999002, 0xABCD1234)
|
||||
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("delete-ts-override"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
if uploadResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
beforeDeleteResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
|
||||
_ = framework.ReadAllAndClose(t, beforeDeleteResp)
|
||||
if beforeDeleteResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("pre-delete read expected 200, got %d", beforeDeleteResp.StatusCode)
|
||||
}
|
||||
lastModifiedBeforeDelete := beforeDeleteResp.Header.Get("Last-Modified")
|
||||
if lastModifiedBeforeDelete == "" {
|
||||
t.Fatalf("expected Last-Modified before delete")
|
||||
}
|
||||
|
||||
deleteReq := mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid+"?ts=1700000000")
|
||||
deleteResp := framework.DoRequest(t, client, deleteReq)
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
if deleteResp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("delete with ts override expected 202, got %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
readDeletedResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?readDeleted=true"))
|
||||
_ = framework.ReadAllAndClose(t, readDeletedResp)
|
||||
if readDeletedResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("readDeleted after ts override expected 200, got %d", readDeletedResp.StatusCode)
|
||||
}
|
||||
lastModified := readDeletedResp.Header.Get("Last-Modified")
|
||||
if lastModified == "" {
|
||||
t.Fatalf("expected Last-Modified header on readDeleted response")
|
||||
}
|
||||
if lastModified != lastModifiedBeforeDelete {
|
||||
t.Fatalf("expected readDeleted Last-Modified parity with pre-delete header, got %q want %q", lastModified, lastModifiedBeforeDelete)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package volume_server_http_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
)
|
||||
|
||||
func TestWriteInvalidVidAndFidReturnBadRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
client := framework.NewHTTPClient()
|
||||
|
||||
invalidVidReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/invalid,12345678", []byte("x"))
|
||||
invalidVidResp := framework.DoRequest(t, client, invalidVidReq)
|
||||
_ = framework.ReadAllAndClose(t, invalidVidResp)
|
||||
if invalidVidResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("write with invalid vid expected 400, got %d", invalidVidResp.StatusCode)
|
||||
}
|
||||
|
||||
invalidFidReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/1,bad", []byte("x"))
|
||||
invalidFidResp := framework.DoRequest(t, client, invalidFidReq)
|
||||
_ = framework.ReadAllAndClose(t, invalidFidResp)
|
||||
if invalidFidResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("write with invalid fid expected 400, got %d", invalidFidResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMalformedMultipartAndMD5Mismatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(98)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
client := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 772001, 0x1A2B3C4D)
|
||||
|
||||
malformedMultipartReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+fid, strings.NewReader("not-a-valid-multipart-body"))
|
||||
if err != nil {
|
||||
t.Fatalf("create malformed multipart request: %v", err)
|
||||
}
|
||||
malformedMultipartReq.Header.Set("Content-Type", "multipart/form-data")
|
||||
malformedMultipartResp := framework.DoRequest(t, client, malformedMultipartReq)
|
||||
malformedMultipartBody := framework.ReadAllAndClose(t, malformedMultipartResp)
|
||||
if malformedMultipartResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("malformed multipart write expected 400, got %d", malformedMultipartResp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(string(malformedMultipartBody)), "boundary") {
|
||||
t.Fatalf("malformed multipart response should mention boundary parse failure, got %q", string(malformedMultipartBody))
|
||||
}
|
||||
|
||||
md5MismatchReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, []byte("content-md5-mismatch-body"))
|
||||
md5MismatchReq.Header.Set("Content-MD5", "AAAAAAAAAAAAAAAAAAAAAA==")
|
||||
md5MismatchResp := framework.DoRequest(t, client, md5MismatchReq)
|
||||
md5MismatchBody := framework.ReadAllAndClose(t, md5MismatchResp)
|
||||
if md5MismatchResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("content-md5 mismatch write expected 400, got %d", md5MismatchResp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(md5MismatchBody), "Content-MD5") {
|
||||
t.Fatalf("content-md5 mismatch response should mention Content-MD5, got %q", string(md5MismatchBody))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package matrix
|
||||
|
||||
import "time"
|
||||
|
||||
// Profile describes one runtime test matrix configuration.
|
||||
type Profile struct {
|
||||
Name string
|
||||
|
||||
ReadMode string
|
||||
SplitPublicPort bool
|
||||
|
||||
EnableJWT bool
|
||||
JWTSigningKey string
|
||||
JWTReadKey string
|
||||
EnableMaintain bool
|
||||
|
||||
ConcurrentUploadLimitMB int
|
||||
ConcurrentDownloadLimitMB int
|
||||
InflightUploadTimeout time.Duration
|
||||
InflightDownloadTimeout time.Duration
|
||||
|
||||
ReplicatedLayout bool
|
||||
HasErasureCoding bool
|
||||
HasRemoteTier bool
|
||||
}
|
||||
|
||||
// P1 is the baseline profile: one volume server, no JWT, proxy read mode.
|
||||
func P1() Profile {
|
||||
return Profile{
|
||||
Name: "P1",
|
||||
ReadMode: "proxy",
|
||||
SplitPublicPort: false,
|
||||
}
|
||||
}
|
||||
|
||||
// P2 uses split public/admin ports to verify public read-only behavior.
|
||||
func P2() Profile {
|
||||
p := P1()
|
||||
p.Name = "P2"
|
||||
p.SplitPublicPort = true
|
||||
return p
|
||||
}
|
||||
|
||||
// P3 enables JWT verification for read/write flows.
|
||||
func P3() Profile {
|
||||
p := P1()
|
||||
p.Name = "P3"
|
||||
p.EnableJWT = true
|
||||
p.JWTSigningKey = "volume-server-write-key"
|
||||
p.JWTReadKey = "volume-server-read-key"
|
||||
return p
|
||||
}
|
||||
|
||||
// P8 enables upload/download throttling branches.
|
||||
func P8() Profile {
|
||||
p := P1()
|
||||
p.Name = "P8"
|
||||
p.ConcurrentUploadLimitMB = 1
|
||||
p.ConcurrentDownloadLimitMB = 1
|
||||
p.InflightUploadTimeout = 2 * time.Second
|
||||
p.InflightDownloadTimeout = 2 * time.Second
|
||||
return p
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build !windows && !openbsd && !netbsd && !plan9 && !solaris
|
||||
// +build !windows,!openbsd,!netbsd,!plan9,!solaris
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mrand "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/signal"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MasterAddresses []string
|
||||
FilerURL string
|
||||
PathPrefix string
|
||||
Collection string
|
||||
FileSizeBytes int64
|
||||
BatchSize int
|
||||
WriteInterval time.Duration
|
||||
CleanupInterval time.Duration
|
||||
EcMinAge time.Duration
|
||||
MaxCleanupPerCycle int
|
||||
RequestTimeout time.Duration
|
||||
MaxRuntime time.Duration
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
type runner struct {
|
||||
cfg config
|
||||
|
||||
httpClient *http.Client
|
||||
grpcDialOption grpc.DialOption
|
||||
|
||||
mu sync.Mutex
|
||||
sequence int64
|
||||
ecFirstSeenAt map[uint32]time.Time
|
||||
rng *mrand.Rand
|
||||
}
|
||||
|
||||
type ecVolumeInfo struct {
|
||||
Collection string
|
||||
NodeShards map[pb.ServerAddress][]uint32
|
||||
}
|
||||
|
||||
type ecCleanupCandidate struct {
|
||||
VolumeID uint32
|
||||
FirstSeenAt time.Time
|
||||
Info *ecVolumeInfo
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("invalid flags: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if cfg.MaxRuntime > 0 {
|
||||
runCtx, cancel := context.WithTimeout(ctx, cfg.MaxRuntime)
|
||||
defer cancel()
|
||||
ctx = runCtx
|
||||
}
|
||||
|
||||
r := &runner{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.RequestTimeout},
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
ecFirstSeenAt: make(map[uint32]time.Time),
|
||||
rng: mrand.New(mrand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"starting EC stress runner: masters=%s filer=%s prefix=%s collection=%s file_size=%d batch=%d write_interval=%s cleanup_interval=%s ec_min_age=%s max_cleanup=%d dry_run=%v",
|
||||
strings.Join(cfg.MasterAddresses, ","),
|
||||
cfg.FilerURL,
|
||||
cfg.PathPrefix,
|
||||
cfg.Collection,
|
||||
cfg.FileSizeBytes,
|
||||
cfg.BatchSize,
|
||||
cfg.WriteInterval,
|
||||
cfg.CleanupInterval,
|
||||
cfg.EcMinAge,
|
||||
cfg.MaxCleanupPerCycle,
|
||||
cfg.DryRun,
|
||||
)
|
||||
|
||||
if err := r.run(ctx); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Fatalf("runner stopped with error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("runner stopped")
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
var masters string
|
||||
cfg := config{}
|
||||
|
||||
flag.StringVar(&masters, "masters", "127.0.0.1:9333", "comma-separated master server addresses")
|
||||
flag.StringVar(&cfg.FilerURL, "filer", "http://127.0.0.1:8888", "filer base URL")
|
||||
flag.StringVar(&cfg.PathPrefix, "path_prefix", "/tmp/ec-stress", "filer path prefix for generated files")
|
||||
flag.StringVar(&cfg.Collection, "collection", "ec_stress", "target collection for stress data")
|
||||
|
||||
fileSizeMB := flag.Int("file_size_mb", 8, "size per generated file in MB")
|
||||
flag.IntVar(&cfg.BatchSize, "batch_size", 4, "files generated per write cycle")
|
||||
flag.DurationVar(&cfg.WriteInterval, "write_interval", 5*time.Second, "interval between write cycles")
|
||||
flag.DurationVar(&cfg.CleanupInterval, "cleanup_interval", 2*time.Minute, "interval between EC cleanup cycles")
|
||||
flag.DurationVar(&cfg.EcMinAge, "ec_min_age", 30*time.Minute, "minimum observed EC age before deletion")
|
||||
flag.IntVar(&cfg.MaxCleanupPerCycle, "max_cleanup_per_cycle", 4, "maximum EC volumes deleted per cleanup cycle")
|
||||
flag.DurationVar(&cfg.RequestTimeout, "request_timeout", 20*time.Second, "HTTP/gRPC request timeout")
|
||||
flag.DurationVar(&cfg.MaxRuntime, "max_runtime", 0, "maximum run duration; 0 means run until interrupted")
|
||||
flag.BoolVar(&cfg.DryRun, "dry_run", false, "log actions without deleting EC shards")
|
||||
flag.Parse()
|
||||
|
||||
cfg.MasterAddresses = splitNonEmpty(masters)
|
||||
cfg.FileSizeBytes = int64(*fileSizeMB) * 1024 * 1024
|
||||
|
||||
if len(cfg.MasterAddresses) == 0 {
|
||||
return cfg, fmt.Errorf("at least one master is required")
|
||||
}
|
||||
if cfg.FileSizeBytes <= 0 {
|
||||
return cfg, fmt.Errorf("file_size_mb must be positive")
|
||||
}
|
||||
if cfg.BatchSize <= 0 {
|
||||
return cfg, fmt.Errorf("batch_size must be positive")
|
||||
}
|
||||
if cfg.WriteInterval <= 0 {
|
||||
return cfg, fmt.Errorf("write_interval must be positive")
|
||||
}
|
||||
if cfg.CleanupInterval <= 0 {
|
||||
return cfg, fmt.Errorf("cleanup_interval must be positive")
|
||||
}
|
||||
if cfg.EcMinAge < 0 {
|
||||
return cfg, fmt.Errorf("ec_min_age must be zero or positive")
|
||||
}
|
||||
// Note: EcMinAge == 0 intentionally disables the age guard, making EC volumes eligible for cleanup immediately.
|
||||
if cfg.MaxCleanupPerCycle <= 0 {
|
||||
return cfg, fmt.Errorf("max_cleanup_per_cycle must be positive")
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
return cfg, fmt.Errorf("request_timeout must be positive")
|
||||
}
|
||||
|
||||
cfg.PathPrefix = ensureLeadingSlash(strings.TrimSpace(cfg.PathPrefix))
|
||||
cfg.Collection = strings.TrimSpace(cfg.Collection)
|
||||
|
||||
cfg.FilerURL = strings.TrimRight(strings.TrimSpace(cfg.FilerURL), "/")
|
||||
if cfg.FilerURL == "" {
|
||||
return cfg, fmt.Errorf("filer URL is required")
|
||||
}
|
||||
|
||||
if _, err := url.ParseRequestURI(cfg.FilerURL); err != nil {
|
||||
return cfg, fmt.Errorf("invalid filer URL %q: %w", cfg.FilerURL, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (r *runner) run(ctx context.Context) error {
|
||||
writeTicker := time.NewTicker(r.cfg.WriteInterval)
|
||||
defer writeTicker.Stop()
|
||||
|
||||
cleanupTicker := time.NewTicker(r.cfg.CleanupInterval)
|
||||
defer cleanupTicker.Stop()
|
||||
|
||||
r.runWriteCycle(ctx)
|
||||
r.runCleanupCycle(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-writeTicker.C:
|
||||
r.runWriteCycle(ctx)
|
||||
case <-cleanupTicker.C:
|
||||
r.runCleanupCycle(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) runWriteCycle(ctx context.Context) {
|
||||
for i := 0; i < r.cfg.BatchSize; i++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := r.uploadOneFile(ctx); err != nil {
|
||||
log.Printf("upload failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runner) uploadOneFile(ctx context.Context) error {
|
||||
sequence := r.nextSequence()
|
||||
filePath := path.Join(r.cfg.PathPrefix, fmt.Sprintf("ec-stress-%d-%d.bin", time.Now().UnixNano(), sequence))
|
||||
fileURL := r.cfg.FilerURL + filePath
|
||||
if r.cfg.Collection != "" {
|
||||
fileURL += "?collection=" + url.QueryEscape(r.cfg.Collection)
|
||||
}
|
||||
|
||||
uploadCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
body := io.LimitReader(r.rng, r.cfg.FileSizeBytes)
|
||||
request, err := http.NewRequestWithContext(uploadCtx, http.MethodPut, fileURL, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.ContentLength = r.cfg.FileSizeBytes
|
||||
request.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
response, err := r.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
io.Copy(io.Discard, response.Body)
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("upload %s returned %s", filePath, response.Status)
|
||||
}
|
||||
|
||||
log.Printf("uploaded %s size=%d", filePath, r.cfg.FileSizeBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runner) runCleanupCycle(ctx context.Context) {
|
||||
volumeList, err := r.fetchVolumeList(ctx)
|
||||
if err != nil {
|
||||
log.Printf("cleanup skipped: fetch volume list failed: %v", err)
|
||||
return
|
||||
}
|
||||
if volumeList == nil || volumeList.TopologyInfo == nil {
|
||||
log.Printf("cleanup skipped: topology is empty")
|
||||
return
|
||||
}
|
||||
|
||||
ecVolumes := collectEcVolumes(volumeList.TopologyInfo, r.cfg.Collection)
|
||||
candidates := r.selectCleanupCandidates(ecVolumes)
|
||||
if len(candidates) == 0 {
|
||||
log.Printf("cleanup: no EC volume candidate aged >= %s in collection=%q", r.cfg.EcMinAge, r.cfg.Collection)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("cleanup: deleting up to %d EC volumes (found=%d)", r.cfg.MaxCleanupPerCycle, len(candidates))
|
||||
deleted := 0
|
||||
for _, candidate := range candidates {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if r.cfg.DryRun {
|
||||
log.Printf(
|
||||
"cleanup dry-run: would delete EC volume=%d collection=%q seen_for=%s nodes=%d",
|
||||
candidate.VolumeID,
|
||||
candidate.Info.Collection,
|
||||
time.Since(candidate.FirstSeenAt).Round(time.Second),
|
||||
len(candidate.Info.NodeShards),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.deleteEcVolume(ctx, candidate.VolumeID, candidate.Info); err != nil {
|
||||
log.Printf("cleanup volume=%d failed: %v", candidate.VolumeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
deleted++
|
||||
r.mu.Lock()
|
||||
delete(r.ecFirstSeenAt, candidate.VolumeID)
|
||||
r.mu.Unlock()
|
||||
log.Printf("cleanup volume=%d completed", candidate.VolumeID)
|
||||
}
|
||||
|
||||
log.Printf("cleanup finished: deleted=%d attempted=%d", deleted, len(candidates))
|
||||
}
|
||||
|
||||
func (r *runner) fetchVolumeList(ctx context.Context) (*master_pb.VolumeListResponse, error) {
|
||||
var lastErr error
|
||||
for _, master := range r.cfg.MasterAddresses {
|
||||
masterAddress := strings.TrimSpace(master)
|
||||
if masterAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var response *master_pb.VolumeListResponse
|
||||
err := pb.WithMasterClient(false, pb.ServerAddress(masterAddress), r.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
|
||||
callCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, callErr := client.VolumeList(callCtx, &master_pb.VolumeListRequest{})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
response = resp
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no valid master address")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func collectEcVolumes(topology *master_pb.TopologyInfo, collection string) map[uint32]*ecVolumeInfo {
|
||||
normalizedCollection := strings.TrimSpace(collection)
|
||||
volumeShardSets := make(map[uint32]map[pb.ServerAddress]map[uint32]struct{})
|
||||
volumeCollection := make(map[uint32]string)
|
||||
|
||||
for _, dc := range topology.GetDataCenterInfos() {
|
||||
for _, rack := range dc.GetRackInfos() {
|
||||
for _, node := range rack.GetDataNodeInfos() {
|
||||
server := pb.NewServerAddressFromDataNode(node)
|
||||
for _, disk := range node.GetDiskInfos() {
|
||||
for _, shardInfo := range disk.GetEcShardInfos() {
|
||||
if shardInfo == nil || shardInfo.Id == 0 {
|
||||
continue
|
||||
}
|
||||
if normalizedCollection != "" && strings.TrimSpace(shardInfo.Collection) != normalizedCollection {
|
||||
continue
|
||||
}
|
||||
|
||||
shards := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(shardInfo).IdsUint32()
|
||||
if len(shards) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
perVolume := volumeShardSets[shardInfo.Id]
|
||||
if perVolume == nil {
|
||||
perVolume = make(map[pb.ServerAddress]map[uint32]struct{})
|
||||
volumeShardSets[shardInfo.Id] = perVolume
|
||||
}
|
||||
perNode := perVolume[server]
|
||||
if perNode == nil {
|
||||
perNode = make(map[uint32]struct{})
|
||||
perVolume[server] = perNode
|
||||
}
|
||||
for _, shardID := range shards {
|
||||
perNode[shardID] = struct{}{}
|
||||
}
|
||||
volumeCollection[shardInfo.Id] = shardInfo.Collection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[uint32]*ecVolumeInfo, len(volumeShardSets))
|
||||
for volumeID, perNode := range volumeShardSets {
|
||||
info := &ecVolumeInfo{
|
||||
Collection: volumeCollection[volumeID],
|
||||
NodeShards: make(map[pb.ServerAddress][]uint32, len(perNode)),
|
||||
}
|
||||
for server, shardSet := range perNode {
|
||||
shardIDs := make([]uint32, 0, len(shardSet))
|
||||
for shardID := range shardSet {
|
||||
shardIDs = append(shardIDs, shardID)
|
||||
}
|
||||
sort.Slice(shardIDs, func(i, j int) bool { return shardIDs[i] < shardIDs[j] })
|
||||
info.NodeShards[server] = shardIDs
|
||||
}
|
||||
result[volumeID] = info
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *runner) selectCleanupCandidates(ecVolumes map[uint32]*ecVolumeInfo) []ecCleanupCandidate {
|
||||
now := time.Now()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for volumeID := range ecVolumes {
|
||||
if _, exists := r.ecFirstSeenAt[volumeID]; !exists {
|
||||
r.ecFirstSeenAt[volumeID] = now
|
||||
}
|
||||
}
|
||||
for volumeID := range r.ecFirstSeenAt {
|
||||
if _, exists := ecVolumes[volumeID]; !exists {
|
||||
delete(r.ecFirstSeenAt, volumeID)
|
||||
}
|
||||
}
|
||||
|
||||
candidates := make([]ecCleanupCandidate, 0, len(ecVolumes))
|
||||
for volumeID, info := range ecVolumes {
|
||||
firstSeenAt := r.ecFirstSeenAt[volumeID]
|
||||
if r.cfg.EcMinAge > 0 && now.Sub(firstSeenAt) < r.cfg.EcMinAge {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, ecCleanupCandidate{
|
||||
VolumeID: volumeID,
|
||||
FirstSeenAt: firstSeenAt,
|
||||
Info: info,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].FirstSeenAt.Equal(candidates[j].FirstSeenAt) {
|
||||
return candidates[i].VolumeID < candidates[j].VolumeID
|
||||
}
|
||||
return candidates[i].FirstSeenAt.Before(candidates[j].FirstSeenAt)
|
||||
})
|
||||
|
||||
if len(candidates) > r.cfg.MaxCleanupPerCycle {
|
||||
candidates = candidates[:r.cfg.MaxCleanupPerCycle]
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func (r *runner) deleteEcVolume(ctx context.Context, volumeID uint32, info *ecVolumeInfo) error {
|
||||
if info == nil {
|
||||
return fmt.Errorf("ec volume %d has no topology info", volumeID)
|
||||
}
|
||||
|
||||
failureCount := 0
|
||||
for server, shardIDs := range info.NodeShards {
|
||||
err := pb.WithVolumeServerClient(false, server, r.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
|
||||
unmountCtx, unmountCancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
|
||||
defer unmountCancel()
|
||||
if _, err := client.VolumeEcShardsUnmount(unmountCtx, &volume_server_pb.VolumeEcShardsUnmountRequest{
|
||||
VolumeId: volumeID,
|
||||
ShardIds: shardIDs,
|
||||
}); err != nil {
|
||||
log.Printf("volume %d ec shards unmount on %s failed: %v", volumeID, server, err)
|
||||
}
|
||||
|
||||
if len(shardIDs) > 0 {
|
||||
deleteCtx, deleteCancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
|
||||
defer deleteCancel()
|
||||
if _, err := client.VolumeEcShardsDelete(deleteCtx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
Collection: r.cfg.Collection,
|
||||
ShardIds: shardIDs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
finalDeleteCtx, finalDeleteCancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
|
||||
defer finalDeleteCancel()
|
||||
if _, err := client.VolumeDelete(finalDeleteCtx, &volume_server_pb.VolumeDeleteRequest{
|
||||
VolumeId: volumeID,
|
||||
}); err != nil {
|
||||
log.Printf("volume %d delete on %s failed: %v", volumeID, server, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
failureCount++
|
||||
log.Printf("cleanup volume=%d server=%s shards=%v failed: %v", volumeID, server, shardIDs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if failureCount == len(info.NodeShards) && failureCount > 0 {
|
||||
return fmt.Errorf("all shard deletions failed for volume %d", volumeID)
|
||||
}
|
||||
if failureCount > 0 {
|
||||
return fmt.Errorf("partial shard deletion failure for volume %d", volumeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runner) nextSequence() int64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.sequence++
|
||||
return r.sequence
|
||||
}
|
||||
|
||||
func splitNonEmpty(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ensureLeadingSlash(value string) string {
|
||||
if value == "" {
|
||||
return "/"
|
||||
}
|
||||
if strings.HasPrefix(value, "/") {
|
||||
return value
|
||||
}
|
||||
return "/" + value
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ install-templ:
|
||||
.PHONY: generate
|
||||
generate: install-templ
|
||||
@echo "Generating templ files..."
|
||||
@templ generate ./view
|
||||
@cd $(BUILD_DIR) && templ generate
|
||||
@echo "Generated: $(TEMPL_GO_FILES)"
|
||||
|
||||
# Clean generated files
|
||||
|
||||
@@ -9,15 +9,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// Access key status constants
|
||||
const (
|
||||
AccessKeyStatusActive = iam.AccessKeyStatusActive
|
||||
AccessKeyStatusInactive = iam.AccessKeyStatusInactive
|
||||
AccessKeyStatusActive = "Active"
|
||||
AccessKeyStatusInactive = "Inactive"
|
||||
)
|
||||
|
||||
type AdminData struct {
|
||||
@@ -234,7 +232,7 @@ func (s *AdminServer) getMasterNodesStatus() []MasterNode {
|
||||
currentMaster := s.masterClient.GetMaster(context.Background())
|
||||
if currentMaster != "" {
|
||||
masterNodes = append(masterNodes, MasterNode{
|
||||
Address: pb.ServerAddress(currentMaster).ToHttpAddress(),
|
||||
Address: string(currentMaster),
|
||||
IsLeader: isLeader,
|
||||
})
|
||||
}
|
||||
|
||||
+808
-198
File diff suppressed because it is too large
Load Diff
@@ -1,735 +0,0 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPluginDetectionTimeout = 45 * time.Second
|
||||
defaultPluginExecutionTimeout = 90 * time.Second
|
||||
maxPluginDetectionTimeout = 5 * time.Minute
|
||||
maxPluginExecutionTimeout = 10 * time.Minute
|
||||
defaultPluginRunTimeout = 5 * time.Minute
|
||||
maxPluginRunTimeout = 30 * time.Minute
|
||||
)
|
||||
|
||||
// GetPluginStatusAPI returns plugin status.
|
||||
func (s *AdminServer) GetPluginStatusAPI(c *gin.Context) {
|
||||
plugin := s.GetPlugin()
|
||||
if plugin == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": false,
|
||||
"worker_grpc_port": s.GetWorkerGrpcPort(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": true,
|
||||
"configured": plugin.IsConfigured(),
|
||||
"base_dir": plugin.BaseDir(),
|
||||
"worker_count": len(plugin.ListWorkers()),
|
||||
"worker_grpc_port": s.GetWorkerGrpcPort(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPluginWorkersAPI returns currently connected plugin workers.
|
||||
func (s *AdminServer) GetPluginWorkersAPI(c *gin.Context) {
|
||||
workers := s.GetPluginWorkers()
|
||||
if workers == nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, workers)
|
||||
}
|
||||
|
||||
// GetPluginJobTypesAPI returns known plugin job types from workers and persisted data.
|
||||
func (s *AdminServer) GetPluginJobTypesAPI(c *gin.Context) {
|
||||
jobTypes, err := s.ListPluginJobTypes()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if jobTypes == nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jobTypes)
|
||||
}
|
||||
|
||||
// GetPluginJobsAPI returns tracked jobs for monitoring.
|
||||
func (s *AdminServer) GetPluginJobsAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Query("job_type"))
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
limit := parsePositiveInt(c.Query("limit"), 200)
|
||||
jobs := s.ListPluginJobs(jobType, state, limit)
|
||||
if jobs == nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jobs)
|
||||
}
|
||||
|
||||
// GetPluginJobAPI returns one tracked job.
|
||||
func (s *AdminServer) GetPluginJobAPI(c *gin.Context) {
|
||||
jobID := strings.TrimSpace(c.Param("jobId"))
|
||||
if jobID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"})
|
||||
return
|
||||
}
|
||||
|
||||
job, found := s.GetPluginJob(jobID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, job)
|
||||
}
|
||||
|
||||
// GetPluginJobDetailAPI returns detailed information for one tracked plugin job.
|
||||
func (s *AdminServer) GetPluginJobDetailAPI(c *gin.Context) {
|
||||
jobID := strings.TrimSpace(c.Param("jobId"))
|
||||
if jobID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"})
|
||||
return
|
||||
}
|
||||
|
||||
activityLimit := parsePositiveInt(c.Query("activity_limit"), 500)
|
||||
relatedLimit := parsePositiveInt(c.Query("related_limit"), 20)
|
||||
|
||||
detail, found, err := s.GetPluginJobDetail(jobID, activityLimit, relatedLimit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !found || detail == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "job detail not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// GetPluginActivitiesAPI returns recent plugin activities.
|
||||
func (s *AdminServer) GetPluginActivitiesAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Query("job_type"))
|
||||
limit := parsePositiveInt(c.Query("limit"), 500)
|
||||
activities := s.ListPluginActivities(jobType, limit)
|
||||
if activities == nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, activities)
|
||||
}
|
||||
|
||||
// GetPluginSchedulerStatesAPI returns per-job-type scheduler status for monitoring.
|
||||
func (s *AdminServer) GetPluginSchedulerStatesAPI(c *gin.Context) {
|
||||
jobTypeFilter := strings.TrimSpace(c.Query("job_type"))
|
||||
|
||||
states, err := s.ListPluginSchedulerStates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if jobTypeFilter != "" {
|
||||
filtered := make([]interface{}, 0, len(states))
|
||||
for _, state := range states {
|
||||
if state.JobType == jobTypeFilter {
|
||||
filtered = append(filtered, state)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
return
|
||||
}
|
||||
|
||||
if states == nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, states)
|
||||
}
|
||||
|
||||
// RequestPluginJobTypeSchemaAPI asks a worker for one job type schema.
|
||||
func (s *AdminServer) RequestPluginJobTypeSchemaAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
forceRefresh := c.DefaultQuery("force_refresh", "false") == "true"
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), defaultPluginDetectionTimeout)
|
||||
defer cancel()
|
||||
descriptor, err := s.RequestPluginJobTypeDescriptor(ctx, jobType, forceRefresh)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
renderProtoJSON(c, http.StatusOK, descriptor)
|
||||
}
|
||||
|
||||
// GetPluginJobTypeDescriptorAPI returns persisted descriptor for a job type.
|
||||
func (s *AdminServer) GetPluginJobTypeDescriptorAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
descriptor, err := s.LoadPluginJobTypeDescriptor(jobType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if descriptor == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "descriptor not found"})
|
||||
return
|
||||
}
|
||||
|
||||
renderProtoJSON(c, http.StatusOK, descriptor)
|
||||
}
|
||||
|
||||
// GetPluginJobTypeConfigAPI loads persisted config for a job type.
|
||||
func (s *AdminServer) GetPluginJobTypeConfigAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := s.LoadPluginJobTypeConfig(jobType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &plugin_pb.PersistedJobTypeConfig{
|
||||
JobType: jobType,
|
||||
AdminConfigValues: map[string]*plugin_pb.ConfigValue{},
|
||||
WorkerConfigValues: map[string]*plugin_pb.ConfigValue{},
|
||||
AdminRuntime: &plugin_pb.AdminRuntimeConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
renderProtoJSON(c, http.StatusOK, config)
|
||||
}
|
||||
|
||||
// UpdatePluginJobTypeConfigAPI stores persisted config for a job type.
|
||||
func (s *AdminServer) UpdatePluginJobTypeConfigAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
config := &plugin_pb.PersistedJobTypeConfig{}
|
||||
if err := parseProtoJSONBody(c, config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config.JobType = jobType
|
||||
if config.UpdatedAt == nil {
|
||||
config.UpdatedAt = timestamppb.Now()
|
||||
}
|
||||
if config.AdminRuntime == nil {
|
||||
config.AdminRuntime = &plugin_pb.AdminRuntimeConfig{}
|
||||
}
|
||||
if config.AdminConfigValues == nil {
|
||||
config.AdminConfigValues = map[string]*plugin_pb.ConfigValue{}
|
||||
}
|
||||
if config.WorkerConfigValues == nil {
|
||||
config.WorkerConfigValues = map[string]*plugin_pb.ConfigValue{}
|
||||
}
|
||||
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
config.UpdatedBy = username
|
||||
|
||||
if err := s.SavePluginJobTypeConfig(config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
renderProtoJSON(c, http.StatusOK, config)
|
||||
}
|
||||
|
||||
// GetPluginRunHistoryAPI returns bounded run history for a job type.
|
||||
func (s *AdminServer) GetPluginRunHistoryAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := s.GetPluginRunHistory(jobType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if history == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"job_type": jobType,
|
||||
"successful_runs": []interface{}{},
|
||||
"error_runs": []interface{}{},
|
||||
"last_updated_time": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
// TriggerPluginDetectionAPI runs one detector for this job type and returns proposals.
|
||||
func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ClusterContext json.RawMessage `json:"cluster_context"`
|
||||
MaxResults int32 `json:"max_results"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginDetectionTimeout, maxPluginDetectionTimeout)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
report, err := s.RunPluginDetectionWithReport(ctx, jobType, clusterContext, req.MaxResults)
|
||||
proposals := make([]*plugin_pb.JobProposal, 0)
|
||||
requestID := ""
|
||||
detectorWorkerID := ""
|
||||
totalProposals := int32(0)
|
||||
if report != nil {
|
||||
proposals = report.Proposals
|
||||
requestID = report.RequestID
|
||||
detectorWorkerID = report.WorkerID
|
||||
if report.Complete != nil {
|
||||
totalProposals = report.Complete.TotalProposals
|
||||
}
|
||||
}
|
||||
|
||||
proposalPayloads := make([]map[string]interface{}, 0, len(proposals))
|
||||
for _, proposal := range proposals {
|
||||
payload, marshalErr := protoMessageToMap(proposal)
|
||||
if marshalErr != nil {
|
||||
glog.Warningf("failed to marshal proposal for jobType=%s: %v", jobType, marshalErr)
|
||||
continue
|
||||
}
|
||||
proposalPayloads = append(proposalPayloads, payload)
|
||||
}
|
||||
|
||||
sort.Slice(proposalPayloads, func(i, j int) bool {
|
||||
iPriorityStr, _ := proposalPayloads[i]["priority"].(string)
|
||||
jPriorityStr, _ := proposalPayloads[j]["priority"].(string)
|
||||
|
||||
iPriority := plugin_pb.JobPriority_value[iPriorityStr]
|
||||
jPriority := plugin_pb.JobPriority_value[jPriorityStr]
|
||||
|
||||
if iPriority != jPriority {
|
||||
return iPriority > jPriority
|
||||
}
|
||||
iID, _ := proposalPayloads[i]["proposal_id"].(string)
|
||||
jID, _ := proposalPayloads[j]["proposal_id"].(string)
|
||||
return iID < jID
|
||||
})
|
||||
|
||||
activities := s.ListPluginActivities(jobType, 500)
|
||||
filteredActivities := make([]interface{}, 0, len(activities))
|
||||
if requestID != "" {
|
||||
for i := len(activities) - 1; i >= 0; i-- {
|
||||
activity := activities[i]
|
||||
if activity.RequestID != requestID {
|
||||
continue
|
||||
}
|
||||
filteredActivities = append(filteredActivities, activity)
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"job_type": jobType,
|
||||
"request_id": requestID,
|
||||
"detector_worker_id": detectorWorkerID,
|
||||
"total_proposals": totalProposals,
|
||||
"count": len(proposalPayloads),
|
||||
"proposals": proposalPayloads,
|
||||
"activities": filteredActivities,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response["error"] = err.Error()
|
||||
c.JSON(http.StatusInternalServerError, response)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// RunPluginJobTypeAPI runs full workflow for one job type: detect then dispatch detected jobs.
|
||||
func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
|
||||
jobType := strings.TrimSpace(c.Param("jobType"))
|
||||
if jobType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ClusterContext json.RawMessage `json:"cluster_context"`
|
||||
MaxResults int32 `json:"max_results"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Attempt < 1 {
|
||||
req.Attempt = 1
|
||||
}
|
||||
|
||||
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginRunTimeout, maxPluginRunTimeout)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
proposals, err := s.RunPluginDetection(ctx, jobType, clusterContext, req.MaxResults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
detectedCount := len(proposals)
|
||||
|
||||
filteredProposals, skippedActiveCount, err := s.FilterPluginProposalsWithActiveJobs(jobType, proposals)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type executionResult struct {
|
||||
JobID string `json:"job_id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Completion map[string]interface{} `json:"completion,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]executionResult, 0, len(filteredProposals))
|
||||
successCount := 0
|
||||
errorCount := 0
|
||||
|
||||
for index, proposal := range filteredProposals {
|
||||
job := buildJobSpecFromProposal(jobType, proposal, index)
|
||||
completed, execErr := s.ExecutePluginJob(ctx, job, clusterContext, req.Attempt)
|
||||
|
||||
result := executionResult{
|
||||
JobID: job.JobId,
|
||||
Success: execErr == nil,
|
||||
}
|
||||
|
||||
if completed != nil {
|
||||
if payload, marshalErr := protoMessageToMap(completed); marshalErr == nil {
|
||||
result.Completion = payload
|
||||
}
|
||||
}
|
||||
|
||||
if execErr != nil {
|
||||
result.Error = execErr.Error()
|
||||
errorCount++
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"job_type": jobType,
|
||||
"detected_count": detectedCount,
|
||||
"ready_to_execute_count": len(filteredProposals),
|
||||
"skipped_active_count": skippedActiveCount,
|
||||
"executed_count": len(results),
|
||||
"success_count": successCount,
|
||||
"error_count": errorCount,
|
||||
"execution_results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ExecutePluginJobAPI executes one job on a capable worker and waits for completion.
|
||||
func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) {
|
||||
var req struct {
|
||||
Job json.RawMessage `json:"job"`
|
||||
ClusterContext json.RawMessage `json:"cluster_context"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Job) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "job is required"})
|
||||
return
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{}
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(req.Job, job); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Attempt < 1 {
|
||||
req.Attempt = 1
|
||||
}
|
||||
|
||||
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginExecutionTimeout, maxPluginExecutionTimeout)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
completed, err := s.ExecutePluginJob(ctx, job, clusterContext, req.Attempt)
|
||||
if err != nil {
|
||||
if completed != nil {
|
||||
payload, marshalErr := protoMessageToMap(completed)
|
||||
if marshalErr == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "completion": payload})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
renderProtoJSON(c, http.StatusOK, completed)
|
||||
}
|
||||
|
||||
func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_pb.ClusterContext, error) {
|
||||
if len(raw) == 0 {
|
||||
return s.buildDefaultPluginClusterContext(), nil
|
||||
}
|
||||
|
||||
contextMessage := &plugin_pb.ClusterContext{}
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, contextMessage); err != nil {
|
||||
return nil, fmt.Errorf("invalid cluster_context payload: %w", err)
|
||||
}
|
||||
|
||||
fallback := s.buildDefaultPluginClusterContext()
|
||||
if len(contextMessage.MasterGrpcAddresses) == 0 {
|
||||
contextMessage.MasterGrpcAddresses = append(contextMessage.MasterGrpcAddresses, fallback.MasterGrpcAddresses...)
|
||||
}
|
||||
if len(contextMessage.FilerGrpcAddresses) == 0 {
|
||||
contextMessage.FilerGrpcAddresses = append(contextMessage.FilerGrpcAddresses, fallback.FilerGrpcAddresses...)
|
||||
}
|
||||
if len(contextMessage.VolumeGrpcAddresses) == 0 {
|
||||
contextMessage.VolumeGrpcAddresses = append(contextMessage.VolumeGrpcAddresses, fallback.VolumeGrpcAddresses...)
|
||||
}
|
||||
if contextMessage.Metadata == nil {
|
||||
contextMessage.Metadata = map[string]string{}
|
||||
}
|
||||
contextMessage.Metadata["source"] = "admin"
|
||||
|
||||
return contextMessage, nil
|
||||
}
|
||||
|
||||
func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterContext {
|
||||
clusterContext := &plugin_pb.ClusterContext{
|
||||
MasterGrpcAddresses: make([]string, 0),
|
||||
FilerGrpcAddresses: make([]string, 0),
|
||||
VolumeGrpcAddresses: make([]string, 0),
|
||||
Metadata: map[string]string{
|
||||
"source": "admin",
|
||||
},
|
||||
}
|
||||
|
||||
masterAddress := string(s.masterClient.GetMaster(context.Background()))
|
||||
if masterAddress != "" {
|
||||
clusterContext.MasterGrpcAddresses = append(clusterContext.MasterGrpcAddresses, masterAddress)
|
||||
}
|
||||
|
||||
filerSeen := map[string]struct{}{}
|
||||
for _, filer := range s.GetAllFilers() {
|
||||
filer = strings.TrimSpace(filer)
|
||||
if filer == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := filerSeen[filer]; exists {
|
||||
continue
|
||||
}
|
||||
filerSeen[filer] = struct{}{}
|
||||
clusterContext.FilerGrpcAddresses = append(clusterContext.FilerGrpcAddresses, filer)
|
||||
}
|
||||
|
||||
volumeSeen := map[string]struct{}{}
|
||||
if volumeServers, err := s.GetClusterVolumeServers(); err == nil {
|
||||
for _, server := range volumeServers.VolumeServers {
|
||||
address := strings.TrimSpace(server.GetDisplayAddress())
|
||||
if address == "" {
|
||||
address = strings.TrimSpace(server.Address)
|
||||
}
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := volumeSeen[address]; exists {
|
||||
continue
|
||||
}
|
||||
volumeSeen[address] = struct{}{}
|
||||
clusterContext.VolumeGrpcAddresses = append(clusterContext.VolumeGrpcAddresses, address)
|
||||
}
|
||||
} else {
|
||||
glog.V(1).Infof("failed to build default plugin volume context: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(clusterContext.MasterGrpcAddresses)
|
||||
sort.Strings(clusterContext.FilerGrpcAddresses)
|
||||
sort.Strings(clusterContext.VolumeGrpcAddresses)
|
||||
|
||||
return clusterContext
|
||||
}
|
||||
|
||||
const parseProtoJSONBodyMaxBytes = 1 << 20 // 1 MB
|
||||
|
||||
func parseProtoJSONBody(c *gin.Context, message proto.Message) error {
|
||||
limitedBody := http.MaxBytesReader(c.Writer, c.Request.Body, parseProtoJSONBodyMaxBytes)
|
||||
data, err := io.ReadAll(limitedBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("request body is empty")
|
||||
}
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(data, message); err != nil {
|
||||
return fmt.Errorf("invalid protobuf json: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderProtoJSON(c *gin.Context, statusCode int, message proto.Message) {
|
||||
payload, err := protojson.MarshalOptions{
|
||||
UseProtoNames: true,
|
||||
EmitUnpopulated: true,
|
||||
}.Marshal(message)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(statusCode, "application/json", payload)
|
||||
}
|
||||
|
||||
func protoMessageToMap(message proto.Message) (map[string]interface{}, error) {
|
||||
payload, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeTimeout(timeoutSeconds int, defaultTimeout, maxTimeout time.Duration) time.Duration {
|
||||
if timeoutSeconds <= 0 {
|
||||
return defaultTimeout
|
||||
}
|
||||
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||
if timeout > maxTimeout {
|
||||
return maxTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func buildJobSpecFromProposal(jobType string, proposal *plugin_pb.JobProposal, index int) *plugin_pb.JobSpec {
|
||||
now := timestamppb.Now()
|
||||
suffix := make([]byte, 4)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
// Fallback to simpler ID if rand fails
|
||||
suffix = []byte(fmt.Sprintf("%d", index))
|
||||
}
|
||||
jobID := fmt.Sprintf("%s-%d-%s", jobType, now.AsTime().UnixNano(), hex.EncodeToString(suffix))
|
||||
|
||||
jobSpec := &plugin_pb.JobSpec{
|
||||
JobId: jobID,
|
||||
JobType: jobType,
|
||||
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
|
||||
CreatedAt: now,
|
||||
Labels: make(map[string]string),
|
||||
Parameters: make(map[string]*plugin_pb.ConfigValue),
|
||||
DedupeKey: "",
|
||||
}
|
||||
|
||||
if proposal != nil {
|
||||
jobSpec.Summary = proposal.Summary
|
||||
jobSpec.Detail = proposal.Detail
|
||||
if proposal.Priority != plugin_pb.JobPriority_JOB_PRIORITY_UNSPECIFIED {
|
||||
jobSpec.Priority = proposal.Priority
|
||||
}
|
||||
jobSpec.DedupeKey = proposal.DedupeKey
|
||||
jobSpec.Parameters = plugin.CloneConfigValueMap(proposal.Parameters)
|
||||
if proposal.Labels != nil {
|
||||
for k, v := range proposal.Labels {
|
||||
jobSpec.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jobSpec
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, defaultValue int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// cloneConfigValueMap is now exported by the plugin package as CloneConfigValueMap
|
||||
@@ -1,33 +0,0 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func TestBuildJobSpecFromProposalDoesNotReuseProposalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proposal := &plugin_pb.JobProposal{
|
||||
ProposalId: "vacuum-2",
|
||||
DedupeKey: "vacuum:2",
|
||||
JobType: "vacuum",
|
||||
}
|
||||
|
||||
jobA := buildJobSpecFromProposal("vacuum", proposal, 0)
|
||||
jobB := buildJobSpecFromProposal("vacuum", proposal, 1)
|
||||
|
||||
if jobA.JobId == proposal.ProposalId {
|
||||
t.Fatalf("job id must not reuse proposal id: %s", jobA.JobId)
|
||||
}
|
||||
if jobB.JobId == proposal.ProposalId {
|
||||
t.Fatalf("job id must not reuse proposal id: %s", jobB.JobId)
|
||||
}
|
||||
if jobA.JobId == jobB.JobId {
|
||||
t.Fatalf("job ids must be unique across jobs: %s", jobA.JobId)
|
||||
}
|
||||
if jobA.DedupeKey != proposal.DedupeKey {
|
||||
t.Fatalf("dedupe key must be preserved: got=%s want=%s", jobA.DedupeKey, proposal.DedupeKey)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
@@ -22,8 +21,8 @@ const (
|
||||
accessKeyPrefix = "ABIA" // Service account access keys use ABIA prefix
|
||||
|
||||
// Status constants
|
||||
StatusActive = iam.AccessKeyStatusActive
|
||||
StatusInactive = iam.AccessKeyStatusInactive
|
||||
StatusActive = "Active"
|
||||
StatusInactive = "Inactive"
|
||||
)
|
||||
|
||||
// GetServiceAccounts returns all service accounts, optionally filtered by parent user
|
||||
|
||||
@@ -5,14 +5,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -95,10 +93,6 @@ func (s *WorkerGrpcServer) StartWithTLS(port int) error {
|
||||
grpcServer := pb.NewGrpcServer(security.LoadServerTLS(util.GetViper(), "grpc.admin"))
|
||||
|
||||
worker_pb.RegisterWorkerServiceServer(grpcServer, s)
|
||||
if plugin := s.adminServer.GetPlugin(); plugin != nil {
|
||||
plugin_pb.RegisterPluginControlServiceServer(grpcServer, plugin)
|
||||
glog.V(0).Infof("Plugin gRPC service registered on worker gRPC server")
|
||||
}
|
||||
|
||||
s.grpcServer = grpcServer
|
||||
s.listener = listener
|
||||
@@ -120,25 +114,6 @@ func (s *WorkerGrpcServer) StartWithTLS(port int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListenPort returns the currently bound worker gRPC listen port.
|
||||
func (s *WorkerGrpcServer) ListenPort() int {
|
||||
if s == nil || s.listener == nil {
|
||||
return 0
|
||||
}
|
||||
if tcpAddr, ok := s.listener.Addr().(*net.TCPAddr); ok {
|
||||
return tcpAddr.Port
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(s.listener.Addr().String())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// Stop stops the gRPC server
|
||||
func (s *WorkerGrpcServer) Stop() error {
|
||||
if !s.running {
|
||||
|
||||
@@ -23,7 +23,7 @@ type AdminHandlers struct {
|
||||
fileBrowserHandlers *FileBrowserHandlers
|
||||
userHandlers *UserHandlers
|
||||
policyHandlers *PolicyHandlers
|
||||
pluginHandlers *PluginHandlers
|
||||
maintenanceHandlers *MaintenanceHandlers
|
||||
mqHandlers *MessageQueueHandlers
|
||||
serviceAccountHandlers *ServiceAccountHandlers
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
|
||||
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
|
||||
userHandlers := NewUserHandlers(adminServer)
|
||||
policyHandlers := NewPolicyHandlers(adminServer)
|
||||
pluginHandlers := NewPluginHandlers(adminServer)
|
||||
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
|
||||
mqHandlers := NewMessageQueueHandlers(adminServer)
|
||||
serviceAccountHandlers := NewServiceAccountHandlers(adminServer)
|
||||
return &AdminHandlers{
|
||||
@@ -45,7 +45,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
|
||||
fileBrowserHandlers: fileBrowserHandlers,
|
||||
userHandlers: userHandlers,
|
||||
policyHandlers: policyHandlers,
|
||||
pluginHandlers: pluginHandlers,
|
||||
maintenanceHandlers: maintenanceHandlers,
|
||||
mqHandlers: mqHandlers,
|
||||
serviceAccountHandlers: serviceAccountHandlers,
|
||||
}
|
||||
@@ -119,12 +119,14 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
protected.GET("/mq/topics", h.mqHandlers.ShowTopics)
|
||||
protected.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails)
|
||||
|
||||
protected.GET("/plugin", h.pluginHandlers.ShowPlugin)
|
||||
protected.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration)
|
||||
protected.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue)
|
||||
protected.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection)
|
||||
protected.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution)
|
||||
protected.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring)
|
||||
// Maintenance system routes
|
||||
protected.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
|
||||
protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
|
||||
protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
|
||||
protected.POST("/maintenance/config", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateMaintenanceConfig)
|
||||
protected.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
|
||||
protected.POST("/maintenance/config/:taskType", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateTaskConfig)
|
||||
protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail)
|
||||
|
||||
// API routes for AJAX calls
|
||||
api := r.Group("/api")
|
||||
@@ -224,25 +226,20 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
volumeApi.POST("/:id/:server/vacuum", dash.RequireWriteAccess(), h.clusterHandlers.VacuumVolume)
|
||||
}
|
||||
|
||||
// Plugin API routes
|
||||
pluginApi := api.Group("/plugin")
|
||||
// Maintenance API routes
|
||||
maintenanceApi := api.Group("/maintenance")
|
||||
{
|
||||
pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI)
|
||||
pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI)
|
||||
pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI)
|
||||
pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI)
|
||||
pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI)
|
||||
pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI)
|
||||
pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI)
|
||||
pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI)
|
||||
pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI)
|
||||
pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI)
|
||||
pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI)
|
||||
pluginApi.PUT("/job-types/:jobType/config", dash.RequireWriteAccess(), h.adminServer.UpdatePluginJobTypeConfigAPI)
|
||||
pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI)
|
||||
pluginApi.POST("/job-types/:jobType/detect", dash.RequireWriteAccess(), h.adminServer.TriggerPluginDetectionAPI)
|
||||
pluginApi.POST("/job-types/:jobType/run", dash.RequireWriteAccess(), h.adminServer.RunPluginJobTypeAPI)
|
||||
pluginApi.POST("/jobs/execute", dash.RequireWriteAccess(), h.adminServer.ExecutePluginJobAPI)
|
||||
maintenanceApi.POST("/scan", dash.RequireWriteAccess(), h.adminServer.TriggerMaintenanceScan)
|
||||
maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
|
||||
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
|
||||
maintenanceApi.GET("/tasks/:id/detail", h.adminServer.GetMaintenanceTaskDetailAPI)
|
||||
maintenanceApi.POST("/tasks/:id/cancel", dash.RequireWriteAccess(), h.adminServer.CancelMaintenanceTask)
|
||||
maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
|
||||
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
|
||||
maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs)
|
||||
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
|
||||
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
|
||||
maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI)
|
||||
}
|
||||
|
||||
// Message Queue API routes
|
||||
@@ -295,12 +292,14 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
r.GET("/mq/topics", h.mqHandlers.ShowTopics)
|
||||
r.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails)
|
||||
|
||||
r.GET("/plugin", h.pluginHandlers.ShowPlugin)
|
||||
r.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration)
|
||||
r.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue)
|
||||
r.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection)
|
||||
r.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution)
|
||||
r.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring)
|
||||
// Maintenance system routes
|
||||
r.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
|
||||
r.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
|
||||
r.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
|
||||
r.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig)
|
||||
r.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
|
||||
r.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig)
|
||||
r.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail)
|
||||
|
||||
// API routes for AJAX calls
|
||||
api := r.Group("/api")
|
||||
@@ -399,25 +398,20 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
|
||||
}
|
||||
|
||||
// Plugin API routes
|
||||
pluginApi := api.Group("/plugin")
|
||||
// Maintenance API routes
|
||||
maintenanceApi := api.Group("/maintenance")
|
||||
{
|
||||
pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI)
|
||||
pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI)
|
||||
pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI)
|
||||
pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI)
|
||||
pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI)
|
||||
pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI)
|
||||
pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI)
|
||||
pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI)
|
||||
pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI)
|
||||
pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI)
|
||||
pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI)
|
||||
pluginApi.PUT("/job-types/:jobType/config", h.adminServer.UpdatePluginJobTypeConfigAPI)
|
||||
pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI)
|
||||
pluginApi.POST("/job-types/:jobType/detect", h.adminServer.TriggerPluginDetectionAPI)
|
||||
pluginApi.POST("/job-types/:jobType/run", h.adminServer.RunPluginJobTypeAPI)
|
||||
pluginApi.POST("/jobs/execute", h.adminServer.ExecutePluginJobAPI)
|
||||
maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan)
|
||||
maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
|
||||
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
|
||||
maintenanceApi.GET("/tasks/:id/detail", h.adminServer.GetMaintenanceTaskDetailAPI)
|
||||
maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask)
|
||||
maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
|
||||
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
|
||||
maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs)
|
||||
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
|
||||
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
|
||||
maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
|
||||
}
|
||||
|
||||
// Message Queue API routes
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
)
|
||||
|
||||
func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true)
|
||||
|
||||
if !hasRoute(router, "GET", "/api/plugin/scheduler-states") {
|
||||
t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in no-auth mode")
|
||||
}
|
||||
if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") {
|
||||
t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in no-auth mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_WithAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true)
|
||||
|
||||
if !hasRoute(router, "GET", "/api/plugin/scheduler-states") {
|
||||
t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in auth mode")
|
||||
}
|
||||
if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") {
|
||||
t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in auth mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true)
|
||||
|
||||
assertHasRoute(t, router, "GET", "/plugin")
|
||||
assertHasRoute(t, router, "GET", "/plugin/configuration")
|
||||
assertHasRoute(t, router, "GET", "/plugin/queue")
|
||||
assertHasRoute(t, router, "GET", "/plugin/detection")
|
||||
assertHasRoute(t, router, "GET", "/plugin/execution")
|
||||
assertHasRoute(t, router, "GET", "/plugin/monitoring")
|
||||
}
|
||||
|
||||
func TestSetupRoutes_RegistersPluginPages_WithAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true)
|
||||
|
||||
assertHasRoute(t, router, "GET", "/plugin")
|
||||
assertHasRoute(t, router, "GET", "/plugin/configuration")
|
||||
assertHasRoute(t, router, "GET", "/plugin/queue")
|
||||
assertHasRoute(t, router, "GET", "/plugin/detection")
|
||||
assertHasRoute(t, router, "GET", "/plugin/execution")
|
||||
assertHasRoute(t, router, "GET", "/plugin/monitoring")
|
||||
}
|
||||
|
||||
func newRouteTestAdminHandlers() *AdminHandlers {
|
||||
adminServer := &dash.AdminServer{}
|
||||
return &AdminHandlers{
|
||||
adminServer: adminServer,
|
||||
authHandlers: &AuthHandlers{adminServer: adminServer},
|
||||
clusterHandlers: &ClusterHandlers{adminServer: adminServer},
|
||||
fileBrowserHandlers: &FileBrowserHandlers{adminServer: adminServer},
|
||||
userHandlers: &UserHandlers{adminServer: adminServer},
|
||||
policyHandlers: &PolicyHandlers{adminServer: adminServer},
|
||||
pluginHandlers: &PluginHandlers{adminServer: adminServer},
|
||||
mqHandlers: &MessageQueueHandlers{adminServer: adminServer},
|
||||
serviceAccountHandlers: &ServiceAccountHandlers{adminServer: adminServer},
|
||||
}
|
||||
}
|
||||
|
||||
func hasRoute(router *gin.Engine, method string, path string) bool {
|
||||
for _, route := range router.Routes() {
|
||||
if route.Method == method && route.Path == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertHasRoute(t *testing.T, router *gin.Engine, method string, path string) {
|
||||
t.Helper()
|
||||
if !hasRoute(router, method, path) {
|
||||
t.Fatalf("expected %s %s to be registered", method, path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"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"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// MaintenanceHandlers handles maintenance-related HTTP requests
|
||||
type MaintenanceHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
}
|
||||
|
||||
// NewMaintenanceHandlers creates a new instance of MaintenanceHandlers
|
||||
func NewMaintenanceHandlers(adminServer *dash.AdminServer) *MaintenanceHandlers {
|
||||
return &MaintenanceHandlers{
|
||||
adminServer: adminServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTaskDetail displays the task detail page
|
||||
func (h *MaintenanceHandlers) ShowTaskDetail(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
|
||||
if h.adminServer == nil {
|
||||
c.String(http.StatusInternalServerError, "Admin server not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
taskDetail, err := h.adminServer.GetMaintenanceTaskDetail(taskID)
|
||||
if err != nil {
|
||||
glog.Errorf("DEBUG ShowTaskDetail: error getting task detail for %s: %v", taskID, err)
|
||||
c.String(http.StatusNotFound, "Task not found: %s (Error: %v)", taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
taskDetailComponent := app.TaskDetail(taskDetail)
|
||||
layoutComponent := layout.Layout(c, taskDetailComponent)
|
||||
err = layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
glog.Errorf("DEBUG ShowTaskDetail: render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to render template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ShowMaintenanceQueue displays the maintenance queue page
|
||||
func (h *MaintenanceHandlers) ShowMaintenanceQueue(c *gin.Context) {
|
||||
// Add timeout to prevent hanging
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a channel to handle timeout for data retrieval
|
||||
type result struct {
|
||||
data *maintenance.MaintenanceQueueData
|
||||
err error
|
||||
}
|
||||
resultChan := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
data, err := h.getMaintenanceQueueData()
|
||||
resultChan <- result{data: data, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultChan:
|
||||
if res.err != nil {
|
||||
glog.V(1).Infof("ShowMaintenanceQueue: error getting data: %v", res.err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": res.err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(2).Infof("ShowMaintenanceQueue: got data with %d tasks", len(res.data.Tasks))
|
||||
|
||||
// Render HTML template
|
||||
c.Header("Content-Type", "text/html")
|
||||
maintenanceComponent := app.MaintenanceQueue(res.data)
|
||||
layoutComponent := layout.Layout(c, maintenanceComponent)
|
||||
err := layoutComponent.Render(ctx, c.Writer)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("ShowMaintenanceQueue: render error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(3).Infof("ShowMaintenanceQueue: template rendered successfully")
|
||||
|
||||
case <-ctx.Done():
|
||||
glog.Warningf("ShowMaintenanceQueue: timeout waiting for data")
|
||||
c.JSON(http.StatusRequestTimeout, gin.H{
|
||||
"error": "Request timeout - maintenance data retrieval took too long. This may indicate a system issue.",
|
||||
"suggestion": "Try refreshing the page or contact system administrator if the problem persists.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowMaintenanceWorkers displays the maintenance workers page
|
||||
func (h *MaintenanceHandlers) ShowMaintenanceWorkers(c *gin.Context) {
|
||||
if h.adminServer == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Admin server not initialized"})
|
||||
return
|
||||
}
|
||||
workersData, err := h.adminServer.GetMaintenanceWorkersData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Render HTML template
|
||||
c.Header("Content-Type", "text/html")
|
||||
workersComponent := app.MaintenanceWorkers(workersData)
|
||||
layoutComponent := layout.Layout(c, workersComponent)
|
||||
err = layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowMaintenanceConfig displays the maintenance configuration page
|
||||
func (h *MaintenanceHandlers) ShowMaintenanceConfig(c *gin.Context) {
|
||||
config, err := h.getMaintenanceConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the schema for dynamic form rendering
|
||||
schema := maintenance.GetMaintenanceConfigSchema()
|
||||
|
||||
// Render HTML template using schema-driven approach
|
||||
c.Header("Content-Type", "text/html")
|
||||
configComponent := app.MaintenanceConfigSchema(config, schema)
|
||||
layoutComponent := layout.Layout(c, configComponent)
|
||||
err = layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTaskConfig displays the configuration page for a specific task type
|
||||
func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) {
|
||||
taskTypeName := c.Param("taskType")
|
||||
|
||||
// Get the schema for this task type
|
||||
schema := tasks.GetTaskConfigSchema(taskTypeName)
|
||||
if schema == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found or no schema available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the UI provider for current configuration
|
||||
uiRegistry := tasks.GetGlobalUIRegistry()
|
||||
typesRegistry := tasks.GetGlobalTypesRegistry()
|
||||
|
||||
var provider types.TaskUIProvider
|
||||
for workerTaskType := range typesRegistry.GetAllDetectors() {
|
||||
if string(workerTaskType) == taskTypeName {
|
||||
provider = uiRegistry.GetProvider(workerTaskType)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if provider == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
currentConfig := provider.GetCurrentConfig()
|
||||
|
||||
// Note: Do NOT apply schema defaults to current config as it overrides saved values
|
||||
// Only apply defaults when creating new configs, not when displaying existing ones
|
||||
|
||||
// Create task configuration data
|
||||
configData := &maintenance.TaskConfigData{
|
||||
TaskType: maintenance.MaintenanceTaskType(taskTypeName),
|
||||
TaskName: schema.DisplayName,
|
||||
TaskIcon: schema.Icon,
|
||||
Description: schema.Description,
|
||||
}
|
||||
|
||||
// Render HTML template using schema-based approach
|
||||
c.Header("Content-Type", "text/html")
|
||||
taskConfigComponent := app.TaskConfigSchema(configData, schema, currentConfig)
|
||||
layoutComponent := layout.Layout(c, taskConfigComponent)
|
||||
err := layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTaskConfig updates task configuration from form
|
||||
func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
|
||||
taskTypeName := c.Param("taskType")
|
||||
taskType := types.TaskType(taskTypeName)
|
||||
|
||||
// Parse form data
|
||||
err := c.Request.ParseForm()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging - show received form data
|
||||
glog.V(1).Infof("Received form data for task type %s:", taskTypeName)
|
||||
for key, values := range c.Request.PostForm {
|
||||
glog.V(1).Infof(" %s: %v", key, values)
|
||||
}
|
||||
|
||||
// Get the task configuration schema
|
||||
schema := tasks.GetTaskConfigSchema(taskTypeName)
|
||||
if schema == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Schema not found for task type: " + taskTypeName})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new config instance based on task type and apply schema defaults
|
||||
var config TaskConfig
|
||||
switch taskType {
|
||||
case types.TaskTypeVacuum:
|
||||
config = &vacuum.Config{}
|
||||
case types.TaskTypeBalance:
|
||||
config = &balance.Config{}
|
||||
case types.TaskTypeErasureCoding:
|
||||
config = &erasure_coding.Config{}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported task type: " + taskTypeName})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply schema defaults first using type-safe method
|
||||
if err := schema.ApplyDefaultsToConfig(config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply defaults: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// First, get the current configuration to preserve existing values
|
||||
currentUIRegistry := tasks.GetGlobalUIRegistry()
|
||||
currentTypesRegistry := tasks.GetGlobalTypesRegistry()
|
||||
|
||||
var currentProvider types.TaskUIProvider
|
||||
for workerTaskType := range currentTypesRegistry.GetAllDetectors() {
|
||||
if string(workerTaskType) == string(taskType) {
|
||||
currentProvider = currentUIRegistry.GetProvider(workerTaskType)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentProvider != nil {
|
||||
// Copy current config values to the new config
|
||||
currentConfig := currentProvider.GetCurrentConfig()
|
||||
if currentConfigProtobuf, ok := currentConfig.(TaskConfig); ok {
|
||||
// Apply current values using protobuf directly - no map conversion needed!
|
||||
currentPolicy := currentConfigProtobuf.ToTaskPolicy()
|
||||
if err := config.FromTaskPolicy(currentPolicy); err != nil {
|
||||
glog.Warningf("Failed to load current config for %s: %v", taskTypeName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse form data using schema-based approach (this will override with new values)
|
||||
err = h.parseTaskConfigFromForm(c.Request.PostForm, schema, config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging - show parsed config values
|
||||
switch taskType {
|
||||
case types.TaskTypeVacuum:
|
||||
if vacuumConfig, ok := config.(*vacuum.Config); ok {
|
||||
glog.V(1).Infof("Parsed vacuum config - GarbageThreshold: %f, MinVolumeAgeSeconds: %d, MinIntervalSeconds: %d",
|
||||
vacuumConfig.GarbageThreshold, vacuumConfig.MinVolumeAgeSeconds, vacuumConfig.MinIntervalSeconds)
|
||||
}
|
||||
case types.TaskTypeErasureCoding:
|
||||
if ecConfig, ok := config.(*erasure_coding.Config); ok {
|
||||
glog.V(1).Infof("Parsed EC config - FullnessRatio: %f, QuietForSeconds: %d, MinSizeMB: %d, CollectionFilter: '%s'",
|
||||
ecConfig.FullnessRatio, ecConfig.QuietForSeconds, ecConfig.MinSizeMB, ecConfig.CollectionFilter)
|
||||
}
|
||||
case types.TaskTypeBalance:
|
||||
if balanceConfig, ok := config.(*balance.Config); ok {
|
||||
glog.V(1).Infof("Parsed balance config - Enabled: %v, MaxConcurrent: %d, ScanIntervalSeconds: %d, ImbalanceThreshold: %f, MinServerCount: %d",
|
||||
balanceConfig.Enabled, balanceConfig.MaxConcurrent, balanceConfig.ScanIntervalSeconds, balanceConfig.ImbalanceThreshold, balanceConfig.MinServerCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
if validationErrors := schema.ValidateConfig(config); len(validationErrors) > 0 {
|
||||
errorMessages := make([]string, len(validationErrors))
|
||||
for i, err := range validationErrors {
|
||||
errorMessages[i] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Configuration validation failed", "details": errorMessages})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply configuration using UIProvider
|
||||
uiRegistry := tasks.GetGlobalUIRegistry()
|
||||
typesRegistry := tasks.GetGlobalTypesRegistry()
|
||||
|
||||
var provider types.TaskUIProvider
|
||||
for workerTaskType := range typesRegistry.GetAllDetectors() {
|
||||
if string(workerTaskType) == string(taskType) {
|
||||
provider = uiRegistry.GetProvider(workerTaskType)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if provider == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply configuration using provider
|
||||
err = provider.ApplyTaskConfig(config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save task configuration to protobuf file using ConfigPersistence
|
||||
if h.adminServer != nil && h.adminServer.GetConfigPersistence() != nil {
|
||||
err = h.saveTaskConfigToProtobuf(taskType, config)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to save task config to protobuf file: %v", err)
|
||||
// Don't fail the request, just log the warning
|
||||
}
|
||||
} else if h.adminServer == nil {
|
||||
glog.Warningf("Failed to save task config: admin server not initialized")
|
||||
}
|
||||
|
||||
// Trigger a configuration reload in the maintenance manager
|
||||
if h.adminServer != nil {
|
||||
if manager := h.adminServer.GetMaintenanceManager(); manager != nil {
|
||||
err = manager.ReloadTaskConfigurations()
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to reload task configurations: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Successfully reloaded task configurations after updating %s", taskTypeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to task configuration page
|
||||
c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
|
||||
}
|
||||
|
||||
// parseTaskConfigFromForm parses form data using schema definitions
|
||||
func (h *MaintenanceHandlers) parseTaskConfigFromForm(formData map[string][]string, schema *tasks.TaskConfigSchema, config interface{}) error {
|
||||
configValue := reflect.ValueOf(config)
|
||||
if configValue.Kind() == reflect.Ptr {
|
||||
configValue = configValue.Elem()
|
||||
}
|
||||
|
||||
if configValue.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("config must be a struct or pointer to struct")
|
||||
}
|
||||
|
||||
configType := configValue.Type()
|
||||
|
||||
for i := 0; i < configValue.NumField(); i++ {
|
||||
field := configValue.Field(i)
|
||||
fieldType := configType.Field(i)
|
||||
|
||||
// Handle embedded structs recursively
|
||||
if fieldType.Anonymous && field.Kind() == reflect.Struct {
|
||||
err := h.parseTaskConfigFromForm(formData, schema, field.Addr().Interface())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing embedded struct %s: %w", fieldType.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Get JSON tag name
|
||||
jsonTag := fieldType.Tag.Get("json")
|
||||
if jsonTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove options like ",omitempty"
|
||||
if commaIdx := strings.Index(jsonTag, ","); commaIdx > 0 {
|
||||
jsonTag = jsonTag[:commaIdx]
|
||||
}
|
||||
|
||||
// Find corresponding schema field
|
||||
schemaField := schema.GetFieldByName(jsonTag)
|
||||
if schemaField == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse value based on field type
|
||||
if err := h.parseFieldFromForm(formData, schemaField, field); err != nil {
|
||||
return fmt.Errorf("error parsing field %s: %w", schemaField.DisplayName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseFieldFromForm parses a single field value from form data
|
||||
func (h *MaintenanceHandlers) parseFieldFromForm(formData map[string][]string, schemaField *config.Field, fieldValue reflect.Value) error {
|
||||
if !fieldValue.CanSet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch schemaField.Type {
|
||||
case config.FieldTypeBool:
|
||||
// Checkbox fields - present means true, absent means false
|
||||
_, exists := formData[schemaField.JSONName]
|
||||
fieldValue.SetBool(exists)
|
||||
|
||||
case config.FieldTypeInt:
|
||||
if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 {
|
||||
if intVal, err := strconv.Atoi(values[0]); err != nil {
|
||||
return fmt.Errorf("invalid integer value: %s", values[0])
|
||||
} else {
|
||||
fieldValue.SetInt(int64(intVal))
|
||||
}
|
||||
}
|
||||
|
||||
case config.FieldTypeFloat:
|
||||
if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 {
|
||||
if floatVal, err := strconv.ParseFloat(values[0], 64); err != nil {
|
||||
return fmt.Errorf("invalid float value: %s", values[0])
|
||||
} else {
|
||||
fieldValue.SetFloat(floatVal)
|
||||
}
|
||||
}
|
||||
|
||||
case config.FieldTypeString:
|
||||
if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 {
|
||||
fieldValue.SetString(values[0])
|
||||
}
|
||||
|
||||
case config.FieldTypeInterval:
|
||||
// Parse interval fields with value + unit
|
||||
valueKey := schemaField.JSONName + "_value"
|
||||
unitKey := schemaField.JSONName + "_unit"
|
||||
|
||||
if valueStrs, ok := formData[valueKey]; ok && len(valueStrs) > 0 {
|
||||
value, err := strconv.Atoi(valueStrs[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval value: %s", valueStrs[0])
|
||||
}
|
||||
|
||||
unit := "minutes" // default
|
||||
if unitStrs, ok := formData[unitKey]; ok && len(unitStrs) > 0 {
|
||||
unit = unitStrs[0]
|
||||
}
|
||||
|
||||
// Convert to seconds
|
||||
seconds := config.IntervalValueUnitToSeconds(value, unit)
|
||||
fieldValue.SetInt(int64(seconds))
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported field type: %s", schemaField.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMaintenanceConfig updates maintenance configuration from form
|
||||
func (h *MaintenanceHandlers) UpdateMaintenanceConfig(c *gin.Context) {
|
||||
var config maintenance.MaintenanceConfig
|
||||
if err := c.ShouldBind(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.updateMaintenanceConfig(&config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/maintenance/config")
|
||||
}
|
||||
|
||||
// Helper methods that delegate to AdminServer
|
||||
|
||||
func (h *MaintenanceHandlers) getMaintenanceQueueData() (*maintenance.MaintenanceQueueData, error) {
|
||||
if h.adminServer == nil {
|
||||
return nil, fmt.Errorf("admin server not initialized")
|
||||
}
|
||||
// Use the exported method from AdminServer used by the JSON API
|
||||
return h.adminServer.GetMaintenanceQueueData()
|
||||
}
|
||||
|
||||
func (h *MaintenanceHandlers) getMaintenanceConfig() (*maintenance.MaintenanceConfigData, error) {
|
||||
if h.adminServer == nil {
|
||||
return nil, fmt.Errorf("admin server not initialized")
|
||||
}
|
||||
// Delegate to AdminServer's real persistence method
|
||||
return h.adminServer.GetMaintenanceConfigData()
|
||||
}
|
||||
|
||||
func (h *MaintenanceHandlers) updateMaintenanceConfig(config *maintenance.MaintenanceConfig) error {
|
||||
if h.adminServer == nil {
|
||||
return fmt.Errorf("admin server not initialized")
|
||||
}
|
||||
// Delegate to AdminServer's real persistence method
|
||||
return h.adminServer.UpdateMaintenanceConfigData(config)
|
||||
}
|
||||
|
||||
// saveTaskConfigToProtobuf saves task configuration to protobuf file
|
||||
func (h *MaintenanceHandlers) saveTaskConfigToProtobuf(taskType types.TaskType, config TaskConfig) error {
|
||||
configPersistence := h.adminServer.GetConfigPersistence()
|
||||
if configPersistence == nil {
|
||||
return fmt.Errorf("config persistence not available")
|
||||
}
|
||||
|
||||
// Use the new ToTaskPolicy method - much simpler and more maintainable!
|
||||
taskPolicy := config.ToTaskPolicy()
|
||||
|
||||
// Save using task-specific methods
|
||||
switch taskType {
|
||||
case types.TaskTypeVacuum:
|
||||
return configPersistence.SaveVacuumTaskPolicy(taskPolicy)
|
||||
case types.TaskTypeErasureCoding:
|
||||
return configPersistence.SaveErasureCodingTaskPolicy(taskPolicy)
|
||||
case types.TaskTypeBalance:
|
||||
return configPersistence.SaveBalanceTaskPolicy(taskPolicy)
|
||||
default:
|
||||
return fmt.Errorf("unsupported task type for protobuf persistence: %s", taskType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
|
||||
)
|
||||
|
||||
func TestParseTaskConfigFromForm_WithEmbeddedStruct(t *testing.T) {
|
||||
// Create a maintenance handlers instance for testing
|
||||
h := &MaintenanceHandlers{}
|
||||
|
||||
// Test with balance config
|
||||
t.Run("Balance Config", func(t *testing.T) {
|
||||
// Simulate form data
|
||||
formData := url.Values{
|
||||
"enabled": {"on"}, // checkbox field
|
||||
"scan_interval_seconds_value": {"30"}, // interval field
|
||||
"scan_interval_seconds_unit": {"minutes"}, // interval unit
|
||||
"max_concurrent": {"2"}, // number field
|
||||
"imbalance_threshold": {"0.15"}, // float field
|
||||
"min_server_count": {"3"}, // number field
|
||||
}
|
||||
|
||||
// Get schema
|
||||
schema := tasks.GetTaskConfigSchema("balance")
|
||||
if schema == nil {
|
||||
t.Fatal("Failed to get balance schema")
|
||||
}
|
||||
|
||||
// Create config instance
|
||||
config := &balance.Config{}
|
||||
|
||||
// Parse form data
|
||||
err := h.parseTaskConfigFromForm(formData, schema, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse form data: %v", err)
|
||||
}
|
||||
|
||||
// Verify embedded struct fields were set correctly
|
||||
if !config.Enabled {
|
||||
t.Errorf("Expected Enabled=true, got %v", config.Enabled)
|
||||
}
|
||||
|
||||
if config.ScanIntervalSeconds != 1800 { // 30 minutes * 60
|
||||
t.Errorf("Expected ScanIntervalSeconds=1800, got %v", config.ScanIntervalSeconds)
|
||||
}
|
||||
|
||||
if config.MaxConcurrent != 2 {
|
||||
t.Errorf("Expected MaxConcurrent=2, got %v", config.MaxConcurrent)
|
||||
}
|
||||
|
||||
// Verify balance-specific fields were set correctly
|
||||
if config.ImbalanceThreshold != 0.15 {
|
||||
t.Errorf("Expected ImbalanceThreshold=0.15, got %v", config.ImbalanceThreshold)
|
||||
}
|
||||
|
||||
if config.MinServerCount != 3 {
|
||||
t.Errorf("Expected MinServerCount=3, got %v", config.MinServerCount)
|
||||
}
|
||||
})
|
||||
|
||||
// Test with vacuum config
|
||||
t.Run("Vacuum Config", func(t *testing.T) {
|
||||
// Simulate form data
|
||||
formData := url.Values{
|
||||
// "enabled" field omitted to simulate unchecked checkbox
|
||||
"scan_interval_seconds_value": {"4"}, // interval field
|
||||
"scan_interval_seconds_unit": {"hours"}, // interval unit
|
||||
"max_concurrent": {"3"}, // number field
|
||||
"garbage_threshold": {"0.4"}, // float field
|
||||
"min_volume_age_seconds_value": {"2"}, // interval field
|
||||
"min_volume_age_seconds_unit": {"days"}, // interval unit
|
||||
"min_interval_seconds_value": {"1"}, // interval field
|
||||
"min_interval_seconds_unit": {"days"}, // interval unit
|
||||
}
|
||||
|
||||
// Get schema
|
||||
schema := tasks.GetTaskConfigSchema("vacuum")
|
||||
if schema == nil {
|
||||
t.Fatal("Failed to get vacuum schema")
|
||||
}
|
||||
|
||||
// Create config instance
|
||||
config := &vacuum.Config{}
|
||||
|
||||
// Parse form data
|
||||
err := h.parseTaskConfigFromForm(formData, schema, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse form data: %v", err)
|
||||
}
|
||||
|
||||
// Verify embedded struct fields were set correctly
|
||||
if config.Enabled {
|
||||
t.Errorf("Expected Enabled=false, got %v", config.Enabled)
|
||||
}
|
||||
|
||||
if config.ScanIntervalSeconds != 14400 { // 4 hours * 3600
|
||||
t.Errorf("Expected ScanIntervalSeconds=14400, got %v", config.ScanIntervalSeconds)
|
||||
}
|
||||
|
||||
if config.MaxConcurrent != 3 {
|
||||
t.Errorf("Expected MaxConcurrent=3, got %v", config.MaxConcurrent)
|
||||
}
|
||||
|
||||
// Verify vacuum-specific fields were set correctly
|
||||
if config.GarbageThreshold != 0.4 {
|
||||
t.Errorf("Expected GarbageThreshold=0.4, got %v", config.GarbageThreshold)
|
||||
}
|
||||
|
||||
if config.MinVolumeAgeSeconds != 172800 { // 2 days * 86400
|
||||
t.Errorf("Expected MinVolumeAgeSeconds=172800, got %v", config.MinVolumeAgeSeconds)
|
||||
}
|
||||
|
||||
if config.MinIntervalSeconds != 86400 { // 1 day * 86400
|
||||
t.Errorf("Expected MinIntervalSeconds=86400, got %v", config.MinIntervalSeconds)
|
||||
}
|
||||
})
|
||||
|
||||
// Test with erasure coding config
|
||||
t.Run("Erasure Coding Config", func(t *testing.T) {
|
||||
// Simulate form data
|
||||
formData := url.Values{
|
||||
"enabled": {"on"}, // checkbox field
|
||||
"scan_interval_seconds_value": {"2"}, // interval field
|
||||
"scan_interval_seconds_unit": {"hours"}, // interval unit
|
||||
"max_concurrent": {"1"}, // number field
|
||||
"quiet_for_seconds_value": {"10"}, // interval field
|
||||
"quiet_for_seconds_unit": {"minutes"}, // interval unit
|
||||
"fullness_ratio": {"0.85"}, // float field
|
||||
"collection_filter": {"test_collection"}, // string field
|
||||
"min_size_mb": {"50"}, // number field
|
||||
}
|
||||
|
||||
// Get schema
|
||||
schema := tasks.GetTaskConfigSchema("erasure_coding")
|
||||
if schema == nil {
|
||||
t.Fatal("Failed to get erasure_coding schema")
|
||||
}
|
||||
|
||||
// Create config instance
|
||||
config := &erasure_coding.Config{}
|
||||
|
||||
// Parse form data
|
||||
err := h.parseTaskConfigFromForm(formData, schema, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse form data: %v", err)
|
||||
}
|
||||
|
||||
// Verify embedded struct fields were set correctly
|
||||
if !config.Enabled {
|
||||
t.Errorf("Expected Enabled=true, got %v", config.Enabled)
|
||||
}
|
||||
|
||||
if config.ScanIntervalSeconds != 7200 { // 2 hours * 3600
|
||||
t.Errorf("Expected ScanIntervalSeconds=7200, got %v", config.ScanIntervalSeconds)
|
||||
}
|
||||
|
||||
if config.MaxConcurrent != 1 {
|
||||
t.Errorf("Expected MaxConcurrent=1, got %v", config.MaxConcurrent)
|
||||
}
|
||||
|
||||
// Verify erasure coding-specific fields were set correctly
|
||||
if config.QuietForSeconds != 600 { // 10 minutes * 60
|
||||
t.Errorf("Expected QuietForSeconds=600, got %v", config.QuietForSeconds)
|
||||
}
|
||||
|
||||
if config.FullnessRatio != 0.85 {
|
||||
t.Errorf("Expected FullnessRatio=0.85, got %v", config.FullnessRatio)
|
||||
}
|
||||
|
||||
if config.CollectionFilter != "test_collection" {
|
||||
t.Errorf("Expected CollectionFilter='test_collection', got %v", config.CollectionFilter)
|
||||
}
|
||||
|
||||
if config.MinSizeMB != 50 {
|
||||
t.Errorf("Expected MinSizeMB=50, got %v", config.MinSizeMB)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigurationValidation(t *testing.T) {
|
||||
// Test that config structs can be validated and converted to protobuf format
|
||||
taskTypes := []struct {
|
||||
name string
|
||||
config interface{}
|
||||
}{
|
||||
{
|
||||
"balance",
|
||||
&balance.Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 2400,
|
||||
MaxConcurrent: 3,
|
||||
},
|
||||
ImbalanceThreshold: 0.18,
|
||||
MinServerCount: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
"vacuum",
|
||||
&vacuum.Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: false,
|
||||
ScanIntervalSeconds: 7200,
|
||||
MaxConcurrent: 2,
|
||||
},
|
||||
GarbageThreshold: 0.35,
|
||||
MinVolumeAgeSeconds: 86400,
|
||||
MinIntervalSeconds: 604800,
|
||||
},
|
||||
},
|
||||
{
|
||||
"erasure_coding",
|
||||
&erasure_coding.Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 3600,
|
||||
MaxConcurrent: 1,
|
||||
},
|
||||
QuietForSeconds: 900,
|
||||
FullnessRatio: 0.9,
|
||||
CollectionFilter: "important",
|
||||
MinSizeMB: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range taskTypes {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Test that configs can be converted to protobuf TaskPolicy
|
||||
switch cfg := test.config.(type) {
|
||||
case *balance.Config:
|
||||
policy := cfg.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("ToTaskPolicy returned nil")
|
||||
}
|
||||
if policy.Enabled != cfg.Enabled {
|
||||
t.Errorf("Expected Enabled=%v, got %v", cfg.Enabled, policy.Enabled)
|
||||
}
|
||||
if policy.MaxConcurrent != int32(cfg.MaxConcurrent) {
|
||||
t.Errorf("Expected MaxConcurrent=%v, got %v", cfg.MaxConcurrent, policy.MaxConcurrent)
|
||||
}
|
||||
case *vacuum.Config:
|
||||
policy := cfg.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("ToTaskPolicy returned nil")
|
||||
}
|
||||
if policy.Enabled != cfg.Enabled {
|
||||
t.Errorf("Expected Enabled=%v, got %v", cfg.Enabled, policy.Enabled)
|
||||
}
|
||||
if policy.MaxConcurrent != int32(cfg.MaxConcurrent) {
|
||||
t.Errorf("Expected MaxConcurrent=%v, got %v", cfg.MaxConcurrent, policy.MaxConcurrent)
|
||||
}
|
||||
case *erasure_coding.Config:
|
||||
policy := cfg.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("ToTaskPolicy returned nil")
|
||||
}
|
||||
if policy.Enabled != cfg.Enabled {
|
||||
t.Errorf("Expected Enabled=%v, got %v", cfg.Enabled, policy.Enabled)
|
||||
}
|
||||
if policy.MaxConcurrent != int32(cfg.MaxConcurrent) {
|
||||
t.Errorf("Expected MaxConcurrent=%v, got %v", cfg.MaxConcurrent, policy.MaxConcurrent)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("Unknown config type: %T", test.config)
|
||||
}
|
||||
|
||||
// Test that configs can be validated
|
||||
switch cfg := test.config.(type) {
|
||||
case *balance.Config:
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validation failed: %v", err)
|
||||
}
|
||||
case *vacuum.Config:
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validation failed: %v", err)
|
||||
}
|
||||
case *erasure_coding.Config:
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFieldFromForm_EdgeCases(t *testing.T) {
|
||||
h := &MaintenanceHandlers{}
|
||||
|
||||
// Test checkbox parsing (boolean fields)
|
||||
t.Run("Checkbox Fields", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
formData url.Values
|
||||
expectedValue bool
|
||||
}{
|
||||
{"Checked checkbox", url.Values{"test_field": {"on"}}, true},
|
||||
{"Unchecked checkbox", url.Values{}, false},
|
||||
{"Empty value checkbox", url.Values{"test_field": {""}}, true}, // Present but empty means checked
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
schema := &tasks.TaskConfigSchema{
|
||||
Schema: config.Schema{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
JSONName: "test_field",
|
||||
Type: config.FieldTypeBool,
|
||||
InputType: "checkbox",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
TestField bool `json:"test_field"`
|
||||
}
|
||||
|
||||
config := &TestConfig{}
|
||||
err := h.parseTaskConfigFromForm(test.formData, schema, config)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskConfigFromForm failed: %v", err)
|
||||
}
|
||||
|
||||
if config.TestField != test.expectedValue {
|
||||
t.Errorf("Expected %v, got %v", test.expectedValue, config.TestField)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test interval parsing
|
||||
t.Run("Interval Fields", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
unit string
|
||||
expectedSecs int
|
||||
}{
|
||||
{"Minutes", "30", "minutes", 1800},
|
||||
{"Hours", "2", "hours", 7200},
|
||||
{"Days", "1", "days", 86400},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
formData := url.Values{
|
||||
"test_field_value": {test.value},
|
||||
"test_field_unit": {test.unit},
|
||||
}
|
||||
|
||||
schema := &tasks.TaskConfigSchema{
|
||||
Schema: config.Schema{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
JSONName: "test_field",
|
||||
Type: config.FieldTypeInterval,
|
||||
InputType: "interval",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
TestField int `json:"test_field"`
|
||||
}
|
||||
|
||||
config := &TestConfig{}
|
||||
err := h.parseTaskConfigFromForm(formData, schema, config)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskConfigFromForm failed: %v", err)
|
||||
}
|
||||
|
||||
if config.TestField != test.expectedSecs {
|
||||
t.Errorf("Expected %d seconds, got %d", test.expectedSecs, config.TestField)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||
)
|
||||
|
||||
// PluginHandlers handles plugin UI pages.
|
||||
type PluginHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
}
|
||||
|
||||
// NewPluginHandlers creates a new instance of PluginHandlers.
|
||||
func NewPluginHandlers(adminServer *dash.AdminServer) *PluginHandlers {
|
||||
return &PluginHandlers{
|
||||
adminServer: adminServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowPlugin displays plugin overview page.
|
||||
func (h *PluginHandlers) ShowPlugin(c *gin.Context) {
|
||||
h.renderPluginPage(c, "overview")
|
||||
}
|
||||
|
||||
// ShowPluginConfiguration displays plugin configuration page.
|
||||
func (h *PluginHandlers) ShowPluginConfiguration(c *gin.Context) {
|
||||
h.renderPluginPage(c, "configuration")
|
||||
}
|
||||
|
||||
// ShowPluginDetection displays plugin detection jobs page.
|
||||
func (h *PluginHandlers) ShowPluginDetection(c *gin.Context) {
|
||||
h.renderPluginPage(c, "detection")
|
||||
}
|
||||
|
||||
// ShowPluginQueue displays plugin job queue page.
|
||||
func (h *PluginHandlers) ShowPluginQueue(c *gin.Context) {
|
||||
h.renderPluginPage(c, "queue")
|
||||
}
|
||||
|
||||
// ShowPluginExecution displays plugin execution jobs page.
|
||||
func (h *PluginHandlers) ShowPluginExecution(c *gin.Context) {
|
||||
h.renderPluginPage(c, "execution")
|
||||
}
|
||||
|
||||
// ShowPluginMonitoring displays plugin monitoring page.
|
||||
func (h *PluginHandlers) ShowPluginMonitoring(c *gin.Context) {
|
||||
// Backward-compatible alias for the old monitoring URL.
|
||||
h.renderPluginPage(c, "detection")
|
||||
}
|
||||
|
||||
func (h *PluginHandlers) renderPluginPage(c *gin.Context, page string) {
|
||||
component := app.Plugin(page)
|
||||
layoutComponent := layout.Layout(c, component)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := layoutComponent.Render(c.Request.Context(), &buf); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user