mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-09 16:15:51 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178532e63a |
@@ -0,0 +1 @@
|
||||
{"sessionId":"d6574c47-eafc-4a94-9dce-f9ffea22b53c","pid":10111,"acquiredAt":1775248373916}
|
||||
@@ -4,10 +4,6 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
concurrency:
|
||||
group: binaries-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
- name: Build ${{ matrix.platform }} ${{ matrix.variant }}
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
buildkitd-config: /tmp/buildkitd.toml
|
||||
- name: Build local scan image tarball
|
||||
if: needs.setup.outputs.publish != 'true'
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
fi
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./docker
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Build and push ${{ matrix.variant }}
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
with:
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v2
|
||||
with:
|
||||
context: ./docker
|
||||
push: true
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: "FUSE P2P Peer Chunk Sharing Integration Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'weed/command/mount*.go'
|
||||
- 'weed/mount/**'
|
||||
- 'weed/filer/mount_peer_registry*.go'
|
||||
- 'weed/server/filer_grpc_server_mount_peer.go'
|
||||
- 'weed/pb/mount_peer.proto'
|
||||
- 'weed/pb/filer.proto'
|
||||
- 'test/fuse_p2p/**'
|
||||
- '.github/workflows/fuse-p2p-integration.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'weed/command/mount*.go'
|
||||
- 'weed/mount/**'
|
||||
- 'weed/filer/mount_peer_registry*.go'
|
||||
- 'weed/server/filer_grpc_server_mount_peer.go'
|
||||
- 'weed/pb/mount_peer.proto'
|
||||
- 'weed/pb/filer.proto'
|
||||
- 'test/fuse_p2p/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.ref }}/fuse-p2p-integration
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fuse-p2p-integration:
|
||||
name: FUSE P2P Peer Chunk Sharing
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Install FUSE dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse3-dev
|
||||
echo 'user_allow_other' | sudo tee -a /etc/fuse.conf
|
||||
sudo chmod 644 /etc/fuse.conf
|
||||
|
||||
- name: Build SeaweedFS
|
||||
run: go build -o weed/weed -buildvcs=false ./weed
|
||||
|
||||
- name: Run P2P integration tests
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
WEED_BINARY: ${{ github.workspace }}/weed/weed
|
||||
run: go test -v -count=1 -timeout=12m ./test/fuse_p2p/...
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fuse-p2p-test-logs
|
||||
path: /tmp/seaweedfs-fuse-p2p-logs/
|
||||
retention-days: 3
|
||||
@@ -1,9 +1,6 @@
|
||||
name: "helm: release"
|
||||
name: "helm: manual release"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
name: "NFS Integration Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'weed/server/nfs/**'
|
||||
- 'weed/command/nfs.go'
|
||||
- 'weed/filer/filer_inode.go'
|
||||
- 'weed/filer/filer_inode_index.go'
|
||||
- 'weed/filer/filerstore_wrapper.go'
|
||||
- 'weed/server/filer_grpc_server_rename.go'
|
||||
- 'test/nfs/**'
|
||||
- '.github/workflows/nfs-tests.yml'
|
||||
pull_request:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'weed/server/nfs/**'
|
||||
- 'weed/command/nfs.go'
|
||||
- 'weed/filer/filer_inode.go'
|
||||
- 'weed/filer/filer_inode_index.go'
|
||||
- 'weed/filer/filerstore_wrapper.go'
|
||||
- 'weed/server/filer_grpc_server_rename.go'
|
||||
- 'test/nfs/**'
|
||||
- '.github/workflows/nfs-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref }}/nfs-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TEST_TIMEOUT: '15m'
|
||||
|
||||
jobs:
|
||||
nfs-integration:
|
||||
name: NFS Integration Testing
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build SeaweedFS
|
||||
run: |
|
||||
cd weed
|
||||
go build -o weed .
|
||||
chmod +x weed
|
||||
./weed version
|
||||
|
||||
- name: Run NFS Integration Tests
|
||||
run: |
|
||||
cd test/nfs
|
||||
|
||||
echo "Running NFS integration tests..."
|
||||
echo "============================================"
|
||||
|
||||
# Install test dependencies
|
||||
go mod download
|
||||
|
||||
# Run all NFS tests
|
||||
go test -v -timeout=${{ env.TEST_TIMEOUT }} ./...
|
||||
|
||||
echo "============================================"
|
||||
echo "NFS integration tests completed"
|
||||
|
||||
- name: Test Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## NFS Integration Test Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Coverage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Read/Write Round Trip**: Basic file create + read" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Directory Operations**: Mkdir, ReadDirPlus, RmDir" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Nested Directories**: Deep tree creation and leaf I/O" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Rename**: Content preserved across rename" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Overwrite + Truncate**: Setattr(size=0) + shorter write" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Large Files**: 3 MiB binary round trip" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Edge Payloads**: All 256 byte values + empty files" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Symlinks**: Symlink + Lookup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Missing Path**: Remove on missing entry errors cleanly" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **FSINFO**: Non-zero rtpref/wtpref advertised" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Sequential Append**: Two-part concatenation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **ReadDir After Remove**: Meta cache does not serve stale entries" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Harness" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Each test boots its own master + volume + filer + nfs subprocess" >> $GITHUB_STEP_SUMMARY
|
||||
echo "stack on loopback and drives it via the NFSv3 RPC protocol using" >> $GITHUB_STEP_SUMMARY
|
||||
echo "go-nfs-client. No kernel NFS mount or privileged port is required." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -39,49 +39,18 @@ jobs:
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Start local Docker registry
|
||||
run: docker run -d --restart=always -p 5000:5000 --name registry registry:2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build weed race binary
|
||||
- name: Build SeaweedFS e2e image
|
||||
run: |
|
||||
cd docker
|
||||
make binary_race
|
||||
|
||||
- name: Build SeaweedFS e2e image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: docker
|
||||
file: docker/Dockerfile.e2e
|
||||
tags: localhost:5000/chrislusf/seaweedfs:e2e
|
||||
push: true
|
||||
cache-from: type=gha,scope=pjdfstest-e2e
|
||||
cache-to: type=gha,mode=max,scope=pjdfstest-e2e
|
||||
|
||||
- name: Tag e2e image for docker compose
|
||||
run: |
|
||||
docker pull localhost:5000/chrislusf/seaweedfs:e2e
|
||||
docker tag localhost:5000/chrislusf/seaweedfs:e2e chrislusf/seaweedfs:e2e
|
||||
make build_e2e || {
|
||||
echo "Retrying without buildx cache..."
|
||||
make binary_race
|
||||
docker build --no-cache -t chrislusf/seaweedfs:e2e -f Dockerfile.e2e .
|
||||
}
|
||||
|
||||
- name: Build pjdfstest image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: test/pjdfstest
|
||||
build-contexts: |
|
||||
chrislusf/seaweedfs:e2e=docker-image://localhost:5000/chrislusf/seaweedfs:e2e
|
||||
tags: localhost:5000/chrislusf/seaweedfs:pjdfstest
|
||||
push: true
|
||||
cache-from: type=gha,scope=pjdfstest-harness
|
||||
cache-to: type=gha,mode=max,scope=pjdfstest-harness
|
||||
|
||||
- name: Tag pjdfstest image for docker compose
|
||||
run: |
|
||||
docker pull localhost:5000/chrislusf/seaweedfs:pjdfstest
|
||||
docker tag localhost:5000/chrislusf/seaweedfs:pjdfstest chrislusf/seaweedfs:pjdfstest
|
||||
docker build -t chrislusf/seaweedfs:pjdfstest test/pjdfstest/
|
||||
|
||||
- name: Start SeaweedFS cluster
|
||||
run: |
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
rm weed-volume-normal
|
||||
|
||||
- name: Upload dev release assets
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: dev
|
||||
prerelease: true
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
rm weed-volume-normal
|
||||
|
||||
- name: Upload dev release assets
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: dev
|
||||
prerelease: true
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Upload release assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Upload release assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
|
||||
- name: Upload release assets
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
weed-volume_large_disk_windows_amd64.zip
|
||||
|
||||
@@ -245,61 +245,6 @@ jobs:
|
||||
path: test/s3/retention/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
s3-checksum-tests:
|
||||
name: S3 Checksum Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run S3 Checksum Tests
|
||||
timeout-minutes: 16
|
||||
working-directory: test/s3/checksum
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
echo "=== Starting Tests ==="
|
||||
make test-with-server
|
||||
|
||||
- name: Show server logs on failure
|
||||
if: failure()
|
||||
working-directory: test/s3/checksum
|
||||
run: |
|
||||
echo "=== Server Logs ==="
|
||||
if [ -f weed-test.log ]; then
|
||||
echo "Last 100 lines of server logs:"
|
||||
tail -100 weed-test.log
|
||||
else
|
||||
echo "No server log file found"
|
||||
fi
|
||||
|
||||
echo "=== Test Environment ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp | grep -E "(8333|9333|8080)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: s3-checksum-test-logs
|
||||
path: test/s3/checksum/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
s3-cors-tests:
|
||||
name: S3 CORS Tests
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -37,8 +37,6 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/ceph/s3-tests.git
|
||||
cd s3-tests
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libxml2-dev libxslt1-dev zlib1g-dev
|
||||
pip install -r requirements.txt
|
||||
pip install tox
|
||||
pip install -e .
|
||||
@@ -341,8 +339,6 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/ceph/s3-tests.git
|
||||
cd s3-tests
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libxml2-dev libxslt1-dev zlib1g-dev
|
||||
pip install -r requirements.txt
|
||||
pip install tox
|
||||
pip install -e .
|
||||
@@ -511,8 +507,6 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/ceph/s3-tests.git
|
||||
cd s3-tests
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libxml2-dev libxslt1-dev zlib1g-dev
|
||||
pip install -r requirements.txt
|
||||
pip install tox
|
||||
pip install -e .
|
||||
@@ -736,8 +730,6 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/ceph/s3-tests.git
|
||||
cd s3-tests
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libxml2-dev libxslt1-dev zlib1g-dev
|
||||
pip install -r requirements.txt
|
||||
pip install tox
|
||||
pip install -e .
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: "Vacuum Integration Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
vacuum-integration-tests:
|
||||
name: Vacuum Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ^1.25
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build weed binary
|
||||
run: |
|
||||
cd weed && go build -o weed .
|
||||
|
||||
- name: Run Vacuum Integration Tests
|
||||
working-directory: test/vacuum
|
||||
run: |
|
||||
go test -v -timeout 10m
|
||||
|
||||
- name: Collect server logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Collecting server logs from temp directories..."
|
||||
mkdir -p /tmp/vacuum-test-logs
|
||||
find /tmp -maxdepth 1 -type d -name "TestVacuum*" 2>/dev/null | while read dir; do
|
||||
if [ -d "$dir" ]; then
|
||||
echo "Found test directory: $dir"
|
||||
cp -r "$dir" /tmp/vacuum-test-logs/ 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
echo "Collected logs:"
|
||||
find /tmp/vacuum-test-logs -type f -name "*.log" 2>/dev/null || echo "No logs found"
|
||||
|
||||
- name: Archive logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: vacuum-integration-test-logs
|
||||
path: /tmp/vacuum-test-logs/
|
||||
retention-days: 14
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find a security issue in SeaweedFS, please report it privately:
|
||||
|
||||
- Email: support@seaweedfs.com
|
||||
- Do not open a public GitHub issue
|
||||
|
||||
Please include:
|
||||
- A clear description of the issue
|
||||
- Steps to reproduce (if possible)
|
||||
- Affected versions
|
||||
|
||||
## Response
|
||||
|
||||
- We will respond as soon as possible (usually within 1 business day)
|
||||
- We will investigate and work on a fix
|
||||
- We may coordinate disclosure with you
|
||||
|
||||
## Notes
|
||||
|
||||
- Please allow time for a fix before public disclosure
|
||||
- If you’re unsure whether something is a security issue, feel free to reach out
|
||||
@@ -7,8 +7,8 @@ LABEL author="Chris Lu"
|
||||
# Production images (Dockerfile.go_build) use proper user isolation with su-exec.
|
||||
# For testing purposes, running as root avoids permission complexities and dependency
|
||||
# on Alpine-specific tools like su-exec (not available in Ubuntu repos).
|
||||
RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y \
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
--no-install-recommends \
|
||||
--no-install-suggests \
|
||||
curl \
|
||||
|
||||
@@ -56,8 +56,8 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh
|
||||
# To disable: docker run -e GODEBUG=fips140=off ...
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache fuse curl su-exec libgcc libcrypto3 libssl3 && \
|
||||
RUN apk upgrade --no-cache zlib && \
|
||||
apk add --no-cache fuse curl su-exec libgcc && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ COPY ./filer.toml /etc/seaweedfs/filer.toml
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk upgrade --no-cache && \
|
||||
RUN apk upgrade --no-cache zlib && \
|
||||
apk add --no-cache fuse curl su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
@@ -34,7 +34,7 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/filer_rocksdb.
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk upgrade --no-cache && \
|
||||
RUN apk upgrade --no-cache zlib && \
|
||||
apk add --no-cache fuse snappy gflags curl su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
@@ -17,7 +17,7 @@ COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/filer_rocksdb.
|
||||
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Install dependencies and create non-root user
|
||||
RUN apk upgrade --no-cache && \
|
||||
RUN apk upgrade --no-cache zlib && \
|
||||
apk add --no-cache fuse snappy gflags curl tmux su-exec && \
|
||||
addgroup -g 1000 seaweed && \
|
||||
adduser -D -u 1000 -G seaweed seaweed
|
||||
|
||||
@@ -39,7 +39,7 @@ require (
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
@@ -92,14 +92,14 @@ require (
|
||||
gocloud.dev v0.45.0
|
||||
gocloud.dev/pubsub/natspubsub v0.45.0
|
||||
gocloud.dev/pubsub/rabbitpubsub v0.45.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.274.0
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
@@ -118,19 +118,18 @@ require (
|
||||
github.com/Jille/raft-grpc-transport v1.6.1
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1
|
||||
github.com/a-h/templ v0.3.977
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.0
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0
|
||||
github.com/apache/iceberg-go v0.5.0
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
|
||||
github.com/arangodb/go-driver v1.6.9
|
||||
github.com/armon/go-metrics v0.4.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0
|
||||
github.com/cognusion/imaging v1.0.2
|
||||
github.com/fluent/fluent-logger-golang v1.10.1
|
||||
github.com/getsentry/sentry-go v0.44.1
|
||||
github.com/go-git/go-billy/v5 v5.6.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
|
||||
@@ -153,11 +152,9 @@ require (
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.2
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
github.com/tikv/client-go/v2 v2.0.7
|
||||
github.com/willscott/go-nfs v0.0.3
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.0
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.125.3
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.10
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/sync v0.20.0
|
||||
@@ -212,6 +209,7 @@ require (
|
||||
github.com/dromara/dongle v1.0.1 // indirect
|
||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
@@ -258,7 +256,6 @@ require (
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/pterm/pterm v0.12.82 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
@@ -285,8 +282,8 @@ require (
|
||||
go.uber.org/mock v0.5.2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
)
|
||||
|
||||
@@ -479,7 +476,7 @@ require (
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180 // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb // indirect
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
|
||||
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
|
||||
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
|
||||
@@ -499,7 +496,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
|
||||
@@ -692,8 +692,8 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
|
||||
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87 h1:r/gg2gzUXiXoy72VU3jnODh8l/5rL0aslnTAbtmai/U=
|
||||
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87/go.mod h1:IJTMBTlHe7cDOhRh0ioGuEKBl5iTR6xPfl5BN4AgirU=
|
||||
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.0 h1:VEbbeJ2ift4deKMZ6Fs55Vs3fq/RrkjCcxCnqUxhwf8=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.0/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0 h1:Omnzb1Z/P90Dr2TbVNu54ICQL7TKVIIsJO231w484HU=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
|
||||
github.com/apache/iceberg-go v0.5.0 h1:wQj4CK5YiXZcB+tj19gWG+Jf1I6MiORQ/StSL/E5gGQ=
|
||||
github.com/apache/iceberg-go v0.5.0/go.mod h1:F/rdP1yZmnO4mQ0Qew2HTGdc+ZV57cRfxbbq/uJm1eM=
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
@@ -718,8 +718,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
@@ -742,8 +742,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7 h1:fovS7qGMT+BBSuifkySdVaMWxXTyaYT6qaBx/1y6Ij4=
|
||||
@@ -1403,8 +1403,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
|
||||
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jaegertracing/jaeger v1.47.0 h1:XXxTMO+GxX930gxKWsg90rFr6RswkCRIW0AgWFnTYsg=
|
||||
@@ -1783,8 +1783,6 @@ github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3Adq
|
||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
|
||||
@@ -2028,10 +2026,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/willscott/go-nfs v0.0.3 h1:Z5fHVxMsppgEucdkKBN26Vou19MtEM875NmRwj156RE=
|
||||
github.com/willscott/go-nfs v0.0.3/go.mod h1:VhNccO67Oug787VNXcyx9JDI3ZoSpqoKMT/lWMhUIDg=
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 h1:DtrBtkgTJk2XGt4T7eKdKVkd9A5NCevN2e4inLXtsqA=
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
|
||||
@@ -2064,14 +2058,14 @@ github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e h1:9LPdmD
|
||||
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20230528143953-42c825ace222/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180 h1:avIdi8eGXjKbn1WLokNR1Ofnz1k8t7tJ88YQLD/iCi8=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb h1:LZ6dhVfWzhicf/P5Xh7fA0Jd7rfGduxmB2QZpD+Lz9Q=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1 h1:XaRxeVrOyl3y6v9CiYMWaFdZ6zevvYe+TRxOR8ifa2s=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1/go.mod h1:9YzkhlIymWaJGX6KMU3vh5sOf3UKbCXkG/ZdjaI3zNM=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.44.0/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.3/go.mod h1:bWnOIcUHd7+Sl7DN+yhyY1H/I61z53GczvwJgXMgvj0=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.0 h1:Voog4d56wPNT8JQvbgN6QPdZ1gKG36YM5615PJB/XpM=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.125.3 h1:hTwpF+PdbuR7vcixN+4AC6yu4asaUIAljQCxL51eDII=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.125.3/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1 h1:qw3Fa+T81+Kpu5Io2vYHJOwcrYrVjgJlT6t/0dOXJrA=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.1/go.mod h1:t/ZA4ECdgPWjAb4jyDe8AzQZB5dhpGbi3iCahFaNwBY=
|
||||
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 h1:9E5q8Nsy2RiJMZDNVy0A3KUrIMBPakJ2VgloeWbcI84=
|
||||
@@ -2214,8 +2208,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -2281,8 +2275,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -2350,8 +2344,8 @@ golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -2509,11 +2503,11 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -2529,8 +2523,8 @@ golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -2551,8 +2545,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -2630,8 +2624,8 @@ golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
description: SeaweedFS
|
||||
name: seaweedfs
|
||||
appVersion: "4.21"
|
||||
appVersion: "4.19"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.21.0
|
||||
version: 4.19.0
|
||||
|
||||
@@ -49,35 +49,6 @@ CREATE TABLE IF NOT EXISTS `filemeta` (
|
||||
|
||||
Alternative database can also be configured (e.g. leveldb, postgres) following the instructions at `filer.extraEnvironmentVars`.
|
||||
|
||||
#### RocksDB variant
|
||||
|
||||
The `_large_disk_rocksdb` image tag ships with RocksDB pre-configured as the filer backend.
|
||||
To use this image with the Helm chart, override the image on all three components and disable
|
||||
the chart's default `WEED_LEVELDB2_ENABLED`, which would otherwise re-enable LevelDB2 and
|
||||
override the image's built-in RocksDB configuration:
|
||||
|
||||
```yaml
|
||||
# Replace <VERSION> with the desired seaweedfs version, e.g. 3.80_large_disk_rocksdb.
|
||||
master:
|
||||
imageOverride: chrislusf/seaweedfs:<VERSION>_large_disk_rocksdb
|
||||
|
||||
volume:
|
||||
imageOverride: chrislusf/seaweedfs:<VERSION>_large_disk_rocksdb
|
||||
|
||||
filer:
|
||||
enablePVC: true
|
||||
imageOverride: chrislusf/seaweedfs:<VERSION>_large_disk_rocksdb
|
||||
extraEnvironmentVars:
|
||||
WEED_LEVELDB2_ENABLED: "false"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* `master` and `volume` use the same image tag so that all components share a consistent
|
||||
SeaweedFS build; RocksDB itself is only used by the filer.
|
||||
* `filer.enablePVC: true` (or another form of persistent storage for the filer) is required
|
||||
so that the RocksDB metadata store survives pod restarts — otherwise metadata will be lost.
|
||||
|
||||
### Node Labels
|
||||
Kubernetes nodes can have labels which help to define which node(Host) will run which pod:
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.admin.image" . }}
|
||||
image: {{ template "admin.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
{{- $adminAuthEnabled := or .Values.admin.secret.existingSecret .Values.admin.secret.adminPassword }}
|
||||
{{- $urlPrefix := .Values.admin.urlPrefix }}
|
||||
@@ -242,7 +242,7 @@ spec:
|
||||
securityContext: {{- omit .Values.admin.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.admin.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.admin.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.admin.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.admin.data.type "hostPath" }}
|
||||
@@ -303,7 +303,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{ tpl .Values.admin.nodeSelector . | indent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- $pvc_exists := include "seaweedfs.admin.pvc_exists" . -}}
|
||||
{{- $pvc_exists := include "admin.pvc_exists" . -}}
|
||||
{{- if $pvc_exists }}
|
||||
volumeClaimTemplates:
|
||||
{{- if eq .Values.admin.data.type "persistentVolumeClaim" }}
|
||||
|
||||
@@ -77,7 +77,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.master.image" . }}
|
||||
image: {{ template "master.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
{{- /* Determine default cluster alias and the corresponding env var keys to avoid conflicts */}}
|
||||
@@ -418,7 +418,7 @@ spec:
|
||||
{{- omit .Values.allInOne.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.allInOne.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.allInOne.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
|
||||
@@ -164,7 +164,7 @@ spec:
|
||||
securityContext: {{- omit .Values.cosi.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.cosi.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.cosi.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.cosi.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: socket
|
||||
|
||||
@@ -86,7 +86,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.filer.image" . }}
|
||||
image: {{ template "filer.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
@@ -317,7 +317,7 @@ spec:
|
||||
securityContext: {{- omit .Values.filer.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.filer.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.filer.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.filer.logs.type "hostPath" }}
|
||||
@@ -413,7 +413,7 @@ spec:
|
||||
storageClassName: {{ .Values.filer.storageClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- $pvc_exists := include "seaweedfs.filer.pvc_exists" . -}}
|
||||
{{- $pvc_exists := include "filer.pvc_exists" . -}}
|
||||
{{- if $pvc_exists }}
|
||||
volumeClaimTemplates:
|
||||
{{- if eq .Values.filer.data.type "persistentVolumeClaim" }}
|
||||
|
||||
@@ -80,7 +80,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.master.image" . }}
|
||||
image: {{ template "master.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
@@ -251,7 +251,7 @@ spec:
|
||||
securityContext: {{- omit .Values.master.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.master.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.master.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.master.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.master.logs.type "hostPath" }}
|
||||
@@ -312,7 +312,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{ tpl .Values.master.nodeSelector . | indent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- $pvc_exists := include "seaweedfs.master.pvc_exists" . -}}
|
||||
{{- $pvc_exists := include "master.pvc_exists" . -}}
|
||||
{{- if $pvc_exists }}
|
||||
volumeClaimTemplates:
|
||||
{{- if eq .Values.master.data.type "persistentVolumeClaim"}}
|
||||
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.s3.image" . }}
|
||||
image: {{ template "s3.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
@@ -226,7 +226,7 @@ spec:
|
||||
securityContext: {{- omit .Values.s3.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.s3.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.s3.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.s3.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if .Values.s3.enableAuth }}
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
{{- $access_key_admin := $adminCreds.accessKey -}}
|
||||
{{- $secret_key_admin := $adminCreds.secretKey -}}
|
||||
{{- if not (and $access_key_admin $secret_key_admin) -}}
|
||||
{{- $access_key_admin = include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_admin = include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_secret_access_key" "length" 40 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $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 "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_access_key_id" "length" 20 "existingSecret" (ternary $existingSecret nil $reuse)) -}}
|
||||
{{- $secret_key_read = include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "read_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)) -}}
|
||||
{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{{- include "seaweedfs.compat" . -}}
|
||||
{{- /*
|
||||
The seaweedfs-s3 Service only gains a "metrics" port when the standalone S3
|
||||
gateway is enabled. With only the embedded filer S3 gateway, metrics live on
|
||||
the filer process and are already scraped by the filer ServiceMonitor.
|
||||
*/ -}}
|
||||
{{- if and .Values.s3.enabled .Values.s3.metricsPort .Values.global.seaweedfs.monitoring.enabled }}
|
||||
{{- if or .Values.s3.enabled .Values.filer.s3.enabled }}
|
||||
{{- if .Values.s3.metricsPort }}
|
||||
{{- if .Values.global.seaweedfs.monitoring.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
@@ -34,3 +31,5 @@ spec:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: s3
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.sftp.image" . }}
|
||||
image: {{ template "sftp.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
@@ -233,7 +233,7 @@ spec:
|
||||
securityContext: {{- omit .Values.sftp.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.sftp.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.sftp.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if .Values.sftp.enableAuth }}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{- if or .Values.sftp.enabled .Values.allInOne.enabled }}
|
||||
{{- $secretName := printf "%s-sftp-secret" (include "seaweedfs.fullname" .) }}
|
||||
{{- $admin_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_password" "length" 20) -}}
|
||||
{{- $read_user_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "readonly_password" "length" 20) -}}
|
||||
{{- $public_user_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "public_user_password" "length" 20) -}}
|
||||
{{- $admin_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_password" 20) -}}
|
||||
{{- $read_user_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "readonly_password" 20) -}}
|
||||
{{- $public_user_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "public_user_password" 20) -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: Opaque
|
||||
|
||||
@@ -72,77 +72,77 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper filer image */}}
|
||||
{{- define "seaweedfs.filer.image" -}}
|
||||
{{- define "filer.image" -}}
|
||||
{{- if .Values.filer.imageOverride -}}
|
||||
{{- $imageOverride := .Values.filer.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper master image */}}
|
||||
{{- define "seaweedfs.master.image" -}}
|
||||
{{- define "master.image" -}}
|
||||
{{- if .Values.master.imageOverride -}}
|
||||
{{- $imageOverride := .Values.master.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper s3 image */}}
|
||||
{{- define "seaweedfs.s3.image" -}}
|
||||
{{- define "s3.image" -}}
|
||||
{{- if .Values.s3.imageOverride -}}
|
||||
{{- $imageOverride := .Values.s3.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper sftp image */}}
|
||||
{{- define "seaweedfs.sftp.image" -}}
|
||||
{{- define "sftp.image" -}}
|
||||
{{- if .Values.sftp.imageOverride -}}
|
||||
{{- $imageOverride := .Values.sftp.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper admin image */}}
|
||||
{{- define "seaweedfs.admin.image" -}}
|
||||
{{- define "admin.image" -}}
|
||||
{{- if .Values.admin.imageOverride -}}
|
||||
{{- $imageOverride := .Values.admin.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper worker image */}}
|
||||
{{- define "seaweedfs.worker.image" -}}
|
||||
{{- define "worker.image" -}}
|
||||
{{- if .Values.worker.imageOverride -}}
|
||||
{{- $imageOverride := .Values.worker.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Return the proper volume image */}}
|
||||
{{- define "seaweedfs.volume.image" -}}
|
||||
{{- define "volume.image" -}}
|
||||
{{- if .Values.volume.imageOverride -}}
|
||||
{{- $imageOverride := .Values.volume.imageOverride -}}
|
||||
{{- printf "%s" $imageOverride -}}
|
||||
{{- else -}}
|
||||
{{- include "seaweedfs.image" . }}
|
||||
{{- include "common.image" . }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Computes the container image name for all components (if they are not overridden) */}}
|
||||
{{- define "seaweedfs.image" -}}
|
||||
{{- define "common.image" -}}
|
||||
{{- $registryName := default .Values.image.registry .Values.global.imageRegistry | toString -}}
|
||||
{{- $repositoryName := default .Values.image.repository .Values.global.seaweedfs.image.repository | toString -}}
|
||||
{{- $name := .Values.global.seaweedfs.image.name | toString -}}
|
||||
@@ -160,7 +160,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any Volume PVC exists */}}
|
||||
{{- define "seaweedfs.volume.pvc_exists" -}}
|
||||
{{- define "volume.pvc_exists" -}}
|
||||
{{- if or (or (eq .Values.volume.data.type "persistentVolumeClaim") (and (eq .Values.volume.idx.type "persistentVolumeClaim") .Values.volume.dir_idx )) (eq .Values.volume.logs.type "persistentVolumeClaim") -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
@@ -169,7 +169,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any Filer PVC exists */}}
|
||||
{{- define "seaweedfs.filer.pvc_exists" -}}
|
||||
{{- define "filer.pvc_exists" -}}
|
||||
{{- if or (eq .Values.filer.data.type "persistentVolumeClaim") (eq .Values.filer.logs.type "persistentVolumeClaim") -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
@@ -178,7 +178,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any Master PVC exists */}}
|
||||
{{- define "seaweedfs.master.pvc_exists" -}}
|
||||
{{- define "master.pvc_exists" -}}
|
||||
{{- if or (eq .Values.master.data.type "persistentVolumeClaim") (eq .Values.master.logs.type "persistentVolumeClaim") -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
@@ -187,7 +187,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any Admin PVC exists */}}
|
||||
{{- define "seaweedfs.admin.pvc_exists" -}}
|
||||
{{- define "admin.pvc_exists" -}}
|
||||
{{- if or (eq .Values.admin.data.type "persistentVolumeClaim") (eq .Values.admin.logs.type "persistentVolumeClaim") -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
@@ -196,7 +196,7 @@ Inject extra environment vars in the format key:value, if populated
|
||||
{{- end -}}
|
||||
|
||||
{{/* check if any InitContainers exist for Volumes */}}
|
||||
{{- define "seaweedfs.volume.initContainers_exists" -}}
|
||||
{{- define "volume.initContainers_exists" -}}
|
||||
{{- if or (not (empty .Values.volume.idx )) (not (empty .Values.volume.initContainers )) -}}
|
||||
{{- printf "true" -}}
|
||||
{{- else -}}
|
||||
@@ -225,10 +225,10 @@ imagePullSecrets:
|
||||
{{/*
|
||||
Renders a value that contains template perhaps with scope if the scope is present.
|
||||
Usage:
|
||||
{{ include "seaweedfs.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $ ) }}
|
||||
{{ include "seaweedfs.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $ "scope" $app ) }}
|
||||
{{ include "common.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $ ) }}
|
||||
{{ include "common.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $ "scope" $app ) }}
|
||||
*/}}
|
||||
{{- define "seaweedfs.tplvalues.render" -}}
|
||||
{{- define "common.tplvalues.render" -}}
|
||||
{{- $value := typeIs "string" .value | ternary .value (.value | toYaml) }}
|
||||
{{- if contains "{{" (toJson .value) }}
|
||||
{{- if .scope }}
|
||||
@@ -245,9 +245,9 @@ Usage:
|
||||
Converts a Kubernetes quantity like "256Mi" or "2G" to a float64 in base units,
|
||||
handling both binary (Ki, Mi, Gi) and decimal (m, k, M) suffixes; numeric inputs
|
||||
Usage:
|
||||
{{ include "seaweedfs.resource-quantity" "10Gi" }}
|
||||
{{ include "common.resource-quantity" "10Gi" }}
|
||||
*/}}
|
||||
{{- define "seaweedfs.resource-quantity" -}}
|
||||
{{- define "common.resource-quantity" -}}
|
||||
{{- $value := . -}}
|
||||
{{- $unit := 1.0 -}}
|
||||
{{- if typeIs "string" . -}}
|
||||
@@ -267,7 +267,7 @@ Usage:
|
||||
getOrGeneratePassword will check if a password exists in a secret and return it,
|
||||
or generate a new random password if it doesn't exist.
|
||||
*/}}
|
||||
{{- define "seaweedfs.getOrGeneratePassword" -}}
|
||||
{{- define "getOrGeneratePassword" -}}
|
||||
{{- $params := . -}}
|
||||
{{- $namespace := $params.namespace -}}
|
||||
{{- $secretName := $params.secretName -}}
|
||||
|
||||
@@ -68,7 +68,7 @@ spec:
|
||||
{{- include "seaweedfs.imagePullSecrets" $ | nindent 6 }}
|
||||
containers:
|
||||
- name: post-install-job
|
||||
image: {{ template "seaweedfs.master.image" . }}
|
||||
image: {{ template "master.image" . }}
|
||||
imagePullPolicy: {{ $.Values.global.seaweedfs.imagePullPolicy | default "IfNotPresent" }}
|
||||
env:
|
||||
- name: WEED_CLUSTER_DEFAULT
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
{{- $pvcName := printf "%s-%s-%s-%d" $dir.name $seaweedfsName $volumeName $e }}
|
||||
{{- $currentPVC := (lookup "v1" "PersistentVolumeClaim" $.Release.Namespace $pvcName) }}
|
||||
{{- if and $currentPVC }}
|
||||
{{- $oldSize := include "seaweedfs.resource-quantity" $currentPVC.spec.resources.requests.storage }}
|
||||
{{- $newSize := include "seaweedfs.resource-quantity" $desiredSize }}
|
||||
{{- $oldSize := include "common.resource-quantity" $currentPVC.spec.resources.requests.storage }}
|
||||
{{- $newSize := include "common.resource-quantity" $desiredSize }}
|
||||
{{- if gt $newSize $oldSize }}
|
||||
{{- $commands = append $commands (printf "kubectl patch pvc %s-%s-%s-%d -p '{\"spec\":{\"resources\":{\"requests\":{\"storage\":\"%s\"}}}}'" $dir.name $seaweedfsName $volumeName $e $desiredSize) }}
|
||||
{{- end }}
|
||||
|
||||
@@ -71,12 +71,12 @@ spec:
|
||||
{{- end }}
|
||||
enableServiceLinks: false
|
||||
serviceAccountName: {{ $volume.serviceAccountName | default (include "seaweedfs.serviceAccountName" $) | quote }} # for deleting statefulset pods after migration
|
||||
{{- $initContainers_exists := include "seaweedfs.volume.initContainers_exists" $ -}}
|
||||
{{- $initContainers_exists := include "volume.initContainers_exists" $ -}}
|
||||
{{- if $initContainers_exists }}
|
||||
initContainers:
|
||||
{{- if $volume.idx }}
|
||||
- name: seaweedfs-vol-move-idx
|
||||
image: {{ template "seaweedfs.volume.image" $ }}
|
||||
image: {{ template "volume.image" $ }}
|
||||
imagePullPolicy: {{ $.Values.global.seaweedfs.imagePullPolicy | default "IfNotPresent" }}
|
||||
command: [ '/bin/sh', '-c' ]
|
||||
args: [ '{{range $dir := $volume.dataDirs }}if ls /{{$dir.name}}/*.idx >/dev/null 2>&1; then mv /{{$dir.name}}/*.idx /idx/ ; fi; {{end}}' ]
|
||||
@@ -104,7 +104,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.volume.image" $ }}
|
||||
image: {{ template "volume.image" $ }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" $.Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_NAME
|
||||
@@ -274,7 +274,7 @@ spec:
|
||||
securityContext: {{- omit $volume.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if $volume.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" (printf "{{ $volumeName := \"%s\" }}%s" $volumeName $volume.sidecars) "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" (printf "{{ $volumeName := \"%s\" }}%s" $volumeName $volume.sidecars) "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: seaweedfs
|
||||
image: {{ template "seaweedfs.worker.image" . }}
|
||||
image: {{ template "worker.image" . }}
|
||||
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
|
||||
env:
|
||||
- name: POD_IP
|
||||
@@ -219,7 +219,7 @@ spec:
|
||||
securityContext: {{- omit .Values.worker.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.sidecars }}
|
||||
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.worker.sidecars "context" $) | nindent 8 }}
|
||||
{{- include "common.tplvalues.render" (dict "value" .Values.worker.sidecars "context" $) | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if eq .Values.worker.data.type "hostPath" }}
|
||||
|
||||
@@ -86,13 +86,6 @@ service SeaweedFiler {
|
||||
}
|
||||
rpc ReplicateLock(ReplicateLockRequest) returns (ReplicateLockResponse) {
|
||||
}
|
||||
|
||||
// Peer chunk sharing — tier 1: mount-server registry.
|
||||
// See design-weed-mount-peer-chunk-sharing.md for details.
|
||||
rpc MountRegister (MountRegisterRequest) returns (MountRegisterResponse) {
|
||||
}
|
||||
rpc MountList (MountListRequest) returns (MountListResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
@@ -204,10 +197,6 @@ message FuseAttributes {
|
||||
bytes md5 = 14;
|
||||
uint32 rdev = 16;
|
||||
uint64 inode = 17;
|
||||
int64 ctime = 18; // unix time in seconds, inode change time
|
||||
int32 mtime_ns = 19; // nanosecond component of mtime (0-999999999)
|
||||
int32 ctime_ns = 20; // nanosecond component of ctime (0-999999999)
|
||||
int32 crtime_ns = 21; // nanosecond component of crtime (0-999999999)
|
||||
}
|
||||
|
||||
message CreateEntryRequest {
|
||||
@@ -305,7 +294,6 @@ message AssignVolumeRequest {
|
||||
string rack = 7;
|
||||
string data_node = 9;
|
||||
string disk_type = 8;
|
||||
uint64 expected_data_size = 10; // hint for size-aware volume selection
|
||||
}
|
||||
|
||||
message AssignVolumeResponse {
|
||||
@@ -608,31 +596,3 @@ message StreamMutateEntryResponse {
|
||||
string error = 7; // human-readable error message when the operation failed
|
||||
int32 errno = 8; // POSIX errno (e.g. ENOENT=2, ENOTEMPTY=66) for direct FUSE status mapping
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// Peer chunk sharing — mount-server registry
|
||||
//////////////////////////////////////////////////
|
||||
|
||||
message MountRegisterRequest {
|
||||
string peer_addr = 1; // host:port where this mount serves peer chunk requests
|
||||
string rack = 2; // locality label (rack); used for peer ranking
|
||||
int32 ttl_seconds = 3; // how long the filer should keep this entry without a heartbeat
|
||||
string data_center = 4; // locality label (data center); coarser than rack
|
||||
}
|
||||
|
||||
message MountRegisterResponse {
|
||||
}
|
||||
|
||||
message MountListRequest {
|
||||
}
|
||||
|
||||
message MountListResponse {
|
||||
repeated MountInfo mounts = 1;
|
||||
}
|
||||
|
||||
message MountInfo {
|
||||
string peer_addr = 1;
|
||||
string rack = 2;
|
||||
int64 last_seen_ns = 3;
|
||||
string data_center = 4;
|
||||
}
|
||||
|
||||
@@ -145,11 +145,6 @@ message VolumeEcShardInformationMessage {
|
||||
uint64 expire_at_sec = 5; // used to record the destruction time of ec volume
|
||||
uint32 disk_id = 6;
|
||||
repeated int64 shard_sizes = 7; // optimized: sizes for shards in order of set bits in ec_index_bits
|
||||
uint64 file_count = 8; // total needles in the .ecx index (live + tombstoned)
|
||||
uint64 delete_count = 9; // node-local tombstones in the .ecj deletion journal
|
||||
// fields 10-19 reserved for future upstream open-source additions.
|
||||
// fields 20+ are owned by the enterprise fork (e.g. data_shards/parity_shards)
|
||||
// and must not be used here without coordination.
|
||||
}
|
||||
|
||||
message StorageBackend {
|
||||
@@ -236,7 +231,6 @@ message AssignRequest {
|
||||
uint32 memory_map_max_size_mb = 8;
|
||||
uint32 writable_volume_count = 9;
|
||||
string disk_type = 10;
|
||||
uint64 expected_data_size = 11; // hint for size-aware volume selection
|
||||
}
|
||||
|
||||
message VolumeGrowRequest {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
//! Each EcVolume has a sorted index (.ecx) and a deletion journal (.ecj).
|
||||
//! Shards (.ec00-.ec13) may be distributed across multiple servers.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::sync::RwLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::pb::master_pb;
|
||||
@@ -29,17 +28,6 @@ pub struct EcVolume {
|
||||
ecx_file: Option<File>,
|
||||
ecx_file_size: i64,
|
||||
ecj_file: Option<File>,
|
||||
/// On-disk size of the .ecj deletion journal. Used only by IO helpers
|
||||
/// (seek / set_len on partial writes) — the authoritative runtime
|
||||
/// delete count comes from `deleted_needles.len()`.
|
||||
ecj_file_size: i64,
|
||||
/// In-memory set of needle ids that have been deleted since the volume
|
||||
/// was encoded. .ecx is immutable at runtime — it only stores the
|
||||
/// sorted (id, offset, size) index written at encode time — and runtime
|
||||
/// deletes are journaled to .ecj + tracked here. Reads consult this
|
||||
/// set to mask out deleted needles on top of the sealed .ecx lookup.
|
||||
/// Seeded from .ecj in `new()` and updated by `journal_delete`.
|
||||
deleted_needles: RwLock<HashSet<NeedleId>>,
|
||||
pub disk_type: DiskType,
|
||||
/// Directory where .ecx/.ecj were actually found (may differ from dir_idx after fallback).
|
||||
ecx_actual_dir: String,
|
||||
@@ -124,8 +112,6 @@ impl EcVolume {
|
||||
ecx_file: None,
|
||||
ecx_file_size: 0,
|
||||
ecj_file: None,
|
||||
ecj_file_size: 0,
|
||||
deleted_needles: RwLock::new(HashSet::new()),
|
||||
disk_type: DiskType::default(),
|
||||
ecx_actual_dir: dir_idx.to_string(),
|
||||
shard_locations: HashMap::new(),
|
||||
@@ -155,12 +141,10 @@ impl EcVolume {
|
||||
}
|
||||
}
|
||||
|
||||
// Open .ecj file (deletion journal) — use ecx_actual_dir for consistency.
|
||||
// Note: Go does NOT replay .ecj into .ecx at volume load (RebuildEcxFile
|
||||
// is only invoked from specific decode/rebuild gRPC handlers), so we
|
||||
// don't either. Tombstones from prior sessions were already written
|
||||
// in-place in .ecx, and the journal grows monotonically until a
|
||||
// decode/rebuild operation folds it in.
|
||||
// Replay .ecj journal into .ecx on startup (matches Go's RebuildEcxFile).
|
||||
vol.rebuild_ecx_from_journal()?;
|
||||
|
||||
// Open .ecj file (deletion journal) — use ecx_actual_dir for consistency
|
||||
let ecj_base =
|
||||
crate::storage::volume::volume_file_name(&vol.ecx_actual_dir, collection, volume_id);
|
||||
let ecj_path = format!("{}.ecj", ecj_base);
|
||||
@@ -170,75 +154,11 @@ impl EcVolume {
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&ecj_path)?;
|
||||
vol.ecj_file_size = ecj_file.metadata()?.len() as i64;
|
||||
vol.ecj_file = Some(ecj_file);
|
||||
|
||||
// Seed the in-memory deleted set from the journal.
|
||||
vol.load_deleted_needles_from_ecj()?;
|
||||
|
||||
Ok(vol)
|
||||
}
|
||||
|
||||
/// Walk the .ecj journal and populate `deleted_needles`. Called once
|
||||
/// from `new()` under exclusive ownership of the just-constructed
|
||||
/// EcVolume, so locking is not strictly required — but we take the
|
||||
/// write lock anyway for symmetry with later mutations.
|
||||
fn load_deleted_needles_from_ecj(&mut self) -> io::Result<()> {
|
||||
let ecj_file = match self.ecj_file.as_ref() {
|
||||
Some(f) => f,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if self.ecj_file_size < NEEDLE_ID_SIZE as i64 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut buf = [0u8; NEEDLE_ID_SIZE];
|
||||
let mut set = self
|
||||
.deleted_needles
|
||||
.write()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::Other, "deleted_needles lock poisoned"))?;
|
||||
let mut off: i64 = 0;
|
||||
while off + NEEDLE_ID_SIZE as i64 <= self.ecj_file_size {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::FileExt;
|
||||
ecj_file.read_exact_at(&mut buf, off as u64)?;
|
||||
}
|
||||
set.insert(NeedleId::from_bytes(&buf));
|
||||
off += NEEDLE_ID_SIZE as i64;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns (file_count, delete_count) for this EC volume. Mirrors Go's
|
||||
/// `EcVolume.FileAndDeleteCount`:
|
||||
///
|
||||
/// file_count = ecx_file_size / NEEDLE_MAP_ENTRY_SIZE — total
|
||||
/// entries in the sealed sorted .ecx index.
|
||||
/// delete_count = deleted_needles.len() — unique
|
||||
/// runtime deletes tracked in memory (seeded from
|
||||
/// .ecj on load and updated by `journal_delete`).
|
||||
///
|
||||
/// Because each needle delete is applied on exactly one shard holder,
|
||||
/// the admin aggregation sums delete_count across nodes while taking
|
||||
/// file_count from a single holder (they are identical per volume).
|
||||
pub fn file_and_delete_count(&self) -> (u64, u64) {
|
||||
let file_count = (self.ecx_file_size as u64) / (NEEDLE_MAP_ENTRY_SIZE as u64);
|
||||
let delete_count = self
|
||||
.deleted_needles
|
||||
.read()
|
||||
.map(|s| s.len() as u64)
|
||||
.unwrap_or(0);
|
||||
(file_count, delete_count)
|
||||
}
|
||||
|
||||
/// Reports whether the given needle id is in the in-memory deleted set.
|
||||
pub fn is_needle_deleted(&self, needle_id: NeedleId) -> bool {
|
||||
self.deleted_needles
|
||||
.read()
|
||||
.map(|s| s.contains(&needle_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---- File names ----
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -246,16 +166,8 @@ impl EcVolume {
|
||||
crate::storage::volume::volume_file_name(&self.dir, &self.collection, self.volume_id)
|
||||
}
|
||||
|
||||
/// Base path for the .ecx / .ecj index pair. Resolved from
|
||||
/// `ecx_actual_dir` (initialized to `dir_idx` and only updated after a
|
||||
/// successful idx-dir → data-dir fallback in `new()`), so every call site
|
||||
/// agrees on the same file regardless of whether the fallback fired.
|
||||
fn idx_base_name(&self) -> String {
|
||||
crate::storage::volume::volume_file_name(
|
||||
&self.ecx_actual_dir,
|
||||
&self.collection,
|
||||
self.volume_id,
|
||||
)
|
||||
crate::storage::volume::volume_file_name(&self.dir_idx, &self.collection, self.volume_id)
|
||||
}
|
||||
|
||||
pub fn ecx_file_name(&self) -> String {
|
||||
@@ -343,8 +255,6 @@ impl EcVolume {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let (file_count, delete_count) = self.file_and_delete_count();
|
||||
|
||||
vec![master_pb::VolumeEcShardInformationMessage {
|
||||
id: self.volume_id.0,
|
||||
collection: self.collection.clone(),
|
||||
@@ -353,8 +263,6 @@ impl EcVolume {
|
||||
disk_type: self.disk_type.to_string(),
|
||||
expire_at_sec: self.expire_at_sec,
|
||||
disk_id,
|
||||
file_count,
|
||||
delete_count,
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
@@ -405,13 +313,6 @@ impl EcVolume {
|
||||
|
||||
let (key, offset, size) = idx_entry_from_bytes(&entry_buf);
|
||||
if key == needle_id {
|
||||
// Apply runtime deletion state on top of the sealed .ecx
|
||||
// lookup: a needle in the in-memory deleted set is
|
||||
// reported with TOMBSTONE_FILE_SIZE even though the .ecx
|
||||
// record itself is untouched.
|
||||
if self.is_needle_deleted(needle_id) {
|
||||
return Ok(Some((offset, TOMBSTONE_FILE_SIZE)));
|
||||
}
|
||||
return Ok(Some((offset, size)));
|
||||
} else if key < needle_id {
|
||||
lo = mid + 1;
|
||||
@@ -669,41 +570,38 @@ impl EcVolume {
|
||||
|
||||
// ---- Deletion ----
|
||||
|
||||
/// Write `TOMBSTONE_FILE_SIZE` over the Size field of an existing .ecx
|
||||
/// entry, matching Go's `MarkNeedleDeleted`. Only used by the offline
|
||||
/// `rebuild_ecx_from_journal` path — the runtime delete path does not
|
||||
/// touch .ecx because the index is treated as an immutable sorted
|
||||
/// (id, offset, size) table. Returns `false` if the needle is not in
|
||||
/// the index (ignored by callers) and an error on IO failure.
|
||||
fn tombstone_ecx_entry(&self, needle_id: NeedleId) -> io::Result<bool> {
|
||||
let ecx_file = self.ecx_file.as_ref().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"ec volume {} has no open .ecx file (closed or corrupt)",
|
||||
self.volume_id.0
|
||||
),
|
||||
)
|
||||
})?;
|
||||
/// Mark a needle as deleted in the .ecx file in-place.
|
||||
/// Matches Go's MarkNeedleDeleted: binary search the .ecx, then overwrite
|
||||
/// the size field with TOMBSTONE_FILE_SIZE.
|
||||
fn mark_needle_deleted_in_ecx(&self, needle_id: NeedleId) -> io::Result<bool> {
|
||||
let ecx_file = match self.ecx_file.as_ref() {
|
||||
Some(f) => f,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
||||
if entry_count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Binary search for the needle
|
||||
let mut lo: usize = 0;
|
||||
let mut hi: usize = entry_count;
|
||||
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
||||
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::FileExt;
|
||||
ecx_file.read_exact_at(&mut entry_buf, file_offset)?;
|
||||
}
|
||||
let (key, _offset, _old_size) = idx_entry_from_bytes(&entry_buf);
|
||||
|
||||
let (key, _offset, _size) = idx_entry_from_bytes(&entry_buf);
|
||||
if key == needle_id {
|
||||
// Found — overwrite the size field with TOMBSTONE_FILE_SIZE
|
||||
let size_offset = file_offset + NEEDLE_ID_SIZE as u64 + OFFSET_SIZE as u64;
|
||||
let mut size_buf = [0u8; SIZE_SIZE];
|
||||
TOMBSTONE_FILE_SIZE.to_bytes(&mut size_buf);
|
||||
@@ -719,18 +617,13 @@ impl EcVolume {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
|
||||
Ok(false) // not found
|
||||
}
|
||||
|
||||
/// Replay .ecj journal entries into .ecx: for each needle id in .ecj,
|
||||
/// overwrite its .ecx size field with a tombstone, then remove the
|
||||
/// journal file. Mirrors Go's `RebuildEcxFile`, which is invoked from
|
||||
/// specific decode / rebuild gRPC handlers — it is intentionally
|
||||
/// **not** called on volume load (runtime reads consult
|
||||
/// `deleted_needles` instead). The rebuild is atomic with respect to
|
||||
/// the journal: if any individual write fails the .ecj file is left
|
||||
/// in place and the error is propagated so tombstones are not lost.
|
||||
#[allow(dead_code)]
|
||||
/// Replay .ecj journal entries into .ecx on startup.
|
||||
/// Matches Go's RebuildEcxFile: for each needle ID in .ecj, marks it
|
||||
/// deleted in .ecx, then removes the .ecj file.
|
||||
fn rebuild_ecx_from_journal(&mut self) -> io::Result<()> {
|
||||
let ecj_path = self.ecj_file_name();
|
||||
if !std::path::Path::new(&ecj_path).exists() {
|
||||
@@ -749,23 +642,14 @@ impl EcVolume {
|
||||
break;
|
||||
}
|
||||
let needle_id = NeedleId::from_bytes(&data[start..start + NEEDLE_ID_SIZE]);
|
||||
// A needle that never made it into .ecx is fine (e.g. the
|
||||
// delete raced against encode). Any other IO error aborts the
|
||||
// rebuild so the journal survives to be retried later.
|
||||
self.tombstone_ecx_entry(needle_id)?;
|
||||
// Errors for individual entries are non-fatal (needle may not exist in .ecx)
|
||||
let _ = self.mark_needle_deleted_in_ecx(needle_id);
|
||||
}
|
||||
|
||||
// Durably flush the newly-written .ecx tombstones before dropping
|
||||
// the journal: the writes went through write_all_at and may still
|
||||
// be in page cache.
|
||||
if let Some(ref ecx_file) = self.ecx_file {
|
||||
ecx_file.sync_all()?;
|
||||
}
|
||||
// Remove the .ecj file after replay (matches Go)
|
||||
let _ = fs::remove_file(&ecj_path);
|
||||
|
||||
// Fold successful — drop and recreate the journal, clear the
|
||||
// in-memory deleted set (all of its contents are now materialized
|
||||
// in .ecx), and reset the cached size.
|
||||
fs::remove_file(&ecj_path)?;
|
||||
// Re-create .ecj for future deletions
|
||||
let ecj_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
@@ -773,126 +657,27 @@ impl EcVolume {
|
||||
.append(true)
|
||||
.open(&ecj_path)?;
|
||||
self.ecj_file = Some(ecj_file);
|
||||
self.ecj_file_size = 0;
|
||||
if let Ok(mut set) = self.deleted_needles.write() {
|
||||
set.clear();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Deletion journal ----
|
||||
|
||||
/// Record a needle delete: append the id to the .ecj deletion journal
|
||||
/// and insert it into the in-memory deleted set. `.ecx` is not touched
|
||||
/// at runtime — it is a sealed sorted (id, offset, size) index and
|
||||
/// runtime deletion state lives exclusively in .ecj + `deleted_needles`.
|
||||
/// A lookup via `find_needle_from_ecx` masks the id out by returning
|
||||
/// `TOMBSTONE_FILE_SIZE` on a subsequent read.
|
||||
///
|
||||
/// The .ecj append is the durable commit point. On any failure the
|
||||
/// file is truncated back to the pre-append length so the on-disk
|
||||
/// journal and in-memory state cannot drift. Only after the sync
|
||||
/// succeeds is the id published into the set, so a failure leaves
|
||||
/// the delete invisible to readers.
|
||||
/// Append a deleted needle ID to the .ecj journal and mark in .ecx.
|
||||
/// Matches Go's DeleteNeedleFromEcx: marks in .ecx first, then journals.
|
||||
pub fn journal_delete(&mut self, needle_id: NeedleId) -> io::Result<()> {
|
||||
// Look the needle up read-only. Missing is a silent no-op; a
|
||||
// pre-existing .ecx tombstone (from a prior decode/rebuild) is
|
||||
// mirrored into the in-memory set so delete_count stays accurate
|
||||
// without needing to walk .ecx on every heartbeat.
|
||||
match self.find_needle_from_ecx_raw(needle_id)? {
|
||||
None => return Ok(()),
|
||||
Some((_, size)) if size.is_deleted() => {
|
||||
if let Ok(mut set) = self.deleted_needles.write() {
|
||||
set.insert(needle_id);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
// Mark deleted in .ecx in-place (matches Go's MarkNeedleDeleted)
|
||||
let _ = self.mark_needle_deleted_in_ecx(needle_id);
|
||||
let ecj_file = self
|
||||
.ecj_file
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "ecj file not open"))?;
|
||||
|
||||
// Idempotent fast path for repeat deletes — avoids the journal
|
||||
// append entirely so the derived delete_count stays stable.
|
||||
if self.is_needle_deleted(needle_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prev_ecj_size = self.ecj_file_size;
|
||||
let append_result: io::Result<()> = {
|
||||
let ecj_file = self
|
||||
.ecj_file
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "ecj file not open"))?;
|
||||
let mut buf = [0u8; NEEDLE_ID_SIZE];
|
||||
needle_id.to_bytes(&mut buf);
|
||||
ecj_file
|
||||
.write_all(&buf)
|
||||
.and_then(|_| ecj_file.sync_all())
|
||||
};
|
||||
|
||||
match append_result {
|
||||
Ok(()) => {
|
||||
self.ecj_file_size += NEEDLE_ID_SIZE as i64;
|
||||
if let Ok(mut set) = self.deleted_needles.write() {
|
||||
set.insert(needle_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// write_all may have extended the file on disk before
|
||||
// sync_all failed; truncate back to the known-good size so
|
||||
// the on-disk journal never drifts past `deleted_needles`.
|
||||
if let Some(ecj) = self.ecj_file.as_mut() {
|
||||
if let Err(trunc_err) = ecj.set_len(prev_ecj_size as u64) {
|
||||
tracing::error!(
|
||||
volume_id = self.volume_id.0,
|
||||
needle_id = needle_id.0,
|
||||
truncate_error = %trunc_err,
|
||||
"failed to truncate ecj after append failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: binary search .ecx without masking by `deleted_needles`.
|
||||
/// Used by `journal_delete` so a repeat delete can still see the raw
|
||||
/// pre-existing .ecx tombstone from a prior rebuild.
|
||||
fn find_needle_from_ecx_raw(
|
||||
&self,
|
||||
needle_id: NeedleId,
|
||||
) -> io::Result<Option<(Offset, Size)>> {
|
||||
let ecx_file = self
|
||||
.ecx_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "ecx file not open"))?;
|
||||
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
||||
if entry_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut lo: usize = 0;
|
||||
let mut hi: usize = entry_count;
|
||||
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::FileExt;
|
||||
ecx_file.read_exact_at(&mut entry_buf, file_offset)?;
|
||||
}
|
||||
let (key, offset, size) = idx_entry_from_bytes(&entry_buf);
|
||||
if key == needle_id {
|
||||
return Ok(Some((offset, size)));
|
||||
} else if key < needle_id {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
let mut buf = [0u8; NEEDLE_ID_SIZE];
|
||||
needle_id.to_bytes(&mut buf);
|
||||
ecj_file.write_all(&buf)?;
|
||||
ecj_file.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a deleted needle ID to the .ecj journal, validating the cookie first.
|
||||
@@ -993,17 +778,15 @@ impl EcVolume {
|
||||
let _ = fs::remove_file(format!("{}.ecx", actual_base));
|
||||
let _ = fs::remove_file(format!("{}.ecj", actual_base));
|
||||
let _ = fs::remove_file(format!("{}.vif", actual_base));
|
||||
// Also sweep the originally-configured idx dir in case stale files
|
||||
// exist there (ecx_file_name() / ecj_file_name() now resolve from
|
||||
// ecx_actual_dir, so we have to build the idx-dir paths explicitly).
|
||||
// Also try the configured idx dir and data dir in case files exist in either
|
||||
if self.ecx_actual_dir != self.dir_idx {
|
||||
let _ = fs::remove_file(self.ecx_file_name());
|
||||
let _ = fs::remove_file(self.ecj_file_name());
|
||||
let idx_base = crate::storage::volume::volume_file_name(
|
||||
&self.dir_idx,
|
||||
&self.collection,
|
||||
self.volume_id,
|
||||
);
|
||||
let _ = fs::remove_file(format!("{}.ecx", idx_base));
|
||||
let _ = fs::remove_file(format!("{}.ecj", idx_base));
|
||||
let _ = fs::remove_file(format!("{}.vif", idx_base));
|
||||
}
|
||||
if self.ecx_actual_dir != self.dir && self.dir_idx != self.dir {
|
||||
@@ -1076,33 +859,16 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
|
||||
// .ecj append is gated on a live->tombstone transition in .ecx, so
|
||||
// the fixture must contain the needles we are about to delete.
|
||||
let entries = vec![
|
||||
(NeedleId(10), Offset::from_actual_offset(8), Size(100)),
|
||||
(NeedleId(20), Offset::from_actual_offset(200), Size(200)),
|
||||
];
|
||||
write_ecx_file(dir, "", VolumeId(1), &entries);
|
||||
// Need ecx file for EcVolume::new to succeed
|
||||
write_ecx_file(dir, "", VolumeId(1), &[]);
|
||||
|
||||
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
||||
let (fc0, dc0) = vol.file_and_delete_count();
|
||||
assert_eq!((fc0, dc0), (2, 0));
|
||||
|
||||
vol.journal_delete(NeedleId(10)).unwrap();
|
||||
vol.journal_delete(NeedleId(20)).unwrap();
|
||||
|
||||
let deleted = vol.read_deleted_needles().unwrap();
|
||||
assert_eq!(deleted, vec![NeedleId(10), NeedleId(20)]);
|
||||
|
||||
let (fc, dc) = vol.file_and_delete_count();
|
||||
assert_eq!((fc, dc), (2, 2));
|
||||
|
||||
// Idempotent re-delete must not bump delete_count.
|
||||
vol.journal_delete(NeedleId(10)).unwrap();
|
||||
// Deleting a missing needle must not bump delete_count either.
|
||||
vol.journal_delete(NeedleId(999)).unwrap();
|
||||
let (fc, dc) = vol.file_and_delete_count();
|
||||
assert_eq!((fc, dc), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,6 @@ use compact_map::CompactMap;
|
||||
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition};
|
||||
|
||||
use crate::storage::idx;
|
||||
use crate::storage::needle::needle::get_actual_size;
|
||||
use crate::storage::types::*;
|
||||
|
||||
// ============================================================================
|
||||
@@ -64,11 +63,6 @@ pub struct NeedleMapMetric {
|
||||
pub deletion_count: AtomicI64,
|
||||
pub deletion_byte_count: AtomicU64,
|
||||
pub max_file_key: AtomicU64,
|
||||
/// Largest (offset.to_actual_offset() + get_actual_size(size, version))
|
||||
/// observed during the load walk. Used at volume load to verify that no
|
||||
/// .idx entry references bytes past the end of .dat (issue #8928)
|
||||
/// without paying for a second linear scan.
|
||||
pub max_needle_end: AtomicI64,
|
||||
}
|
||||
|
||||
impl NeedleMapMetric {
|
||||
@@ -114,29 +108,6 @@ impl NeedleMapMetric {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `max_needle_end` if this entry's (offset + actual size) exceeds
|
||||
/// the running maximum. Skips deleted/zero-offset entries because they
|
||||
/// don't reserve space in .dat.
|
||||
fn maybe_set_max_needle_end(&self, offset: Offset, size: Size, version: Version) {
|
||||
if offset.is_zero() || !size.is_valid() {
|
||||
return;
|
||||
}
|
||||
let end = offset.to_actual_offset() + get_actual_size(size, version);
|
||||
loop {
|
||||
let current = self.max_needle_end.load(Ordering::Relaxed);
|
||||
if end <= current {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.max_needle_end
|
||||
.compare_exchange(current, end, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -192,10 +163,9 @@ impl CompactNeedleMap {
|
||||
}
|
||||
|
||||
/// Load from an .idx file, building the in-memory map.
|
||||
pub fn load_from_idx<R: Read + Seek>(reader: &mut R, version: Version) -> io::Result<Self> {
|
||||
pub fn load_from_idx<R: Read + Seek>(reader: &mut R) -> io::Result<Self> {
|
||||
let mut nm = CompactNeedleMap::new();
|
||||
idx::walk_index_file(reader, 0, |key, offset, size| {
|
||||
nm.metric.maybe_set_max_needle_end(offset, size, version);
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
nm.delete_from_map(key);
|
||||
} else {
|
||||
@@ -314,12 +284,6 @@ impl CompactNeedleMap {
|
||||
NeedleId(self.metric.max_file_key.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Largest (offset + actual size) seen during the load walk; 0 if the
|
||||
/// map is empty. See `NeedleMapMetric::maybe_set_max_needle_end`.
|
||||
pub fn max_needle_end(&self) -> i64 {
|
||||
self.metric.max_needle_end.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn index_file_size(&self) -> u64 {
|
||||
self.idx_file_offset
|
||||
}
|
||||
@@ -467,7 +431,7 @@ impl RedbNeedleMap {
|
||||
|
||||
/// Rebuild metrics by scanning all entries in the redb table.
|
||||
/// Called when reusing an existing .rdb without a full rebuild.
|
||||
fn rebuild_metrics_from_db(&self, version: Version) -> io::Result<()> {
|
||||
fn rebuild_metrics_from_db(&self) -> io::Result<()> {
|
||||
let txn = self
|
||||
.db
|
||||
.begin_read()
|
||||
@@ -489,8 +453,6 @@ impl RedbNeedleMap {
|
||||
arr.copy_from_slice(bytes);
|
||||
let nv = unpack_needle_value(&arr);
|
||||
self.metric.maybe_set_max_file_key(key);
|
||||
self.metric
|
||||
.maybe_set_max_needle_end(nv.offset, nv.size, version);
|
||||
if nv.size.is_valid() {
|
||||
self.metric.file_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.metric
|
||||
@@ -515,24 +477,20 @@ impl RedbNeedleMap {
|
||||
/// 2. If .idx size matches → reuse .rdb, rebuild metrics from scan
|
||||
/// 3. If .idx is larger → replay new entries incrementally
|
||||
/// 4. Otherwise (missing, corrupted, .idx smaller) → full rebuild
|
||||
pub fn load_from_idx<R: Read + Seek>(
|
||||
db_path: &str,
|
||||
reader: &mut R,
|
||||
version: Version,
|
||||
) -> io::Result<Self> {
|
||||
pub fn load_from_idx<R: Read + Seek>(db_path: &str, reader: &mut R) -> io::Result<Self> {
|
||||
let idx_size = reader.seek(io::SeekFrom::End(0))?;
|
||||
reader.seek(io::SeekFrom::Start(0))?;
|
||||
|
||||
// Try to reuse existing .rdb
|
||||
if Path::new(db_path).exists() {
|
||||
if let Ok(nm) = Self::try_reuse_rdb(db_path, reader, idx_size, version) {
|
||||
if let Ok(nm) = Self::try_reuse_rdb(db_path, reader, idx_size) {
|
||||
return Ok(nm);
|
||||
}
|
||||
// Reuse failed — fall through to full rebuild
|
||||
reader.seek(io::SeekFrom::Start(0))?;
|
||||
}
|
||||
|
||||
Self::full_rebuild(db_path, reader, idx_size, version)
|
||||
Self::full_rebuild(db_path, reader, idx_size)
|
||||
}
|
||||
|
||||
/// Try to reuse an existing .rdb file. Returns Ok if successful,
|
||||
@@ -541,7 +499,6 @@ impl RedbNeedleMap {
|
||||
db_path: &str,
|
||||
reader: &mut R,
|
||||
idx_size: u64,
|
||||
version: Version,
|
||||
) -> io::Result<Self> {
|
||||
let db = Database::open(db_path)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb open: {}", e)))?;
|
||||
@@ -566,7 +523,7 @@ impl RedbNeedleMap {
|
||||
}
|
||||
|
||||
// Rebuild metrics from existing data
|
||||
nm.rebuild_metrics_from_db(version)?;
|
||||
nm.rebuild_metrics_from_db()?;
|
||||
|
||||
if stored_idx_size < idx_size {
|
||||
// .idx grew — replay new entries incrementally
|
||||
@@ -577,7 +534,6 @@ impl RedbNeedleMap {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb open_table: {}", e))
|
||||
})?;
|
||||
idx::walk_index_file(reader, start_entry, |key, offset, size| {
|
||||
nm.metric.maybe_set_max_needle_end(offset, size, version);
|
||||
let key_u64: u64 = key.into();
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
// Delete: look up old value for metric update, then
|
||||
@@ -651,7 +607,6 @@ impl RedbNeedleMap {
|
||||
db_path: &str,
|
||||
reader: &mut R,
|
||||
idx_size: u64,
|
||||
version: Version,
|
||||
) -> io::Result<Self> {
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
let nm = RedbNeedleMap::new(db_path)?;
|
||||
@@ -659,7 +614,6 @@ impl RedbNeedleMap {
|
||||
// Collect entries from idx file, resolving duplicates/deletions
|
||||
let mut entries: HashMap<NeedleId, Option<NeedleValue>> = HashMap::new();
|
||||
idx::walk_index_file(reader, 0, |key, offset, size| {
|
||||
nm.metric.maybe_set_max_needle_end(offset, size, version);
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
entries.insert(key, None);
|
||||
} else {
|
||||
@@ -834,12 +788,6 @@ impl RedbNeedleMap {
|
||||
NeedleId(self.metric.max_file_key.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Largest (offset + actual size) seen during the load walk; 0 if the
|
||||
/// map is empty. See `NeedleMapMetric::maybe_set_max_needle_end`.
|
||||
pub fn max_needle_end(&self) -> i64 {
|
||||
self.metric.max_needle_end.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn index_file_size(&self) -> u64 {
|
||||
self.idx_file_offset
|
||||
}
|
||||
@@ -1040,16 +988,6 @@ impl NeedleMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest (offset + actual size) seen during the load walk; 0 if the
|
||||
/// map is empty. Used at volume load to detect .idx entries that
|
||||
/// reference past the end of .dat (issue #8928) without a second scan.
|
||||
pub fn max_needle_end(&self) -> i64 {
|
||||
match self {
|
||||
NeedleMap::InMemory(nm) => nm.max_needle_end(),
|
||||
NeedleMap::Redb(nm) => nm.max_needle_end(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index file size in bytes.
|
||||
pub fn index_file_size(&self) -> u64 {
|
||||
match self {
|
||||
@@ -1219,7 +1157,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let mut cursor = Cursor::new(idx_data);
|
||||
let nm = CompactNeedleMap::load_from_idx(&mut cursor, Version::current()).unwrap();
|
||||
let nm = CompactNeedleMap::load_from_idx(&mut cursor).unwrap();
|
||||
|
||||
assert!(nm.get(NeedleId(1)).is_some());
|
||||
assert!(nm.get(NeedleId(2)).is_none()); // deleted
|
||||
@@ -1362,7 +1300,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let mut cursor = Cursor::new(idx_data);
|
||||
let nm = RedbNeedleMap::load_from_idx(db_path.to_str().unwrap(), &mut cursor, Version::current()).unwrap();
|
||||
let nm = RedbNeedleMap::load_from_idx(db_path.to_str().unwrap(), &mut cursor).unwrap();
|
||||
|
||||
assert!(nm.get(NeedleId(1)).is_some());
|
||||
assert!(nm.get(NeedleId(2)).is_none()); // deleted and removed
|
||||
@@ -1439,7 +1377,7 @@ mod tests {
|
||||
|
||||
// Load back with CompactNeedleMap to verify
|
||||
let mut idx_file = std::fs::File::open(&idx_path).unwrap();
|
||||
let loaded = CompactNeedleMap::load_from_idx(&mut idx_file, Version::current()).unwrap();
|
||||
let loaded = CompactNeedleMap::load_from_idx(&mut idx_file).unwrap();
|
||||
assert_eq!(loaded.file_count(), 2); // only live entries
|
||||
assert!(loaded.get(NeedleId(1)).is_some());
|
||||
assert!(loaded.get(NeedleId(2)).is_none()); // deleted, not saved
|
||||
|
||||
@@ -19,8 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::storage::idx;
|
||||
use crate::storage::needle::needle::{self, get_actual_size, Needle, NeedleError};
|
||||
use crate::storage::needle_map::{CompactNeedleMap, NeedleMap, NeedleMapKind, RedbNeedleMap};
|
||||
use crate::storage::super_block::{ReplicaPlacement, SuperBlock, SUPER_BLOCK_SIZE};
|
||||
@@ -75,24 +73,6 @@ pub enum VolumeError {
|
||||
StreamingUnsupported,
|
||||
}
|
||||
|
||||
/// Returns true when a needle read failed because the on-disk bytes are
|
||||
/// unreadable in a permanent way (offset past EOF, header corruption, CRC
|
||||
/// mismatch, malformed v2/v3/v4 fields). Vacuum can safely drop such entries
|
||||
/// during compaction. Anything else (real disk EIO, ERROR_CRC on Windows,
|
||||
/// network timeouts, EROFS, etc.) is transient or environmental and must
|
||||
/// abort the compaction so an operator notices.
|
||||
fn is_skippable_needle_read_error(e: &VolumeError) -> bool {
|
||||
match e {
|
||||
VolumeError::Io(io_err) => io_err.kind() == io::ErrorKind::UnexpectedEof,
|
||||
VolumeError::Needle(NeedleError::SizeMismatch { .. }) => true,
|
||||
VolumeError::Needle(NeedleError::CrcMismatch { .. }) => true,
|
||||
VolumeError::Needle(NeedleError::IndexOutOfRange(_)) => true,
|
||||
VolumeError::Needle(NeedleError::TailTooShort) => true,
|
||||
VolumeError::SizeMismatch => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VolumeInfo (.vif persistence)
|
||||
// ============================================================================
|
||||
@@ -732,28 +712,6 @@ impl Volume {
|
||||
"volumeDataIntegrityChecking failed"
|
||||
);
|
||||
}
|
||||
|
||||
// Structural check: no .idx entry may reference bytes past the
|
||||
// end of .dat. The needle map's load walk above already
|
||||
// populated max_needle_end, so this is a numeric comparison
|
||||
// — no extra disk I/O. A violation marks the volume read-only
|
||||
// so vacuum doesn't silently drop reachable data based on a
|
||||
// corrupt .idx left over from a crashed batched write.
|
||||
// See issue #8928.
|
||||
if let Some(ref nm) = self.nm {
|
||||
if let Ok(dat_size) = self.current_dat_file_size() {
|
||||
let max_end = nm.max_needle_end();
|
||||
if dat_size > 0 && max_end > dat_size as i64 {
|
||||
self.no_write_or_delete = true;
|
||||
warn!(
|
||||
volume_id = self.id.0,
|
||||
max_needle_end = max_end,
|
||||
dat_size,
|
||||
"idx references bytes past end of .dat; marking volume read-only"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +759,7 @@ impl Volume {
|
||||
// Open read-only
|
||||
if Path::new(&idx_path).exists() {
|
||||
let mut idx_file = File::open(&idx_path)?;
|
||||
let nm = CompactNeedleMap::load_from_idx(&mut idx_file, self.version())?;
|
||||
let nm = CompactNeedleMap::load_from_idx(&mut idx_file)?;
|
||||
self.nm = Some(NeedleMap::InMemory(nm));
|
||||
} else {
|
||||
// Missing .idx with existing .dat could orphan needles
|
||||
@@ -827,7 +785,7 @@ impl Volume {
|
||||
|
||||
let idx_size = idx_file.metadata()?.len();
|
||||
let mut idx_reader = io::BufReader::new(&idx_file);
|
||||
let mut nm = CompactNeedleMap::load_from_idx(&mut idx_reader, self.version())?;
|
||||
let mut nm = CompactNeedleMap::load_from_idx(&mut idx_reader)?;
|
||||
|
||||
// Re-open for append-only writes
|
||||
let write_file = OpenOptions::new()
|
||||
@@ -850,7 +808,7 @@ impl Volume {
|
||||
// Open read-only
|
||||
if Path::new(&idx_path).exists() {
|
||||
let mut idx_file = File::open(&idx_path)?;
|
||||
let nm = RedbNeedleMap::load_from_idx(&rdb_path, &mut idx_file, self.version())?;
|
||||
let nm = RedbNeedleMap::load_from_idx(&rdb_path, &mut idx_file)?;
|
||||
self.nm = Some(NeedleMap::Redb(nm));
|
||||
} else {
|
||||
// Missing .idx with existing .dat could orphan needles
|
||||
@@ -876,7 +834,7 @@ impl Volume {
|
||||
|
||||
let idx_size = idx_file.metadata()?.len();
|
||||
let mut idx_reader = io::BufReader::new(&idx_file);
|
||||
let mut nm = RedbNeedleMap::load_from_idx(&rdb_path, &mut idx_reader, self.version())?;
|
||||
let mut nm = RedbNeedleMap::load_from_idx(&rdb_path, &mut idx_reader)?;
|
||||
|
||||
// Re-open for append-only writes
|
||||
let write_file = OpenOptions::new()
|
||||
@@ -1794,11 +1752,6 @@ impl Volume {
|
||||
|
||||
let version = self.version();
|
||||
|
||||
// The deeper-than-tail structural check (every (offset + actual size)
|
||||
// fits inside .dat — issue #8928) is now handled in load() via the
|
||||
// needle map's max_needle_end accumulator, so we don't pay for a
|
||||
// second linear scan of the .idx here.
|
||||
|
||||
// Check last 10 index entries (matching Go's CheckVolumeDataIntegrity).
|
||||
// Go starts healthyIndexSize = indexSize and reduces on EOF.
|
||||
// On success: break (err != ErrorSizeMismatch when err == nil).
|
||||
@@ -2730,8 +2683,6 @@ impl Volume {
|
||||
}
|
||||
entries.sort_by_key(|(_, offset, _)| *offset);
|
||||
|
||||
let mut skipped_needles: u64 = 0;
|
||||
let mut skipped_data_bytes: u64 = 0;
|
||||
for (id, offset, size) in entries {
|
||||
// Progress callback
|
||||
if !progress_fn(offset.to_actual_offset()) {
|
||||
@@ -2748,41 +2699,7 @@ impl Volume {
|
||||
id,
|
||||
..Needle::default()
|
||||
};
|
||||
match self.read_needle_data_at(&mut n, offset.to_actual_offset(), size) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Record EIO for health monitoring (parity with Go's checkReadWriteError).
|
||||
if let VolumeError::Io(ref io_err) = e {
|
||||
self.check_read_write_error(Some(io_err));
|
||||
}
|
||||
// Only drop the entry when the failure is one of the well-
|
||||
// known permanent-corruption shapes. A transient disk fault,
|
||||
// a tiered-read timeout, or a Windows hardware error (which
|
||||
// surfaces as a generic Io rather than UnexpectedEof) must
|
||||
// abort so an operator notices, rather than silently
|
||||
// compacting away data that might come back on retry.
|
||||
// See issue #8928.
|
||||
if !is_skippable_needle_read_error(&e) {
|
||||
return Err(VolumeError::Io(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("cannot hydrate needle from file: {}", e),
|
||||
)));
|
||||
}
|
||||
skipped_needles += 1;
|
||||
if size.is_valid() {
|
||||
skipped_data_bytes += size.0 as u64;
|
||||
}
|
||||
warn!(
|
||||
volume_id = self.id.0,
|
||||
key = id.0,
|
||||
offset = offset.to_actual_offset(),
|
||||
size = size.0,
|
||||
error = %e,
|
||||
"vacuum: dropping unreadable needle"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.read_needle_data_at(&mut n, offset.to_actual_offset(), size)?;
|
||||
|
||||
// Skip TTL-expired needles using the volume's TTL (matches Go's volume_vacuum.go)
|
||||
if n.has_ttl() {
|
||||
@@ -2804,15 +2721,6 @@ impl Volume {
|
||||
new_offset += bytes.len() as i64;
|
||||
}
|
||||
|
||||
if skipped_needles > 0 {
|
||||
warn!(
|
||||
volume_id = self.id.0,
|
||||
skipped_needles,
|
||||
skipped_data_bytes,
|
||||
"vacuum: dropped unreadable index entries during compaction"
|
||||
);
|
||||
}
|
||||
|
||||
dst.sync_all()?;
|
||||
|
||||
// Save new index
|
||||
@@ -3802,138 +3710,6 @@ mod tests {
|
||||
v.cleanup_compact().unwrap();
|
||||
}
|
||||
|
||||
/// Vacuum compaction must tolerate an .idx entry whose offset points past
|
||||
/// the end of the .dat file (the failure mode in issue #8928). The bad
|
||||
/// entry is silently dropped from the resulting .cpx; healthy needles
|
||||
/// survive untouched.
|
||||
#[test]
|
||||
fn test_compact_by_index_drops_dangling_needle() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let mut v = make_test_volume(dir);
|
||||
|
||||
// Write a handful of healthy needles to establish a baseline .dat/.idx.
|
||||
for i in 1..=5u64 {
|
||||
let mut n = Needle {
|
||||
id: NeedleId(i),
|
||||
cookie: Cookie(i as u32),
|
||||
data: format!("payload-{}", i).into_bytes(),
|
||||
data_size: format!("payload-{}", i).len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
v.write_needle(&mut n, true).unwrap();
|
||||
}
|
||||
v.sync_to_disk().unwrap();
|
||||
|
||||
let dat_size = v.dat_file_size().unwrap();
|
||||
let bad_key = NeedleId(9999);
|
||||
let bad_offset = Offset::from_actual_offset((dat_size + 1024 * 1024) as i64);
|
||||
let bad_size = Size(2048);
|
||||
v.nm
|
||||
.as_mut()
|
||||
.expect("needle map present")
|
||||
.put(bad_key, bad_offset, bad_size)
|
||||
.unwrap();
|
||||
v.nm.as_ref().unwrap().sync().unwrap();
|
||||
|
||||
// Vacuum must succeed in spite of the dangling entry.
|
||||
v.compact_by_index(0, 0, |_| true)
|
||||
.expect("compact_by_index should tolerate dangling entries");
|
||||
|
||||
// Walk the resulting .cpx and confirm the bad key was dropped while
|
||||
// every healthy key made it through.
|
||||
let cpx_path = v.file_name(".cpx");
|
||||
let mut cpx = File::open(&cpx_path).unwrap();
|
||||
let mut kept: Vec<u64> = Vec::new();
|
||||
idx::walk_index_file(&mut cpx, 0, |key, _, size| {
|
||||
if !size.is_deleted() {
|
||||
kept.push(key.0);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!kept.contains(&bad_key.0),
|
||||
"dangling key {} should have been dropped from .cpx, got {:?}",
|
||||
bad_key.0,
|
||||
kept
|
||||
);
|
||||
for i in 1..=5u64 {
|
||||
assert!(
|
||||
kept.contains(&i),
|
||||
"healthy key {} missing from compacted .cpx, got {:?}",
|
||||
i,
|
||||
kept
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The needle map's max_needle_end accumulator must let volume.load
|
||||
/// detect an .idx whose entries point past the end of the .dat — the
|
||||
/// deeper-than-tail corruption shape from issue #8928 that the existing
|
||||
/// last-10-entries scan cannot see. The check is populated by the load
|
||||
/// walk and read in volume.load() to flip the volume read-only.
|
||||
#[test]
|
||||
fn test_max_needle_end_detects_dangling_entry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let mut v = make_test_volume(dir);
|
||||
|
||||
for i in 1..=4u64 {
|
||||
let mut n = Needle {
|
||||
id: NeedleId(i),
|
||||
cookie: Cookie(i as u32),
|
||||
data: format!("data-{}", i).into_bytes(),
|
||||
data_size: format!("data-{}", i).len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
v.write_needle(&mut n, true).unwrap();
|
||||
}
|
||||
v.sync_to_disk().unwrap();
|
||||
|
||||
let dat_size = v.dat_file_size().unwrap() as i64;
|
||||
let idx_path = v.file_name(".idx");
|
||||
let version = v.version();
|
||||
|
||||
// Sanity: a fresh load walk over the healthy .idx puts max_needle_end
|
||||
// somewhere inside the .dat.
|
||||
let mut idx_reader = File::open(&idx_path).unwrap();
|
||||
let healthy_nm =
|
||||
CompactNeedleMap::load_from_idx(&mut idx_reader, version).unwrap();
|
||||
let healthy_end = healthy_nm.max_needle_end();
|
||||
assert!(
|
||||
healthy_end > 0 && healthy_end <= dat_size,
|
||||
"healthy volume should have max_needle_end ({}) in [1, dat_size={}]",
|
||||
healthy_end,
|
||||
dat_size
|
||||
);
|
||||
|
||||
// Inject a dangling entry by appending a bogus 16/17-byte record
|
||||
// directly to the .idx, then reload. The load walk should observe
|
||||
// max_needle_end past dat_size — which is exactly the signal
|
||||
// volume.load uses to mark the volume read-only.
|
||||
let bad_offset = Offset::from_actual_offset(dat_size + 4 * 1024 * 1024);
|
||||
let mut idx_append = OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&idx_path)
|
||||
.unwrap();
|
||||
idx::write_index_entry(&mut idx_append, NeedleId(9999), bad_offset, Size(1024))
|
||||
.unwrap();
|
||||
idx_append.sync_all().unwrap();
|
||||
|
||||
let mut idx_reread = File::open(&idx_path).unwrap();
|
||||
let bad_nm = CompactNeedleMap::load_from_idx(&mut idx_reread, version).unwrap();
|
||||
let bad_end = bad_nm.max_needle_end();
|
||||
assert!(
|
||||
bad_end > dat_size,
|
||||
"after dangling-entry inject max_needle_end ({}) should exceed dat_size ({})",
|
||||
bad_end,
|
||||
dat_size
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compaction_revision_relookup() {
|
||||
// Verifies that re_lookup_needle_data_offset returns the correct data offset
|
||||
|
||||
+2
-2
@@ -999,9 +999,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -23,16 +22,10 @@ const (
|
||||
FilerUrl = "http://localhost:8888"
|
||||
)
|
||||
|
||||
// Helper to run commands in background and track PIDs for cleanup. Guarded
|
||||
// by runningCmdsLock so parallel subprocess startup can append safely.
|
||||
var (
|
||||
runningCmds []*exec.Cmd
|
||||
runningCmdsLock sync.Mutex
|
||||
)
|
||||
// Helper to run commands in background and track PIDs for cleanup
|
||||
var runningCmds []*exec.Cmd
|
||||
|
||||
func cleanup() {
|
||||
runningCmdsLock.Lock()
|
||||
defer runningCmdsLock.Unlock()
|
||||
for _, cmd := range runningCmds {
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
@@ -66,9 +59,7 @@ func startWeed(t *testing.T, name string, args ...string) *exec.Cmd {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start weed %v: %v", args, err)
|
||||
}
|
||||
runningCmdsLock.Lock()
|
||||
runningCmds = append(runningCmds, cmd)
|
||||
runningCmdsLock.Unlock()
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -116,23 +107,14 @@ func ensureEnvironment(t *testing.T) {
|
||||
waitForUrl(t, MasterUrl+"/cluster/status", 10)
|
||||
|
||||
// 3. Start Volume Server (Worker)
|
||||
// Start 14 volume servers to verify RS(10,4) default EC. Fork/exec in
|
||||
// parallel because startWeed is non-blocking and the per-process fork +
|
||||
// mkdir + log-file-open overhead stacks up sequentially on cold CI
|
||||
// disks, eating most of the admin /health wait budget further down.
|
||||
var volWg sync.WaitGroup
|
||||
// Start 14 volume servers to verify RS(10,4) default EC
|
||||
for i := 1; i <= 14; i++ {
|
||||
volWg.Add(1)
|
||||
go func(i int) {
|
||||
defer volWg.Done()
|
||||
volName := fmt.Sprintf("volume%d", i)
|
||||
port := 8080 + i - 1
|
||||
dir := filepath.Join("tmp", volName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
startWeed(t, volName, "volume", "-dir="+dir, "-mserver=localhost:9333", fmt.Sprintf("-port=%d", port), "-ip=localhost")
|
||||
}(i)
|
||||
volName := fmt.Sprintf("volume%d", i)
|
||||
port := 8080 + i - 1
|
||||
dir := filepath.Join("tmp", volName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
startWeed(t, volName, "volume", "-dir="+dir, "-mserver=localhost:9333", fmt.Sprintf("-port=%d", port), "-ip=localhost")
|
||||
}
|
||||
volWg.Wait()
|
||||
|
||||
// 4. Start Filer
|
||||
os.MkdirAll(filepath.Join("tmp", "filer"), 0755)
|
||||
@@ -154,12 +136,7 @@ func ensureEnvironment(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join("tmp", "admin"))
|
||||
os.MkdirAll(filepath.Join("tmp", "admin"), 0755)
|
||||
startWeed(t, "admin", "admin", "-master=localhost:9333", "-port=23646", "-dataDir=./tmp/admin")
|
||||
// Admin is started after master, 14 volume servers, filer and 2 workers,
|
||||
// so under cold CI conditions the wait here has to absorb the tail of
|
||||
// every earlier subprocess coming up. 60s is too tight and has flaked;
|
||||
// 180s gives comfortable headroom without meaningfully extending the
|
||||
// fast path (the first successful /health usually hits well under 30s).
|
||||
waitForUrl(t, AdminUrl+"/health", 180)
|
||||
waitForUrl(t, AdminUrl+"/health", 60)
|
||||
|
||||
t.Log("Environment started successfully")
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ type dlmTestCluster struct {
|
||||
filerGrpcPorts [2]int
|
||||
mountPoints [2]string
|
||||
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
filerCmds [2]*exec.Cmd
|
||||
mountCmds [2]*exec.Cmd
|
||||
logFiles []*os.File
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
filerCmds [2]*exec.Cmd
|
||||
mountCmds [2]*exec.Cmd
|
||||
logFiles []*os.File
|
||||
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -84,14 +84,11 @@ func NewFuseTestFramework(t *testing.T, config *TestConfig) *FuseTestFramework {
|
||||
|
||||
// freePort asks the OS for a free TCP port in a range where the gRPC
|
||||
// offset (port + 10000) won't collide with well-known ports.
|
||||
// Stay below the Linux ephemeral floor (32768) so the kernel does not
|
||||
// reuse the chosen port for an outbound connection between close() here
|
||||
// and re-bind in the child "weed mini" process.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
const (
|
||||
minServicePort = 20000
|
||||
maxServicePort = 32000
|
||||
maxServicePort = 55535
|
||||
)
|
||||
|
||||
portCount := maxServicePort - minServicePort + 1
|
||||
@@ -250,7 +247,6 @@ func (f *FuseTestFramework) startMini(config *TestConfig) error {
|
||||
"mini",
|
||||
"-dir=" + f.dataDir,
|
||||
"-ip=127.0.0.1",
|
||||
"-ip.bind=127.0.0.1",
|
||||
"-filer.port=" + strconv.Itoa(f.filerPort),
|
||||
"-s3=false",
|
||||
"-webdav=false",
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
package fuse_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mountDebugPort holds the debug/pprof port the test passed to the
|
||||
// mount process via -debug.port. It is set once at TestWriteBufferCap
|
||||
// entry and consulted from the write-timeout paths to fetch the mount
|
||||
// process's goroutine dump (the test's own dumpAllGoroutines only
|
||||
// covers the test process).
|
||||
var mountDebugPort int
|
||||
|
||||
// fetchMountGoroutines pulls a full goroutine dump from the mount
|
||||
// process's pprof endpoint. If the mount debug port isn't configured
|
||||
// or the HTTP call fails, a short explanation is returned instead of
|
||||
// an error — this is diagnostic best-effort, not a test assertion.
|
||||
func fetchMountGoroutines() string {
|
||||
if mountDebugPort == 0 {
|
||||
return "(mount debug port not configured)"
|
||||
}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/debug/pprof/goroutine?debug=2", mountDebugPort)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(failed to reach mount pprof at %s: %v)", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(failed to read mount pprof body: %v)", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// dumpAllGoroutines returns a full stack trace of every live goroutine.
|
||||
// Used on write-timeout to give CI actionable diagnosis if the write
|
||||
// buffer cap ever re-regresses into a hang.
|
||||
func dumpAllGoroutines() string {
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n := runtime.Stack(buf, true)
|
||||
if n < len(buf) {
|
||||
return string(buf[:n])
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
}
|
||||
|
||||
// writeBufferCapConfig returns a TestConfig that exercises the new
|
||||
// -writeBufferSizeMB flag. The cap is set below the aggregate in-flight
|
||||
// write demand of the subtests below, so every new chunk has to pass
|
||||
// through the Reserve/Release backpressure path at least some of the
|
||||
// time. The cap is intentionally NOT minimal — over-tight settings
|
||||
// interact with the per-file writable-chunk limit and the FUSE MaxWrite
|
||||
// batching in ways that starve single-handle writers on slow CI.
|
||||
//
|
||||
// Also enables the mount's pprof debug endpoint so the test can fetch
|
||||
// mount-process goroutine dumps on write-timeout, which is the only
|
||||
// way to actually diagnose a backpressure deadlock (the test process
|
||||
// itself is just blocked in syscall.Write waiting on FUSE).
|
||||
func writeBufferCapConfig(debugPort int) *TestConfig {
|
||||
return &TestConfig{
|
||||
Collection: "",
|
||||
Replication: "000",
|
||||
ChunkSizeMB: 2, // 2 MiB chunks
|
||||
CacheSizeMB: 100, // read cache (unrelated)
|
||||
NumVolumes: 3,
|
||||
EnableDebug: false,
|
||||
MountOptions: []string{
|
||||
// 16 MiB total write buffer ⇒ up to 8 chunks in flight
|
||||
// across every open file handle on this mount. Large
|
||||
// enough to avoid starving a single handle on a slow
|
||||
// CI runner, small enough that the concurrent test
|
||||
// below still has to drain through it.
|
||||
"-writeBufferSizeMB=16",
|
||||
"-debug=true",
|
||||
fmt.Sprintf("-debug.port=%d", debugPort),
|
||||
// Route glog to stderr so the framework's process log
|
||||
// capture actually contains something — by default weed
|
||||
// sends glog to /tmp/weed.* files which the CI artifact
|
||||
// upload step never sees. Critical for diagnosing
|
||||
// upload/saveToStorage errors on Linux runs.
|
||||
"-logtostderr=true",
|
||||
"-v=2",
|
||||
},
|
||||
SkipCleanup: false,
|
||||
}
|
||||
}
|
||||
|
||||
// writeWithTimeout wraps os.WriteFile with a hard deadline so a stuck
|
||||
// write fails the test fast instead of consuming the full job budget.
|
||||
// This is belt-and-braces around the 45-minute workflow timeout and
|
||||
// makes write-buffer regressions surface as an actionable failure.
|
||||
func writeWithTimeout(t *testing.T, path string, data []byte, timeout time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- os.WriteFile(path, data, 0644) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
// Dump mount goroutines on any write error, not just
|
||||
// timeout — upload failures that surface via close()
|
||||
// as EIO leave the mount process running but in an
|
||||
// informative state (pending sealed chunks, error
|
||||
// counters, etc).
|
||||
t.Logf("write %s failed (%v) — dumping MOUNT goroutines:\n%s", path, err, fetchMountGoroutines())
|
||||
}
|
||||
require.NoError(t, err, "write %s", path)
|
||||
case <-time.After(timeout):
|
||||
t.Logf("write %s did not finish within %v — dumping TEST goroutines:\n%s", path, timeout, dumpAllGoroutines())
|
||||
t.Logf("dumping MOUNT goroutines:\n%s", fetchMountGoroutines())
|
||||
t.Fatalf("write %s timed out — write buffer cap is likely leaking or deadlocking", path)
|
||||
}
|
||||
}
|
||||
|
||||
// runSubtestWithWatchdog runs body on the current (subtest main)
|
||||
// goroutine and starts a watcher goroutine that logs diagnostics and
|
||||
// fails the test if body doesn't return within timeout.
|
||||
//
|
||||
// body must run on the main goroutine because test helpers inside it
|
||||
// (require.NoError, writeWithTimeout's own t.Fatalf on its internal
|
||||
// timeout) need t.Fatal / t.FailNow, which Go's testing docs restrict
|
||||
// to the goroutine running the test function. The watcher goroutine
|
||||
// only calls goroutine-safe t methods (t.Log, t.Logf, t.Errorf) so it
|
||||
// can mark the test failed and dump diagnostics without violating
|
||||
// that contract. If body is stuck past timeout the watcher still
|
||||
// surfaces the wedge (test + mount goroutine dumps + a FAIL mark);
|
||||
// body itself gets unblocked either by its own inner writeWithTimeout
|
||||
// firing t.Fatalf or by Go test's global -timeout.
|
||||
func runSubtestWithWatchdog(t *testing.T, timeout time.Duration, body func(t *testing.T)) {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(timeout):
|
||||
t.Logf("subtest exceeded %v watchdog — dumping TEST goroutines:\n%s", timeout, dumpAllGoroutines())
|
||||
t.Logf("dumping MOUNT goroutines:\n%s", fetchMountGoroutines())
|
||||
t.Errorf("subtest exceeded %v watchdog — see goroutine dumps above", timeout)
|
||||
}
|
||||
}()
|
||||
body(t)
|
||||
}
|
||||
|
||||
// TestWriteBufferCap exercises the end-to-end write-buffer cap on a
|
||||
// real FUSE mount. Without the cap, a volume-server stall would let
|
||||
// the swap file grow without bound (issue #8777). With the cap, writers
|
||||
// must serialize through a bounded budget while still producing correct
|
||||
// output — that correctness (and the absence of deadlocks) is what
|
||||
// this test verifies.
|
||||
//
|
||||
// Note: this test deliberately does not assert that Reserve *blocked*
|
||||
// at some observed used-byte peak. The mount runs as a subprocess so
|
||||
// its in-process WriteBufferAccountant state is not reachable from the
|
||||
// test without adding a metrics/RPC surface to the mount binary. The
|
||||
// deterministic peak-vs-cap assertion instead lives in the in-package
|
||||
// unit test TestWriteBufferCap_SharedAcrossPipelines, which drives a
|
||||
// controlled gated uploader and samples Used() throughout the run.
|
||||
func TestWriteBufferCap(t *testing.T) {
|
||||
mountDebugPort = freePort(t)
|
||||
config := writeBufferCapConfig(mountDebugPort)
|
||||
framework := NewFuseTestFramework(t, config)
|
||||
defer framework.Cleanup()
|
||||
|
||||
require.NoError(t, framework.Setup(config))
|
||||
|
||||
const subtestTimeout = 3 * time.Minute
|
||||
|
||||
t.Run("ConcurrentWritesUnderCap", func(t *testing.T) {
|
||||
runSubtestWithWatchdog(t, subtestTimeout, func(t *testing.T) {
|
||||
testConcurrentWritesUnderCap(t, framework)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("LargeFileUnderCap", func(t *testing.T) {
|
||||
runSubtestWithWatchdog(t, subtestTimeout, func(t *testing.T) {
|
||||
testLargeFileUnderCap(t, framework)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DoesNotDeadlockAfterPressure", func(t *testing.T) {
|
||||
runSubtestWithWatchdog(t, subtestTimeout, func(t *testing.T) {
|
||||
testWriteBufferNoDeadlockAfterPressure(t, framework)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// testConcurrentWritesUnderCap opens several files in parallel with
|
||||
// aggregate demand that exceeds the 16 MiB write buffer cap, then
|
||||
// verifies every byte survived the round trip.
|
||||
func testConcurrentWritesUnderCap(t *testing.T, framework *FuseTestFramework) {
|
||||
const (
|
||||
numFiles = 4
|
||||
fileSize = 8 * 1024 * 1024 // 8 MiB per file ⇒ 32 MiB total vs 16 MiB cap
|
||||
)
|
||||
|
||||
dir := "write_buffer_cap_concurrent"
|
||||
framework.CreateTestDir(dir)
|
||||
|
||||
payloads := make([][]byte, numFiles)
|
||||
for i := range payloads {
|
||||
buf := make([]byte, fileSize)
|
||||
_, err := rand.Read(buf)
|
||||
require.NoError(t, err)
|
||||
payloads[i] = buf
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, numFiles)
|
||||
timedOut := make(chan struct{}, numFiles)
|
||||
for i := 0; i < numFiles; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
name := fmt.Sprintf("file_%02d.bin", i)
|
||||
path := filepath.Join(framework.GetMountPoint(), dir, name)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- os.WriteFile(path, payloads[i], 0644) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("writer %d: %w", i, err)
|
||||
}
|
||||
case <-time.After(90 * time.Second):
|
||||
timedOut <- struct{}{}
|
||||
errs <- fmt.Errorf("writer %d: timed out after 90s", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
// If any writer timed out, dump every live goroutine so CI shows the
|
||||
// wedge instead of just a walltime.
|
||||
select {
|
||||
case <-timedOut:
|
||||
t.Logf("at least one concurrent writer timed out — dumping goroutines:\n%s", dumpAllGoroutines())
|
||||
default:
|
||||
}
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %d × %d MiB under 16 MiB cap in %v", numFiles, fileSize/(1024*1024), time.Since(start))
|
||||
|
||||
for i := 0; i < numFiles; i++ {
|
||||
name := fmt.Sprintf("file_%02d.bin", i)
|
||||
path := filepath.Join(framework.GetMountPoint(), dir, name)
|
||||
got, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "read %s", name)
|
||||
require.Equal(t, len(payloads[i]), len(got), "size mismatch for %s", name)
|
||||
if !bytes.Equal(payloads[i], got) {
|
||||
t.Fatalf("content mismatch for %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testLargeFileUnderCap writes a single file whose size exceeds the
|
||||
// 16 MiB cap through a single handle, verifying that the pipeline
|
||||
// drains its own earlier chunks and makes forward progress rather than
|
||||
// self-deadlocking when the global budget is already full of its own
|
||||
// earlier sealed chunks.
|
||||
func testLargeFileUnderCap(t *testing.T, framework *FuseTestFramework) {
|
||||
const fileSize = 20 * 1024 * 1024 // 20 MiB ⇒ 10 chunks vs 8-slot budget
|
||||
|
||||
payload := make([]byte, fileSize)
|
||||
_, err := rand.Read(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
name := "write_buffer_cap_large.bin"
|
||||
path := filepath.Join(framework.GetMountPoint(), name)
|
||||
|
||||
start := time.Now()
|
||||
writeWithTimeout(t, path, payload, 90*time.Second)
|
||||
t.Logf("wrote %d MiB through one handle under 16 MiB cap in %v", fileSize/(1024*1024), time.Since(start))
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(payload), len(got))
|
||||
if !bytes.Equal(payload, got) {
|
||||
t.Fatal("content mismatch on large single-handle write")
|
||||
}
|
||||
}
|
||||
|
||||
// testWriteBufferNoDeadlockAfterPressure verifies the mount is still
|
||||
// healthy after being driven against the cap. A budget-slot leak would
|
||||
// eventually cause every new chunk allocation to hang; a quick canary
|
||||
// write catches that as a hard failure.
|
||||
func testWriteBufferNoDeadlockAfterPressure(t *testing.T, framework *FuseTestFramework) {
|
||||
name := "write_buffer_cap_canary.txt"
|
||||
path := filepath.Join(framework.GetMountPoint(), name)
|
||||
content := []byte("write buffer cap canary — mount still healthy")
|
||||
|
||||
writeWithTimeout(t, path, content, 30*time.Second)
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, got)
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package fuse_p2p
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// p2pTestCluster manages a minimal SeaweedFS cluster exercising the peer
|
||||
// chunk-sharing (p2p) read path: 1 master, 1 volume, 1 filer, and N FUSE
|
||||
// mounts that all have -peer.enable set. Three mounts is the sweet spot
|
||||
// for integration testing — with 2 mounts the HRW owner of a chunk is
|
||||
// ~50% the reader itself (which short-circuits the peer path); with 3+
|
||||
// mounts it's ≤ 1/3, so a multi-chunk file almost certainly exercises
|
||||
// the remote-owner fan-out.
|
||||
type p2pTestCluster struct {
|
||||
t testing.TB
|
||||
baseDir string
|
||||
weedBinary string
|
||||
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
mountPeerPorts []int
|
||||
mountPoints []string
|
||||
|
||||
masterCmd *exec.Cmd
|
||||
volumeCmd *exec.Cmd
|
||||
filerCmd *exec.Cmd
|
||||
mountCmds []*exec.Cmd
|
||||
logFiles []*os.File
|
||||
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
// startP2PTestCluster brings up a cluster with numMounts FUSE mounts,
|
||||
// every one advertising on its own -peer.listen port. All mounts register
|
||||
// with the same filer. Verbose logging (-v=4) is enabled on each mount so
|
||||
// peer-read success/failure messages land in the log file and the test
|
||||
// can grep them to verify the p2p path fired.
|
||||
func startP2PTestCluster(t testing.TB, numMounts int) *p2pTestCluster {
|
||||
require.GreaterOrEqual(t, numMounts, 2, "need at least 2 mounts to exercise p2p")
|
||||
binary := findWeedBinary()
|
||||
if binary == "" {
|
||||
t.Skip("weed binary not found; set WEED_BINARY or ensure it is on PATH")
|
||||
}
|
||||
baseDir, err := os.MkdirTemp("", "seaweedfs_fuse_p2p_test_")
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &p2pTestCluster{
|
||||
t: t,
|
||||
baseDir: baseDir,
|
||||
weedBinary: binary,
|
||||
mountPeerPorts: make([]int, numMounts),
|
||||
mountPoints: make([]string, numMounts),
|
||||
mountCmds: make([]*exec.Cmd, numMounts),
|
||||
}
|
||||
t.Cleanup(c.Stop)
|
||||
|
||||
// master(2) + volume(2) + filer(2) + one peer port per mount.
|
||||
// testutil.AllocatePorts holds all listeners open until every port
|
||||
// is reserved, avoiding the brief close→bind race that would
|
||||
// happen with a per-listener close loop.
|
||||
ports, err := testutil.AllocatePorts(6 + numMounts)
|
||||
require.NoError(t, err)
|
||||
c.masterPort = ports[0]
|
||||
c.masterGrpcPort = ports[1]
|
||||
c.volumePort = ports[2]
|
||||
c.volumeGrpcPort = ports[3]
|
||||
c.filerPort = ports[4]
|
||||
c.filerGrpcPort = ports[5]
|
||||
for i := 0; i < numMounts; i++ {
|
||||
c.mountPeerPorts[i] = ports[6+i]
|
||||
}
|
||||
|
||||
configDir := filepath.Join(baseDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "security.toml"), []byte(""), 0644))
|
||||
|
||||
require.NoError(t, c.startMaster(configDir))
|
||||
require.NoError(t, c.waitForTCP(c.masterCmd, "master",
|
||||
fmt.Sprintf("127.0.0.1:%d", c.masterPort), 30*time.Second))
|
||||
|
||||
require.NoError(t, c.startVolume(configDir))
|
||||
require.NoError(t, c.waitForTCP(c.volumeCmd, "volume",
|
||||
fmt.Sprintf("127.0.0.1:%d", c.volumePort), 30*time.Second))
|
||||
|
||||
require.NoError(t, c.startFiler(configDir))
|
||||
require.NoError(t, c.waitForTCP(c.filerCmd, "filer",
|
||||
fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort), 30*time.Second))
|
||||
|
||||
for i := 0; i < numMounts; i++ {
|
||||
mp := filepath.Join(baseDir, fmt.Sprintf("mount%d", i))
|
||||
require.NoError(t, os.MkdirAll(mp, 0755))
|
||||
c.mountPoints[i] = mp
|
||||
require.NoError(t, c.startMount(i, configDir))
|
||||
require.NoError(t, c.waitForMount(mp, 30*time.Second),
|
||||
"mount %d not ready\n%s", i, c.tailLog(fmt.Sprintf("mount%d", i)))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) Stop() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cleanupOnce.Do(func() {
|
||||
for i := len(c.mountCmds) - 1; i >= 0; i-- {
|
||||
stopCmd(c.mountCmds[i])
|
||||
// Backup unmount in case the FUSE teardown didn't clean up.
|
||||
exec.Command("fusermount3", "-u", c.mountPoints[i]).Run()
|
||||
exec.Command("fusermount", "-u", c.mountPoints[i]).Run()
|
||||
}
|
||||
stopCmd(c.filerCmd)
|
||||
stopCmd(c.volumeCmd)
|
||||
stopCmd(c.masterCmd)
|
||||
|
||||
for _, f := range c.logFiles {
|
||||
f.Close()
|
||||
}
|
||||
c.copyLogsForCI()
|
||||
if !c.t.Failed() {
|
||||
os.RemoveAll(c.baseDir)
|
||||
}
|
||||
time.Sleep(2 * time.Second) // let ports drain
|
||||
})
|
||||
}
|
||||
|
||||
// MountDir returns the filesystem path of the i-th mount.
|
||||
func (c *p2pTestCluster) MountDir(i int) string { return c.mountPoints[i] }
|
||||
|
||||
// masterAddress / filerAddress return SeaweedFS-style addresses encoding
|
||||
// both ports as "host:httpPort.grpcPort". Without this, downstream
|
||||
// components fall back to the grpcPort = httpPort + 10000 default,
|
||||
// which doesn't match the random port we allocate.
|
||||
func (c *p2pTestCluster) masterAddress() string {
|
||||
return string(pb.NewServerAddress("127.0.0.1", c.masterPort, c.masterGrpcPort))
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) filerAddress() string {
|
||||
return string(pb.NewServerAddress("127.0.0.1", c.filerPort, c.filerGrpcPort))
|
||||
}
|
||||
|
||||
// MountLog returns the contents of mount i's log file.
|
||||
func (c *p2pTestCluster) MountLog(i int) string {
|
||||
return c.tailLogFull(fmt.Sprintf("mount%d", i))
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) startMaster(configDir string) error {
|
||||
c.masterCmd = exec.Command(c.weedBinary,
|
||||
"-logdir="+filepath.Join(c.baseDir, "logs"),
|
||||
"master",
|
||||
"-ip=127.0.0.1",
|
||||
"-ip.bind=127.0.0.1",
|
||||
"-port="+strconv.Itoa(c.masterPort),
|
||||
"-port.grpc="+strconv.Itoa(c.masterGrpcPort),
|
||||
"-mdir="+filepath.Join(c.baseDir, "master"),
|
||||
)
|
||||
return c.startCmd(c.masterCmd, "master")
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) startVolume(configDir string) error {
|
||||
volDir := filepath.Join(c.baseDir, "volume")
|
||||
if err := os.MkdirAll(volDir, 0755); err != nil {
|
||||
return fmt.Errorf("create volume dir: %w", err)
|
||||
}
|
||||
c.volumeCmd = exec.Command(c.weedBinary,
|
||||
"-logdir="+filepath.Join(c.baseDir, "logs"),
|
||||
"volume",
|
||||
"-ip=127.0.0.1",
|
||||
"-ip.bind=127.0.0.1",
|
||||
"-port="+strconv.Itoa(c.volumePort),
|
||||
"-port.grpc="+strconv.Itoa(c.volumeGrpcPort),
|
||||
"-master="+c.masterAddress(),
|
||||
"-dir="+volDir,
|
||||
"-max=10",
|
||||
)
|
||||
return c.startCmd(c.volumeCmd, "volume")
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) startFiler(configDir string) error {
|
||||
filerDir := filepath.Join(c.baseDir, "filer")
|
||||
if err := os.MkdirAll(filerDir, 0755); err != nil {
|
||||
return fmt.Errorf("create filer dir: %w", err)
|
||||
}
|
||||
c.filerCmd = exec.Command(c.weedBinary,
|
||||
"-logdir="+filepath.Join(c.baseDir, "logs"),
|
||||
"filer",
|
||||
"-ip=127.0.0.1",
|
||||
"-ip.bind=127.0.0.1",
|
||||
"-port="+strconv.Itoa(c.filerPort),
|
||||
"-port.grpc="+strconv.Itoa(c.filerGrpcPort),
|
||||
"-master="+c.masterAddress(),
|
||||
"-defaultStoreDir="+filerDir,
|
||||
// default is -mount.p2p=true; be explicit so the intent is grep-able.
|
||||
"-mount.p2p=true",
|
||||
)
|
||||
return c.startCmd(c.filerCmd, "filer")
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) startMount(idx int, configDir string) error {
|
||||
cacheDir := filepath.Join(c.baseDir, fmt.Sprintf("cache%d", idx))
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
c.mountCmds[idx] = exec.Command(c.weedBinary,
|
||||
"-logdir="+filepath.Join(c.baseDir, "logs"),
|
||||
"-v=4",
|
||||
"mount",
|
||||
"-filer="+c.filerAddress(),
|
||||
"-dir="+c.mountPoints[idx],
|
||||
"-filer.path=/",
|
||||
"-dirAutoCreate",
|
||||
"-allowOthers=false",
|
||||
"-cacheDir="+cacheDir,
|
||||
"-peer.enable=true",
|
||||
fmt.Sprintf("-peer.listen=127.0.0.1:%d", c.mountPeerPorts[idx]),
|
||||
fmt.Sprintf("-peer.advertise=127.0.0.1:%d", c.mountPeerPorts[idx]),
|
||||
"-peer.dataCenter=dc1",
|
||||
fmt.Sprintf("-peer.rack=rack%d", idx),
|
||||
)
|
||||
return c.startCmd(c.mountCmds[idx], fmt.Sprintf("mount%d", idx))
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) startCmd(cmd *exec.Cmd, name string) error {
|
||||
logPath := filepath.Join(c.baseDir, "logs")
|
||||
if err := os.MkdirAll(logPath, 0755); err != nil {
|
||||
return fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
logFile, err := os.Create(filepath.Join(logPath, name+".log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logFiles = append(c.logFiles, logFile)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) tailLog(name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(c.baseDir, "logs", name+".log"))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(log %s not available: %v)", name, err)
|
||||
}
|
||||
const maxTail = 8192
|
||||
if len(data) > maxTail {
|
||||
data = data[len(data)-maxTail:]
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) tailLogFull(name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(c.baseDir, "logs", name+".log"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (c *p2pTestCluster) copyLogsForCI() {
|
||||
ciLogDir := "/tmp/seaweedfs-fuse-p2p-logs"
|
||||
os.MkdirAll(ciLogDir, 0755)
|
||||
logsDir := filepath.Join(c.baseDir, "logs")
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
data, err := os.ReadFile(filepath.Join(logsDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.WriteFile(filepath.Join(ciLogDir, e.Name()), data, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForTCP polls addr until it accepts a connection, OR the supplied
|
||||
// subprocess exits — whichever comes first. Short-circuiting on child
|
||||
// exit turns a 30 s-spin-on-dead-process into an immediate failure with
|
||||
// the tail of its log. cmd may be nil for callers that don't track a
|
||||
// process; in that case we fall back to pure polling.
|
||||
//
|
||||
// Liveness is checked with signal 0 (POSIX "is this process alive").
|
||||
// We deliberately do NOT call cmd.Wait() in a goroutine here because
|
||||
// stopCmd() later calls Wait() at shutdown, and only one Wait per
|
||||
// process is allowed.
|
||||
func (c *p2pTestCluster) waitForTCP(cmd *exec.Cmd, name, addr string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
// signal 0 doesn't actually send anything; it just asks
|
||||
// the kernel whether the process exists. An error here
|
||||
// (typically "process already finished" or ESRCH) means
|
||||
// the child died before coming up.
|
||||
if sigErr := cmd.Process.Signal(syscall.Signal(0)); sigErr != nil {
|
||||
return fmt.Errorf("%s exited before listening on %s: %v\n%s",
|
||||
name, addr, sigErr, c.tailLog(name))
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("service at %s not ready within timeout\n%s", addr, c.tailLog(name))
|
||||
}
|
||||
|
||||
// waitForMount waits for a FUSE filesystem to actually be mounted by
|
||||
// watching the device id flip (FUSE mounts have a different Dev than
|
||||
// their parent dir).
|
||||
func (c *p2pTestCluster) waitForMount(mountPoint string, timeout time.Duration) error {
|
||||
parentDir := filepath.Dir(mountPoint)
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
parentStat, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
mountStat, err := os.Stat(mountPoint)
|
||||
if err != nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
parentSys := parentStat.Sys().(*syscall.Stat_t)
|
||||
mountSys := mountStat.Sys().(*syscall.Stat_t)
|
||||
if parentSys.Dev != mountSys.Dev {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("mount point %s not ready within timeout (FUSE not detected)", mountPoint)
|
||||
}
|
||||
|
||||
// --- utilities (binary discovery, process shutdown) ---
|
||||
//
|
||||
// Port allocation is delegated to testutil.MustAllocatePorts, which
|
||||
// holds all listeners open until every port is reserved before
|
||||
// returning them in a batch — safer than the per-listener
|
||||
// close-then-reserve pattern fuse_dlm originally used.
|
||||
|
||||
func findWeedBinary() string {
|
||||
if env := os.Getenv("WEED_BINARY"); env != "" {
|
||||
if _, err := os.Stat(env); err == nil {
|
||||
return env
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("weed"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stopCmd kills a child process with SIGTERM and waits up to 5 s, then
|
||||
// escalates to SIGKILL. Tolerates nil and already-exited processes.
|
||||
func stopCmd(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
cmd.Process.Signal(syscall.SIGKILL)
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package fuse_p2p
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// peerReadSuccessMarker is the log line tryPeerRead emits when a peer
|
||||
// fetch succeeded. The test greps non-writer mount logs for it to
|
||||
// prove the p2p path fired. At glog verbosity 4 (framework sets -v=4).
|
||||
const peerReadSuccessMarker = "peer read successful"
|
||||
|
||||
// seedConvergenceTimeout bounds how long the test waits for:
|
||||
// - the filer registry to list all mounts,
|
||||
// - every mount's seed view (via MountList poll) to include all peers,
|
||||
// - the first announcer flush cycle to publish chunk holders.
|
||||
//
|
||||
// With defaults the mount polls MountList every 30 s and flushes
|
||||
// ChunkAnnounce every 15 s. Allowing 45 s absorbs one MountList refresh
|
||||
// plus one announce cycle plus some slop for CI variance.
|
||||
const seedConvergenceTimeout = 90 * time.Second
|
||||
|
||||
// TestPeerChunkSharing_ReadersPullFromPeerCache is the headline p2p
|
||||
// integration test. It proves at least one non-writer mount can satisfy
|
||||
// a read from the writer's chunk cache instead of the volume tier.
|
||||
//
|
||||
// 1. Bring up 1 master/volume/filer + 3 mounts, all with -peer.enable.
|
||||
// 2. Mount 0 writes a ~8 MiB file and reads it back so chunks land in
|
||||
// its local cache and the announcer publishes them.
|
||||
// 3. Wait for seed convergence + at least one announcer flush cycle.
|
||||
// 4. BOTH mount 1 and mount 2 read the file.
|
||||
//
|
||||
// Why both readers: with 3 mounts, HRW picks one owner for the chunk.
|
||||
// If that owner is mount 1, only mount 2's read will hit the peer
|
||||
// path (mount 1's tryPeerRead bails on owner==self). If that owner is
|
||||
// mount 2, only mount 1's read will. If that owner is mount 0 (the
|
||||
// writer), both can. So by reading from both, we deterministically
|
||||
// guarantee at least one non-writer mount exercises the peer path.
|
||||
//
|
||||
// Once the peer fetch populates its local cache, subsequent reads
|
||||
// short-circuit on IsInCache — so we only get one real shot per mount
|
||||
// per chunk. That's fine: one success is all the test needs.
|
||||
func TestPeerChunkSharing_ReadersPullFromPeerCache(t *testing.T) {
|
||||
c := startP2PTestCluster(t, 3)
|
||||
|
||||
// ~8 MiB, pseudo-random so compression doesn't collapse it to one block.
|
||||
payload := make([]byte, 8*1024*1024)
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for i := range payload {
|
||||
payload[i] = byte(rng.Uint32())
|
||||
}
|
||||
|
||||
const relPath = "p2p-test.bin"
|
||||
writer := c.MountDir(0)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(writer, relPath), payload, 0644))
|
||||
|
||||
// Warm mount 0's chunk cache by reading back through its own FUSE.
|
||||
// Without this the chunks are on the volume server but not yet
|
||||
// in anyone's peer-servable cache.
|
||||
readBack, err := os.ReadFile(filepath.Join(writer, relPath))
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(readBack, payload), "write-then-read on writer mount should match")
|
||||
|
||||
waitForSeedConvergence(t, c, seedConvergenceTimeout)
|
||||
|
||||
// Give the announcer several flush windows to push chunk-holder
|
||||
// entries to the HRW owners. First flush may see the writer's seed
|
||||
// view incomplete (only self) and defer all fids; subsequent
|
||||
// flushes re-check against a refreshed seed view. announce interval
|
||||
// is 15 s, so 45 s covers three attempts.
|
||||
time.Sleep(45 * time.Second)
|
||||
|
||||
// Read from both non-writer mounts. For any HRW outcome on any
|
||||
// chunk, at least one of these reads will NOT have the reader as
|
||||
// the HRW owner, so its tryPeerRead will proceed to ChunkLookup +
|
||||
// FetchChunk.
|
||||
for _, idx := range []int{1, 2} {
|
||||
got, err := os.ReadFile(filepath.Join(c.MountDir(idx), relPath))
|
||||
require.NoError(t, err, "read from mount %d must succeed\n--- mount%d ---\n%s",
|
||||
idx, idx, tailLines(c.MountLog(idx), 80))
|
||||
require.Equal(t, md5.Sum(payload), md5.Sum(got),
|
||||
"mount %d returned mismatched bytes (len got=%d want=%d)", idx, len(got), len(payload))
|
||||
}
|
||||
|
||||
// Content matches alone doesn't prove p2p — the volume fallback
|
||||
// would also satisfy the reads. Require at least one non-writer
|
||||
// mount's log to contain the peer-read success marker.
|
||||
var sawPeerRead bool
|
||||
for _, idx := range []int{1, 2} {
|
||||
if strings.Contains(c.MountLog(idx), peerReadSuccessMarker) {
|
||||
sawPeerRead = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawPeerRead {
|
||||
t.Fatalf("no non-writer mount logged %q — peer read path never fired.\n"+
|
||||
"--- mount0 (writer) tail ---\n%s\n--- mount1 tail ---\n%s\n--- mount2 tail ---\n%s",
|
||||
peerReadSuccessMarker,
|
||||
tailLines(c.MountLog(0), 60), tailLines(c.MountLog(1), 60), tailLines(c.MountLog(2), 60))
|
||||
}
|
||||
}
|
||||
|
||||
// waitForSeedConvergence polls each mount's log looking for any sign
|
||||
// that MountList returned a peer list containing the other mounts.
|
||||
func waitForSeedConvergence(t *testing.T, c *p2pTestCluster, timeout time.Duration) {
|
||||
t.Helper()
|
||||
// The first MountRegister happens synchronously during mount startup,
|
||||
// and the first MountList is pulled right after. 30 s is the refresh
|
||||
// interval; waiting one full cycle here guarantees every mount has
|
||||
// at minimum observed the others in its seed view.
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
ready := true
|
||||
for i := range c.mountCmds {
|
||||
if !strings.Contains(c.MountLog(i), "peer-grpc listening on") {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
time.Sleep(30 * time.Second)
|
||||
return
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("peer-grpc servers did not come up within %s", timeout)
|
||||
}
|
||||
|
||||
// tailLines returns the last n newline-delimited lines of s, or the
|
||||
// whole thing if shorter. Keeps test failures readable.
|
||||
func tailLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) <= n {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("... (%d earlier lines omitted) ...\n%s",
|
||||
len(lines)-n, strings.Join(lines[len(lines)-n:], "\n"))
|
||||
}
|
||||
+10
-11
@@ -45,7 +45,7 @@ require (
|
||||
github.com/aws/aws-sdk-go v1.55.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 // indirect
|
||||
@@ -57,7 +57,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
@@ -70,7 +70,6 @@ require (
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/buengese/sgzip v0.1.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/calebcase/tmpfile v1.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
|
||||
@@ -242,20 +241,20 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.274.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
|
||||
+26
-28
@@ -116,8 +116,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
@@ -140,8 +140,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
@@ -170,8 +170,6 @@ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
@@ -754,16 +752,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
@@ -787,8 +785,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -869,8 +867,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -946,8 +944,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -959,8 +957,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -976,8 +974,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1030,8 +1028,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -10,14 +10,6 @@ import (
|
||||
"github.com/segmentio/kafka-go"
|
||||
)
|
||||
|
||||
const (
|
||||
consumerGroupHeartbeatInterval = 2 * time.Second
|
||||
consumerGroupSessionTimeout = 6 * time.Second
|
||||
consumerGroupRebalanceTimeout = 6 * time.Second
|
||||
consumerGroupJoinBackoff = 250 * time.Millisecond
|
||||
consumerGroupAttemptTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// KafkaGoClient wraps kafka-go client with test utilities
|
||||
type KafkaGoClient struct {
|
||||
brokerAddr string
|
||||
@@ -136,81 +128,40 @@ func (k *KafkaGoClient) ConsumeMessages(topicName string, expectedCount int) ([]
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConsumeWithGroup consumes messages using consumer group.
|
||||
// Retries the initial join+fetch with a fresh reader if it fails before any
|
||||
// message is received — re-joining an existing group races with the previous
|
||||
// member's LeaveGroup / session cleanup and can surface as an i/o timeout on
|
||||
// the first FetchMessage.
|
||||
// ConsumeWithGroup consumes messages using consumer group
|
||||
func (k *KafkaGoClient) ConsumeWithGroup(topicName, groupID string, expectedCount int) ([]kafka.Message, error) {
|
||||
k.t.Helper()
|
||||
|
||||
const maxJoinAttempts = 5
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxJoinAttempts; attempt++ {
|
||||
messages, err, progressed := k.consumeWithGroupOnce(topicName, groupID, expectedCount)
|
||||
if err == nil {
|
||||
return messages, nil
|
||||
}
|
||||
lastErr = err
|
||||
// Only retry if we failed before any message was received. Once we've
|
||||
// fetched at least one message, a partial result is more useful than a
|
||||
// full retry (which would start over from the last committed offset).
|
||||
if progressed {
|
||||
return messages, err
|
||||
}
|
||||
if attempt == maxJoinAttempts {
|
||||
break
|
||||
}
|
||||
backoff := time.Duration(500*(1<<(attempt-1))) * time.Millisecond
|
||||
k.t.Logf("ConsumeWithGroup join attempt %d/%d failed (%v) — retrying after %v", attempt, maxJoinAttempts, err, backoff)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// consumeWithGroupOnce runs a single consume attempt. Returns the messages
|
||||
// fetched, any error, and whether any message was received (used to decide
|
||||
// whether a retry is safe).
|
||||
func (k *KafkaGoClient) consumeWithGroupOnce(topicName, groupID string, expectedCount int) ([]kafka.Message, error, bool) {
|
||||
// Give each reader its own ClientID so restarts do not get mistaken for the
|
||||
// still-shutting-down reader they are replacing.
|
||||
dialer := &kafka.Dialer{
|
||||
ClientID: fmt.Sprintf("seaweedfs-e2e-%s-%d", groupID, time.Now().UnixNano()),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: []string{k.brokerAddr},
|
||||
Dialer: dialer,
|
||||
Topic: topicName,
|
||||
GroupID: groupID,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
CommitInterval: 500 * time.Millisecond,
|
||||
HeartbeatInterval: consumerGroupHeartbeatInterval,
|
||||
SessionTimeout: consumerGroupSessionTimeout,
|
||||
RebalanceTimeout: consumerGroupRebalanceTimeout,
|
||||
JoinGroupBackoff: consumerGroupJoinBackoff,
|
||||
Brokers: []string{k.brokerAddr},
|
||||
Topic: topicName,
|
||||
GroupID: groupID,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
CommitInterval: 500 * time.Millisecond,
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
// Log the initial offset position
|
||||
offset := reader.Offset()
|
||||
k.t.Logf("Consumer group reader created for group %s, initial offset: %d", groupID, offset)
|
||||
|
||||
// Keep each attempt short enough that a retry can outlive a stale group
|
||||
// member instead of burning most of the overall test timeout on one try.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), consumerGroupAttemptTimeout)
|
||||
// Increased timeout for consumer groups - they require coordinator discovery,
|
||||
// offset fetching, and offset commits which can be slow in CI environments
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var messages []kafka.Message
|
||||
for i := 0; i < expectedCount; i++ {
|
||||
// Fetch then explicitly commit to better control commit timing
|
||||
msg, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
return messages, fmt.Errorf("read message %d: %w", i, err), len(messages) > 0
|
||||
return messages, fmt.Errorf("read message %d: %w", i, err)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
k.t.Logf(" Fetched message %d: offset=%d, partition=%d", i, msg.Offset, msg.Partition)
|
||||
|
||||
// Commit with simple retry to handle transient connection churn
|
||||
var commitErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
commitErr = reader.CommitMessages(ctx, msg)
|
||||
@@ -219,15 +170,16 @@ func (k *KafkaGoClient) consumeWithGroupOnce(topicName, groupID string, expected
|
||||
break
|
||||
}
|
||||
k.t.Logf(" Commit attempt %d failed for offset %d: %v", attempt+1, msg.Offset, commitErr)
|
||||
// brief backoff
|
||||
time.Sleep(time.Duration(50*(1<<attempt)) * time.Millisecond)
|
||||
}
|
||||
if commitErr != nil {
|
||||
return messages, fmt.Errorf("committing message %d: %w", i, commitErr), true
|
||||
return messages, fmt.Errorf("committing message %d: %w", i, commitErr)
|
||||
}
|
||||
}
|
||||
|
||||
k.t.Logf("Consumed %d messages from topic %s with group %s", len(messages), topicName, groupID)
|
||||
return messages, nil, true
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// CreateTopic creates a topic using Sarama
|
||||
|
||||
@@ -17,18 +17,16 @@ COPY test/kafka/kafka-client-loadtest/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /kafka-loadtest ./cmd/loadtest
|
||||
|
||||
# Stage 2: Runtime
|
||||
# Use alpine so we don't depend on Ubuntu apt mirrors, which intermittently
|
||||
# refuse connections from GitHub Actions runners and fail the CI build.
|
||||
# All runtime dependencies we need (ca-certificates, curl, jq, bash, nc)
|
||||
# are in the Alpine main repo.
|
||||
FROM alpine:3.20
|
||||
FROM ubuntu:22.04
|
||||
|
||||
RUN apk add --no-cache \
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
jq \
|
||||
bash \
|
||||
netcat-openbsd
|
||||
netcat \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy built binary from builder stage
|
||||
COPY --from=builder /kafka-loadtest /usr/local/bin/kafka-loadtest
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Use the official Eclipse Temurin + Maven image so we don't depend on
|
||||
# Debian apt mirrors, which intermittently refuse connections from
|
||||
# GitHub Actions runners and fail the CI build. This image already has
|
||||
# JDK 11 and Maven installed.
|
||||
FROM maven:3.9-eclipse-temurin-11
|
||||
FROM openjdk:11-jdk-slim
|
||||
|
||||
# Install Maven
|
||||
RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<kafka.version>3.9.2</kafka.version>
|
||||
<kafka.version>3.9.1</kafka.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<kafka.version>3.9.2</kafka.version>
|
||||
<kafka.version>3.9.1</kafka.version>
|
||||
<confluent.version>7.6.0</confluent.version>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ type MasterCluster struct {
|
||||
|
||||
// clusterStatus is the JSON returned by /cluster/status.
|
||||
type clusterStatus struct {
|
||||
IsLeader bool `json:"IsLeader"`
|
||||
Leader string `json:"Leader"`
|
||||
IsLeader bool `json:"IsLeader"`
|
||||
Leader string `json:"Leader"`
|
||||
Peers []string `json:"Peers"`
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ func (mc *MasterCluster) tailLog(i int) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
|
||||
func findOrBuildWeedBinary() (string, error) {
|
||||
if fromEnv := os.Getenv("WEED_BINARY"); fromEnv != "" {
|
||||
if isExecutableFile(fromEnv) {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.PHONY: all build test test-verbose test-short test-debug clean deps tidy
|
||||
|
||||
all: build test
|
||||
|
||||
# Build the weed binary first
|
||||
build:
|
||||
cd ../../weed && go build -o weed .
|
||||
|
||||
# Install test dependencies
|
||||
deps:
|
||||
go mod download
|
||||
|
||||
# Run all tests
|
||||
test: build deps
|
||||
go test -timeout 5m ./...
|
||||
|
||||
# Run tests with verbose output
|
||||
test-verbose: build deps
|
||||
go test -v -timeout 5m ./...
|
||||
|
||||
# Skip long-running integration tests
|
||||
test-short: deps
|
||||
go test -short -v ./...
|
||||
|
||||
# Run tests with debug output from SeaweedFS
|
||||
test-debug: build deps
|
||||
go test -v -timeout 5m ./... 2>&1 | tee test.log
|
||||
|
||||
# Clean up test artifacts
|
||||
clean:
|
||||
rm -f test.log
|
||||
go clean -testcache
|
||||
|
||||
# Update go.sum
|
||||
tidy:
|
||||
go mod tidy
|
||||
@@ -1,92 +0,0 @@
|
||||
# SeaweedFS NFS Integration Tests
|
||||
|
||||
End-to-end tests that boot a real SeaweedFS cluster (`master` + `volume` +
|
||||
`filer`) plus the experimental `weed nfs` frontend and drive it through the
|
||||
NFSv3 wire protocol. The tests talk to the server over TCP using
|
||||
`github.com/willscott/go-nfs-client`, which means they do **not** need a
|
||||
kernel NFS mount, privileged ports, or any platform-specific tooling.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Build the `weed` binary:
|
||||
```bash
|
||||
cd ../../weed
|
||||
go build -o weed .
|
||||
```
|
||||
2. Go 1.24 or later.
|
||||
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
# Build weed and run everything
|
||||
make test
|
||||
|
||||
# Verbose output, keeps the subprocess stdout
|
||||
make test-verbose
|
||||
|
||||
# Skip integration tests — useful when iterating on the framework itself
|
||||
make test-short
|
||||
|
||||
# Run a single test
|
||||
go test -v -run TestNfsBasicReadWrite ./...
|
||||
```
|
||||
|
||||
Every test starts its own cluster on random loopback ports, so runs are
|
||||
isolated and can execute in parallel.
|
||||
|
||||
## Layout
|
||||
|
||||
- `framework.go` — launches `weed master`, `weed volume`, `weed filer`, and
|
||||
`weed nfs` as subprocesses, waits for each to accept TCP, and exposes a
|
||||
`Mount()` helper that returns an `nfsclient.Target`.
|
||||
- `basic_test.go` — covers the most common NFS operations:
|
||||
- Read/write round-trip (`TestNfsBasicReadWrite`)
|
||||
- Mkdir / ReadDirPlus / RmDir (`TestNfsMkdirAndRmdir`)
|
||||
- Nested directory + leaf file (`TestNfsNestedDirectories`)
|
||||
- Rename preserves content (`TestNfsRenamePreservesContent`)
|
||||
- Overwrite shrinks file size (`TestNfsOverwriteShrinksFile`)
|
||||
- Large binary file round-trip (`TestNfsLargeFile`)
|
||||
- Arbitrary binary and empty files (`TestNfsBinaryAndEmptyFiles`)
|
||||
- Symlink + Readlink (`TestNfsSymlinkRoundTrip`)
|
||||
- ReadDirPlus ordering sanity (`TestNfsReadDirPlusOrdering`)
|
||||
- Remove on missing path errors cleanly (`TestNfsRemoveMissingFailsCleanly`)
|
||||
- FSINFO advertises non-zero limits (`TestNfsFSInfoReturnsSaneLimits`)
|
||||
- Sequential append writes concatenate (`TestNfsAppendIsSequential`)
|
||||
- ReadDir after remove (`TestNfsReadDirAfterRemove`)
|
||||
|
||||
## Debugging a failing test
|
||||
|
||||
Keep the cluster temp dir for inspection:
|
||||
|
||||
```go
|
||||
config := DefaultTestConfig()
|
||||
config.SkipCleanup = true
|
||||
```
|
||||
|
||||
Enable subprocess stdout/stderr:
|
||||
|
||||
```go
|
||||
config := DefaultTestConfig()
|
||||
config.EnableDebug = true
|
||||
```
|
||||
|
||||
Or run with `-v`, which flips `EnableDebug` automatically via `testing.Verbose()`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The NFS server binds to `127.0.0.1` with `-ip.bind=127.0.0.1` and exports
|
||||
`/nfs_export`. The test framework pre-creates that directory via the
|
||||
filer's HTTP API before starting the NFS server — the NFS server requires
|
||||
its export root to exist in the filer's namespace with a real entry, and
|
||||
the filer's synthetic `/` root does not match the `Name=="/"` check the
|
||||
NFS server performs during `ensureIndexedEntry`.
|
||||
- Ports are allocated dynamically. Each test run opens a short-lived
|
||||
listener on `127.0.0.1:0`, reads back the assigned port, closes the
|
||||
listener, and hands the port to `weed master/volume/filer/nfs`. There is
|
||||
a tiny race window between close and reopen that has not been a problem
|
||||
in practice but is worth remembering if you see a "bind: address already
|
||||
in use" failure.
|
||||
- All four `weed` components are started with explicit `-port.grpc=...`
|
||||
flags. Without them, the default is `-port + 10000`, which overflows
|
||||
`65535` whenever the HTTP port lands above `55535` — the kernel's
|
||||
ephemeral port range on macOS routinely does.
|
||||
@@ -1,400 +0,0 @@
|
||||
package nfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
nfsclient "github.com/willscott/go-nfs-client/nfs"
|
||||
)
|
||||
|
||||
// setupFramework is a small helper that boots the cluster for a single test
|
||||
// and tears everything down on completion. Every test gets a fresh filer +
|
||||
// volume pair so they cannot step on each other's namespace.
|
||||
func setupFramework(t *testing.T) *NfsTestFramework {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
config := DefaultTestConfig()
|
||||
config.EnableDebug = testing.Verbose()
|
||||
fw := NewNfsTestFramework(t, config)
|
||||
require.NoError(t, fw.Setup(config), "framework setup")
|
||||
t.Cleanup(fw.Cleanup)
|
||||
return fw
|
||||
}
|
||||
|
||||
// writeAll writes payload to path on the target in a single Write call. The
|
||||
// NFS WRITE3 RPC chunks internally, so this exists purely so tests read
|
||||
// linearly.
|
||||
func writeAll(t *testing.T, target *nfsclient.Target, remotePath string, payload []byte) {
|
||||
t.Helper()
|
||||
file, err := target.OpenFile(remotePath, 0o644)
|
||||
require.NoError(t, err, "open %s for write", remotePath)
|
||||
if len(payload) > 0 {
|
||||
n, err := file.Write(payload)
|
||||
require.NoError(t, err, "write %s", remotePath)
|
||||
require.Equal(t, len(payload), n, "short write on %s", remotePath)
|
||||
}
|
||||
require.NoError(t, file.Close(), "close %s", remotePath)
|
||||
}
|
||||
|
||||
// readAll opens path on the target and returns the full file contents.
|
||||
func readAll(t *testing.T, target *nfsclient.Target, remotePath string) []byte {
|
||||
t.Helper()
|
||||
file, err := target.Open(remotePath)
|
||||
require.NoError(t, err, "open %s for read", remotePath)
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
require.NoError(t, err, "read %s", remotePath)
|
||||
return content
|
||||
}
|
||||
|
||||
// TestNfsBasicReadWrite exercises the most common NFS path: OpenFile + Write
|
||||
// + Close followed by Open + Read to verify round-trip data integrity.
|
||||
func TestNfsBasicReadWrite(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
payload := []byte("hello from seaweedfs nfs integration test")
|
||||
writeAll(t, target, "/hello.txt", payload)
|
||||
|
||||
got := readAll(t, target, "/hello.txt")
|
||||
assert.Equal(t, payload, got, "round-tripped content must match")
|
||||
|
||||
info, err := target.Getattr("/hello.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(payload)), int64(info.Filesize))
|
||||
}
|
||||
|
||||
// TestNfsMkdirAndRmdir covers Mkdir, ReadDirPlus, and RmDir. The readdir
|
||||
// assertion also verifies that the newly-created directory shows up under
|
||||
// the export root the way a POSIX client would expect.
|
||||
func TestNfsMkdirAndRmdir(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
_, err = target.Mkdir("/dir1", 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := target.ReadDirPlus("/")
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == "dir1" {
|
||||
found = true
|
||||
assert.True(t, entry.IsDir(), "dir1 should be a directory")
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected dir1 in readdir listing")
|
||||
|
||||
require.NoError(t, target.RmDir("/dir1"))
|
||||
|
||||
// After removal, dir1 must be gone from the listing.
|
||||
entries, err = target.ReadDirPlus("/")
|
||||
require.NoError(t, err)
|
||||
for _, entry := range entries {
|
||||
assert.NotEqual(t, "dir1", entry.Name(), "dir1 should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNfsNestedDirectories ensures the server can materialise a deep tree in
|
||||
// a single Mkdir-per-segment sequence and that reads/writes work at the
|
||||
// leaves.
|
||||
func TestNfsNestedDirectories(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
for _, segment := range []string{"/a", "/a/b", "/a/b/c"} {
|
||||
_, err := target.Mkdir(segment, 0o755)
|
||||
require.NoError(t, err, "mkdir %s", segment)
|
||||
}
|
||||
|
||||
payload := []byte("deep path content")
|
||||
writeAll(t, target, "/a/b/c/leaf.txt", payload)
|
||||
|
||||
got := readAll(t, target, "/a/b/c/leaf.txt")
|
||||
assert.Equal(t, payload, got)
|
||||
|
||||
require.NoError(t, target.Remove("/a/b/c/leaf.txt"))
|
||||
require.NoError(t, target.RmDir("/a/b/c"))
|
||||
require.NoError(t, target.RmDir("/a/b"))
|
||||
require.NoError(t, target.RmDir("/a"))
|
||||
}
|
||||
|
||||
// TestNfsRenamePreservesContent renames a file and makes sure the content
|
||||
// at the new path matches what was written at the old one, and that the
|
||||
// old path disappears. It does not assert on inode identity because pjdfstest
|
||||
// already covers that and this test intentionally avoids depending on the
|
||||
// mount-side identity plumbing.
|
||||
func TestNfsRenamePreservesContent(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
payload := []byte("rename me")
|
||||
writeAll(t, target, "/src.txt", payload)
|
||||
|
||||
require.NoError(t, target.Rename("/src.txt", "/dst.txt"))
|
||||
|
||||
_, _, err = target.Lookup("/src.txt")
|
||||
assert.Error(t, err, "source should be gone after rename")
|
||||
|
||||
got := readAll(t, target, "/dst.txt")
|
||||
assert.Equal(t, payload, got)
|
||||
|
||||
require.NoError(t, target.Remove("/dst.txt"))
|
||||
}
|
||||
|
||||
// TestNfsOverwriteShrinksFile rewrites an existing file with shorter content
|
||||
// and asserts Getattr reports the new (smaller) size. go-nfs-client's
|
||||
// OpenFile does not pass O_TRUNC, so the test truncates explicitly via
|
||||
// Setattr(size=0) before the second write — mirroring what `echo >file`
|
||||
// does on a POSIX client.
|
||||
func TestNfsOverwriteShrinksFile(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
writeAll(t, target, "/overwrite.txt", []byte("the quick brown fox"))
|
||||
|
||||
require.NoError(t, target.Setattr("/overwrite.txt", nfsclient.Sattr3{
|
||||
Size: nfsclient.SetSize{SetIt: true, Size: 0},
|
||||
}))
|
||||
|
||||
writeAll(t, target, "/overwrite.txt", []byte("short"))
|
||||
|
||||
info, err := target.Getattr("/overwrite.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len("short")), int64(info.Filesize))
|
||||
|
||||
got := readAll(t, target, "/overwrite.txt")
|
||||
assert.Equal(t, []byte("short"), got)
|
||||
|
||||
require.NoError(t, target.Remove("/overwrite.txt"))
|
||||
}
|
||||
|
||||
// TestNfsLargeFile writes a multi-megabyte payload so the write path has to
|
||||
// cut chunks and flush through the volume server rather than inlining
|
||||
// content in the filer entry.
|
||||
func TestNfsLargeFile(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
const size = 3 * 1024 * 1024 // 3 MiB — exceeds the 4 MiB inline cutoff boundary when combined with metadata
|
||||
payload := make([]byte, size)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i % 251) // non-repeating to catch offset bugs
|
||||
}
|
||||
|
||||
writeAll(t, target, "/big.bin", payload)
|
||||
|
||||
info, err := target.Getattr("/big.bin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(size), int64(info.Filesize))
|
||||
|
||||
got := readAll(t, target, "/big.bin")
|
||||
require.Equal(t, size, len(got))
|
||||
assert.True(t, bytes.Equal(payload, got), "large file content must round-trip byte-for-byte")
|
||||
|
||||
require.NoError(t, target.Remove("/big.bin"))
|
||||
}
|
||||
|
||||
// TestNfsBinaryAndEmptyFiles covers two edge-case payloads the write path
|
||||
// tends to regress on: arbitrary binary bytes and zero-length files.
|
||||
func TestNfsBinaryAndEmptyFiles(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
t.Run("AllByteValues", func(t *testing.T) {
|
||||
payload := make([]byte, 256)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
writeAll(t, target, "/binary.bin", payload)
|
||||
assert.Equal(t, payload, readAll(t, target, "/binary.bin"))
|
||||
require.NoError(t, target.Remove("/binary.bin"))
|
||||
})
|
||||
|
||||
t.Run("EmptyFile", func(t *testing.T) {
|
||||
writeAll(t, target, "/empty.txt", nil)
|
||||
info, err := target.Getattr("/empty.txt")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), int64(info.Filesize))
|
||||
require.NoError(t, target.Remove("/empty.txt"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestNfsSymlinkRoundTrip covers Symlink and Readlink through the nfs server.
|
||||
// Readlink returns the target path; the server does not auto-traverse it.
|
||||
func TestNfsSymlinkRoundTrip(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
// Symlink uses a different RPC than open+create, and our server routes it
|
||||
// through the billy Change interface.
|
||||
require.NoError(t, target.Symlink("/target.txt", "/link.txt"))
|
||||
|
||||
// The underlying target does not need to exist for readlink to succeed.
|
||||
file, _, err := target.Lookup("/link.txt")
|
||||
require.NoError(t, err, "lookup symlink")
|
||||
assert.True(t, file.Mode()&os.ModeSymlink != 0, "expected symlink mode, got %s", file.Mode())
|
||||
|
||||
require.NoError(t, target.Remove("/link.txt"))
|
||||
}
|
||||
|
||||
// TestNfsReadDirPlusOrdering creates a handful of files with distinct names
|
||||
// and ensures ReadDirPlus surfaces every one of them. The server pages
|
||||
// listings from the filer, so we want to make sure nothing is truncated.
|
||||
func TestNfsReadDirPlusOrdering(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
_, err = target.Mkdir("/listing", 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
names := []string{"alpha.txt", "beta.txt", "gamma.txt", "delta.txt", "epsilon.txt"}
|
||||
for _, name := range names {
|
||||
writeAll(t, target, path.Join("/listing", name), []byte(name))
|
||||
}
|
||||
|
||||
entries, err := target.ReadDirPlus("/listing")
|
||||
require.NoError(t, err)
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == "." || entry.Name() == ".." {
|
||||
continue
|
||||
}
|
||||
seen[entry.Name()] = struct{}{}
|
||||
}
|
||||
for _, name := range names {
|
||||
_, ok := seen[name]
|
||||
assert.True(t, ok, "expected %s in directory listing", name)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
require.NoError(t, target.Remove(path.Join("/listing", name)))
|
||||
}
|
||||
require.NoError(t, target.RmDir("/listing"))
|
||||
}
|
||||
|
||||
// TestNfsRemoveMissingFailsCleanly asserts that removing a non-existent path
|
||||
// surfaces an error instead of silently succeeding. A bug where the server
|
||||
// returned NFS3_OK on missing entries would hide metadata drift.
|
||||
func TestNfsRemoveMissingFailsCleanly(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
err = target.Remove("/does_not_exist.txt")
|
||||
require.Error(t, err, "removing a missing file must error")
|
||||
// NFS3 surfaces this as NFS3ERR_NOENT; make sure the error text is
|
||||
// recognisable without locking us into the library's exact wording.
|
||||
assert.True(t,
|
||||
strings.Contains(strings.ToLower(err.Error()), "noent") ||
|
||||
strings.Contains(strings.ToLower(err.Error()), "not exist") ||
|
||||
strings.Contains(strings.ToLower(err.Error()), "no such"),
|
||||
"unexpected error shape: %v", err)
|
||||
}
|
||||
|
||||
// TestNfsFSInfoReturnsSaneLimits pokes at FSINFO so we catch regressions
|
||||
// where the server advertises zero read/write limits (which would make
|
||||
// clients fall back to the 8 KiB floor and slow every test that follows).
|
||||
func TestNfsFSInfoReturnsSaneLimits(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
info, err := target.FSInfo()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
assert.Greater(t, info.RTPref, uint32(0), "rtpref must be positive")
|
||||
assert.Greater(t, info.WTPref, uint32(0), "wtpref must be positive")
|
||||
}
|
||||
|
||||
// TestNfsAppendIsSequential writes two chunks to the same file in separate
|
||||
// Open cycles and asserts the concatenation is preserved. The second write
|
||||
// uses O_APPEND (the default Open path in go-nfs-client does not pass
|
||||
// flags, so we explicitly reopen after writing the first chunk).
|
||||
func TestNfsAppendIsSequential(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
const prefix = "part1-"
|
||||
const suffix = "part2"
|
||||
|
||||
writeAll(t, target, "/concat.txt", []byte(prefix))
|
||||
|
||||
file, err := target.OpenFile("/concat.txt", 0o644)
|
||||
require.NoError(t, err)
|
||||
// Seek to end before writing so we append rather than overwrite. go-nfs
|
||||
// client's File.Seek uses the same offset tracking as Write so this is
|
||||
// enough to place the second chunk after the first.
|
||||
_, err = file.Seek(int64(len(prefix)), io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte(suffix))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
got := readAll(t, target, "/concat.txt")
|
||||
assert.Equal(t, prefix+suffix, string(got))
|
||||
|
||||
require.NoError(t, target.Remove("/concat.txt"))
|
||||
}
|
||||
|
||||
// Regression: readdir should not emit stale entries after a remove. This is
|
||||
// the scenario the PR's meta cache invalidation logic was written to fix.
|
||||
func TestNfsReadDirAfterRemove(t *testing.T) {
|
||||
fw := setupFramework(t)
|
||||
target, cleanup, err := fw.Mount()
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
_, err = target.Mkdir("/churn", 0o755)
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 5; i++ {
|
||||
writeAll(t, target, path.Join("/churn", fmt.Sprintf("f%d.txt", i)), []byte{byte(i)})
|
||||
}
|
||||
// Remove the middle one and re-list.
|
||||
require.NoError(t, target.Remove("/churn/f2.txt"))
|
||||
|
||||
entries, err := target.ReadDirPlus("/churn")
|
||||
require.NoError(t, err)
|
||||
for _, entry := range entries {
|
||||
assert.NotEqual(t, "f2.txt", entry.Name(), "removed file should not reappear in listing")
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if i == 2 {
|
||||
continue
|
||||
}
|
||||
require.NoError(t, target.Remove(path.Join("/churn", fmt.Sprintf("f%d.txt", i))))
|
||||
}
|
||||
require.NoError(t, target.RmDir("/churn"))
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
package nfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
nfsclient "github.com/willscott/go-nfs-client/nfs"
|
||||
"github.com/willscott/go-nfs-client/nfs/rpc"
|
||||
)
|
||||
|
||||
// NfsTestFramework boots a minimal SeaweedFS cluster (master + volume + filer)
|
||||
// plus the experimental `weed nfs` frontend and hands out NFSv3 RPC clients
|
||||
// that talk to it. Everything is driven via subprocesses so the tests exercise
|
||||
// the same binary an operator would deploy, and no kernel mount is required.
|
||||
type NfsTestFramework struct {
|
||||
t *testing.T
|
||||
tempDir string
|
||||
dataDir string
|
||||
masterProcess *os.Process
|
||||
volumeProcess *os.Process
|
||||
filerProcess *os.Process
|
||||
nfsProcess *os.Process
|
||||
masterAddr string
|
||||
masterGrpc int
|
||||
volumeAddr string
|
||||
volumeGrpc int
|
||||
filerAddr string
|
||||
filerGrpc int
|
||||
nfsAddr string
|
||||
exportRoot string
|
||||
weedBinary string
|
||||
isSetup bool
|
||||
skipCleanup bool
|
||||
}
|
||||
|
||||
// TestConfig controls how the framework boots the cluster.
|
||||
type TestConfig struct {
|
||||
NumVolumes int
|
||||
EnableDebug bool
|
||||
SkipCleanup bool // keep temp dir on failure for inspection
|
||||
// ExportRoot is the filer path the NFS server exports. Defaults to "/"
|
||||
// so tests can use any path, with a single warning logged by the server.
|
||||
ExportRoot string
|
||||
}
|
||||
|
||||
// DefaultTestConfig returns the defaults used by most tests. A dedicated
|
||||
// /nfs_export subtree is used as the NFS export root because the NFS server
|
||||
// requires the export directory to exist in the filer's namespace and carry
|
||||
// a non-zero inode — passing "/" would succeed only for filer setups that
|
||||
// have already backfilled the root inode.
|
||||
func DefaultTestConfig() *TestConfig {
|
||||
return &TestConfig{
|
||||
NumVolumes: 3,
|
||||
EnableDebug: false,
|
||||
SkipCleanup: false,
|
||||
ExportRoot: "/nfs_export",
|
||||
}
|
||||
}
|
||||
|
||||
// NewNfsTestFramework allocates a framework bound to the current test. Call
|
||||
// Setup next to actually start the cluster.
|
||||
func NewNfsTestFramework(t *testing.T, config *TestConfig) *NfsTestFramework {
|
||||
if config == nil {
|
||||
config = DefaultTestConfig()
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "seaweedfs_nfs_test_")
|
||||
require.NoError(t, err)
|
||||
|
||||
// testutil.MustAllocatePorts holds every listener open until the full
|
||||
// batch has been reserved, which avoids the "close-then-hope" race my
|
||||
// original per-port helper had. We need seven ports: four HTTP (master,
|
||||
// volume, filer, nfs) and three gRPC (master, volume, filer — nfs has
|
||||
// no gRPC endpoint).
|
||||
ports := testutil.MustAllocatePorts(t, 7)
|
||||
|
||||
exportRoot := config.ExportRoot
|
||||
if exportRoot == "" {
|
||||
exportRoot = "/"
|
||||
}
|
||||
|
||||
return &NfsTestFramework{
|
||||
t: t,
|
||||
tempDir: tempDir,
|
||||
dataDir: filepath.Join(tempDir, "data"),
|
||||
masterAddr: fmt.Sprintf("127.0.0.1:%d", ports[0]),
|
||||
masterGrpc: ports[1],
|
||||
volumeAddr: fmt.Sprintf("127.0.0.1:%d", ports[2]),
|
||||
volumeGrpc: ports[3],
|
||||
filerAddr: fmt.Sprintf("127.0.0.1:%d", ports[4]),
|
||||
filerGrpc: ports[5],
|
||||
nfsAddr: fmt.Sprintf("127.0.0.1:%d", ports[6]),
|
||||
exportRoot: exportRoot,
|
||||
weedBinary: findWeedBinary(),
|
||||
isSetup: false,
|
||||
skipCleanup: config.SkipCleanup,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup starts the SeaweedFS cluster and the NFS frontend, waiting for each
|
||||
// component to accept connections before moving on.
|
||||
func (f *NfsTestFramework) Setup(config *TestConfig) error {
|
||||
if f.isSetup {
|
||||
return fmt.Errorf("framework already setup")
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
f.dataDir,
|
||||
filepath.Join(f.dataDir, "master"),
|
||||
filepath.Join(f.dataDir, "volume"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.startMaster(config); err != nil {
|
||||
return fmt.Errorf("failed to start master: %v", err)
|
||||
}
|
||||
if !testutil.WaitForPort(portFromAddr(f.masterAddr), testutil.SeaweedMiniStartupTimeout) {
|
||||
return fmt.Errorf("master not ready at %s", f.masterAddr)
|
||||
}
|
||||
|
||||
if err := f.startVolumeServer(config); err != nil {
|
||||
return fmt.Errorf("failed to start volume server: %v", err)
|
||||
}
|
||||
if !testutil.WaitForPort(portFromAddr(f.volumeAddr), testutil.SeaweedMiniStartupTimeout) {
|
||||
return fmt.Errorf("volume server not ready at %s", f.volumeAddr)
|
||||
}
|
||||
|
||||
if err := f.startFiler(config); err != nil {
|
||||
return fmt.Errorf("failed to start filer: %v", err)
|
||||
}
|
||||
if !testutil.WaitForPort(portFromAddr(f.filerAddr), testutil.SeaweedMiniStartupTimeout) {
|
||||
return fmt.Errorf("filer not ready at %s", f.filerAddr)
|
||||
}
|
||||
|
||||
// Pre-create the export root in the filer's namespace. The NFS server
|
||||
// expects its export directory to exist with a real inode; uploading a
|
||||
// placeholder file creates the parent directory implicitly and then
|
||||
// removing the file leaves the empty directory in place.
|
||||
if f.exportRoot != "/" {
|
||||
if err := f.ensureExportRootExists(); err != nil {
|
||||
return fmt.Errorf("failed to pre-create export root %s: %v", f.exportRoot, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.startNfsServer(config); err != nil {
|
||||
return fmt.Errorf("failed to start NFS server: %v", err)
|
||||
}
|
||||
if !testutil.WaitForPort(portFromAddr(f.nfsAddr), testutil.SeaweedMiniStartupTimeout) {
|
||||
return fmt.Errorf("NFS server not ready at %s", f.nfsAddr)
|
||||
}
|
||||
|
||||
// Let the NFS server finish wiring up its gRPC subscription to the filer
|
||||
// before the first client call hits MOUNT/LOOKUP.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
f.isSetup = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup stops all processes. Temp state is preserved if SkipCleanup is set.
|
||||
func (f *NfsTestFramework) Cleanup() {
|
||||
processes := []*os.Process{f.nfsProcess, f.filerProcess, f.volumeProcess, f.masterProcess}
|
||||
for _, proc := range processes {
|
||||
if proc != nil {
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
_, _ = proc.Wait()
|
||||
}
|
||||
}
|
||||
if !f.skipCleanup {
|
||||
_ = os.RemoveAll(f.tempDir)
|
||||
}
|
||||
}
|
||||
|
||||
// NfsAddr returns the TCP address the NFS server is listening on.
|
||||
func (f *NfsTestFramework) NfsAddr() string { return f.nfsAddr }
|
||||
|
||||
// FilerAddr returns the TCP address of the filer.
|
||||
func (f *NfsTestFramework) FilerAddr() string { return f.filerAddr }
|
||||
|
||||
// ExportRoot returns the path the NFS server exports.
|
||||
func (f *NfsTestFramework) ExportRoot() string { return f.exportRoot }
|
||||
|
||||
// Mount opens an NFSv3 MOUNT+NFS connection against the running NFS server
|
||||
// and returns a Target that tests can drive like a mini-VFS. Caller is
|
||||
// responsible for calling the returned cleanup func to Unmount and close the
|
||||
// TCP connection.
|
||||
func (f *NfsTestFramework) Mount() (*nfsclient.Target, func(), error) {
|
||||
var (
|
||||
client *rpc.Client
|
||||
err error
|
||||
)
|
||||
// The NFS server's TCP listener may already be accepting connections when
|
||||
// waitForService returns, but the RPC program registration can trail it
|
||||
// by a few milliseconds. Retry the dial to absorb that small window.
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
client, err = rpc.DialTCP("tcp", f.nfsAddr, false)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dial NFS: %w", err)
|
||||
}
|
||||
|
||||
// Note: do not set Mount.Addr here. When Addr is non-empty, the go-nfs
|
||||
// client re-dials via portmapper and concatenates `:111` onto the
|
||||
// address, which produces "too many colons" for a raw `host:port`
|
||||
// string. Reusing the existing RPC client avoids that path entirely.
|
||||
mounter := &nfsclient.Mount{Client: client}
|
||||
target, err := mounter.Mount(f.exportRoot, rpc.AuthNull)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, nil, fmt.Errorf("mount %s: %w", f.exportRoot, err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = mounter.Unmount()
|
||||
client.Close()
|
||||
}
|
||||
return target, cleanup, nil
|
||||
}
|
||||
|
||||
func (f *NfsTestFramework) startMaster(config *TestConfig) error {
|
||||
_, masterPort := splitHostPort(f.masterAddr)
|
||||
args := []string{
|
||||
"master",
|
||||
"-ip=127.0.0.1",
|
||||
fmt.Sprintf("-port=%d", masterPort),
|
||||
fmt.Sprintf("-port.grpc=%d", f.masterGrpc),
|
||||
"-mdir=" + filepath.Join(f.dataDir, "master"),
|
||||
"-raftBootstrap",
|
||||
"-peers=none",
|
||||
}
|
||||
return f.startProcess(&f.masterProcess, config, args)
|
||||
}
|
||||
|
||||
func (f *NfsTestFramework) startVolumeServer(config *TestConfig) error {
|
||||
_, volumePort := splitHostPort(f.volumeAddr)
|
||||
// pb.ServerAddress encodes a non-default gRPC port as `host:port.grpc`.
|
||||
// See weed/pb/server_address.go — the dot, not a colon, is the separator
|
||||
// between the HTTP port and the gRPC port.
|
||||
masterWithGrpc := fmt.Sprintf("%s.%d", f.masterAddr, f.masterGrpc)
|
||||
args := []string{
|
||||
"volume",
|
||||
"-master=" + masterWithGrpc,
|
||||
"-ip=127.0.0.1",
|
||||
fmt.Sprintf("-port=%d", volumePort),
|
||||
fmt.Sprintf("-port.grpc=%d", f.volumeGrpc),
|
||||
"-dir=" + filepath.Join(f.dataDir, "volume"),
|
||||
fmt.Sprintf("-max=%d", config.NumVolumes),
|
||||
}
|
||||
return f.startProcess(&f.volumeProcess, config, args)
|
||||
}
|
||||
|
||||
func (f *NfsTestFramework) startFiler(config *TestConfig) error {
|
||||
_, filerPort := splitHostPort(f.filerAddr)
|
||||
masterWithGrpc := fmt.Sprintf("%s.%d", f.masterAddr, f.masterGrpc)
|
||||
args := []string{
|
||||
"filer",
|
||||
"-master=" + masterWithGrpc,
|
||||
"-ip=127.0.0.1",
|
||||
fmt.Sprintf("-port=%d", filerPort),
|
||||
fmt.Sprintf("-port.grpc=%d", f.filerGrpc),
|
||||
}
|
||||
return f.startProcess(&f.filerProcess, config, args)
|
||||
}
|
||||
|
||||
func (f *NfsTestFramework) startNfsServer(config *TestConfig) error {
|
||||
_, nfsPort := splitHostPort(f.nfsAddr)
|
||||
// `host:port.grpc` encoding — see pb/server_address.go.
|
||||
filerWithGrpc := fmt.Sprintf("%s.%d", f.filerAddr, f.filerGrpc)
|
||||
args := []string{
|
||||
"nfs",
|
||||
"-filer=" + filerWithGrpc,
|
||||
"-ip.bind=127.0.0.1",
|
||||
fmt.Sprintf("-port=%d", nfsPort),
|
||||
"-filer.path=" + f.exportRoot,
|
||||
}
|
||||
return f.startProcess(&f.nfsProcess, config, args)
|
||||
}
|
||||
|
||||
func (f *NfsTestFramework) startProcess(target **os.Process, config *TestConfig, args []string) error {
|
||||
cmd := exec.Command(f.weedBinary, args...)
|
||||
cmd.Dir = f.tempDir
|
||||
if config.EnableDebug {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
*target = cmd.Process
|
||||
return nil
|
||||
}
|
||||
|
||||
// portFromAddr returns just the port number from a `host:port` string.
|
||||
// testutil.WaitForPort takes an int port, not a full address.
|
||||
func portFromAddr(addr string) int {
|
||||
_, port := splitHostPort(addr)
|
||||
return port
|
||||
}
|
||||
|
||||
// ensureExportRootExists posts a placeholder file to f.exportRoot via the
|
||||
// filer's HTTP API, then deletes it. That roundtrip implicitly creates the
|
||||
// target directory so the NFS server has something to mount. We bypass
|
||||
// weed/pb here because the HTTP client is simpler and needs no gRPC stubs.
|
||||
func (f *NfsTestFramework) ensureExportRootExists() error {
|
||||
exportRoot := strings.TrimRight(f.exportRoot, "/")
|
||||
if exportRoot == "" {
|
||||
return nil
|
||||
}
|
||||
placeholder := exportRoot + "/.nfs_test_init"
|
||||
filerURL := "http://" + f.filerAddr + placeholder
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", ".nfs_test_init")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(part, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodPost, filerURL, &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("filer POST %s returned status %d", filerURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Delete the placeholder; the directory stays behind.
|
||||
deleteReq, err := http.NewRequest(http.MethodDelete, filerURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteResp, err := httpClient.Do(deleteReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, deleteResp.Body)
|
||||
deleteResp.Body.Close()
|
||||
if deleteResp.StatusCode/100 != 2 && deleteResp.StatusCode != http.StatusNotFound {
|
||||
return fmt.Errorf("filer DELETE %s returned status %d", filerURL, deleteResp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitHostPort(addr string) (string, int) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
var port int
|
||||
_, _ = fmt.Sscanf(portStr, "%d", &port)
|
||||
return host, port
|
||||
}
|
||||
|
||||
// findWeedBinary locates the weed binary, preferring the local build in the
|
||||
// checkout so tests run against the code under review rather than whatever is
|
||||
// on $PATH.
|
||||
func findWeedBinary() string {
|
||||
if _, thisFile, _, ok := runtime.Caller(0); ok {
|
||||
thisDir := filepath.Dir(thisFile)
|
||||
candidates := []string{
|
||||
filepath.Join(thisDir, "../../weed/weed"),
|
||||
filepath.Join(thisDir, "../weed/weed"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
abs, _ := filepath.Abs(candidate)
|
||||
return abs
|
||||
}
|
||||
}
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
candidates := []string{
|
||||
filepath.Join(cwd, "../../weed/weed"),
|
||||
filepath.Join(cwd, "../weed/weed"),
|
||||
filepath.Join(cwd, "./weed"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
abs, _ := filepath.Abs(candidate)
|
||||
return abs
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("weed"); err == nil {
|
||||
return path
|
||||
}
|
||||
return "weed"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
module seaweedfs-nfs-tests
|
||||
|
||||
go 1.25.0
|
||||
|
||||
// test/testutil lives inside the main seaweedfs module; pull it in via a
|
||||
// local replace so this integration suite can reuse the shared port
|
||||
// allocator and readiness helpers instead of reinventing them.
|
||||
replace github.com/seaweedfs/seaweedfs => ../..
|
||||
|
||||
require (
|
||||
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
|
||||
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 h1:DtrBtkgTJk2XGt4T7eKdKVkd9A5NCevN2e4inLXtsqA=
|
||||
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM chrislusf/seaweedfs:e2e
|
||||
|
||||
RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y \
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
--no-install-recommends \
|
||||
--no-install-suggests \
|
||||
autoconf \
|
||||
@@ -13,8 +13,8 @@ RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG PJDFSTEST_REPO=https://github.com/sanwan/pjdfstest.git
|
||||
ARG PJDFSTEST_REF=d25636a227606f8960e5179741d8f4ad7030ef41
|
||||
ARG PJDFSTEST_REPO=https://github.com/pjd/pjdfstest.git
|
||||
ARG PJDFSTEST_REF=03eb25706d8dbf3611c3f820b45b7a5e09a36c06
|
||||
|
||||
RUN git clone "${PJDFSTEST_REPO}" /opt/pjdfstest && \
|
||||
cd /opt/pjdfstest && \
|
||||
|
||||
@@ -6,3 +6,55 @@
|
||||
# A failure in any test NOT listed here will cause the CI job to fail,
|
||||
# catching regressions immediately.
|
||||
|
||||
# ── Linux FUSE NAME_MAX=255 limitation ──────────────────────────────────
|
||||
# The Linux FUSE kernel module enforces NAME_MAX=255 at the VFS layer.
|
||||
# These tests create filenames >255 bytes which cannot be looked up via
|
||||
# normal syscalls (stat, chmod, etc.) after creation.
|
||||
tests/chmod/02.t
|
||||
tests/chmod/03.t
|
||||
tests/chown/02.t
|
||||
tests/chown/03.t
|
||||
tests/ftruncate/02.t
|
||||
tests/ftruncate/03.t
|
||||
tests/link/02.t
|
||||
tests/link/03.t
|
||||
tests/mkdir/02.t
|
||||
tests/mkdir/03.t
|
||||
tests/mkfifo/02.t
|
||||
tests/mkfifo/03.t
|
||||
tests/mknod/02.t
|
||||
tests/mknod/03.t
|
||||
tests/open/02.t
|
||||
tests/open/03.t
|
||||
tests/rename/01.t
|
||||
tests/rename/02.t
|
||||
tests/rmdir/02.t
|
||||
tests/rmdir/03.t
|
||||
tests/symlink/02.t
|
||||
tests/symlink/03.t
|
||||
tests/truncate/02.t
|
||||
tests/truncate/03.t
|
||||
tests/unlink/02.t
|
||||
tests/unlink/03.t
|
||||
|
||||
# ── Hard link nlink/ctime tracking (requires filer changes) ────────────
|
||||
# nlink counts are not correctly maintained across rename operations.
|
||||
tests/rename/23.t
|
||||
tests/rename/24.t
|
||||
|
||||
# ── Parent directory mtime/ctime on deferred file create ───────────────
|
||||
# When file creation is deferred (not flushed to filer immediately),
|
||||
# the parent directory mtime/ctime cannot be updated without invalidating
|
||||
# the just-cached child entry in the metadata cache.
|
||||
tests/open/00.t
|
||||
|
||||
# ── Directory rename permission edge case ──────────────────────────────
|
||||
# Cross-directory rename of a subdirectory with restricted permissions
|
||||
# causes cascading test failures within the test file.
|
||||
tests/rename/21.t
|
||||
|
||||
# ── rmdir after hard link unlink ───────────────────────────────────────
|
||||
# The filer may still report a directory as non-empty after all hard-linked
|
||||
# entries have been unlinked.
|
||||
tests/unlink/14.t
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ FILER_ADDR="127.0.0.1:${FILER_PORT}"
|
||||
|
||||
# Pin to an immutable upstream commit so CI is reproducible. Override via env
|
||||
# if you want to test against a different ref or fork.
|
||||
PJDFSTEST_REPO="${PJDFSTEST_REPO:-https://github.com/sanwan/pjdfstest.git}"
|
||||
PJDFSTEST_REF="${PJDFSTEST_REF:-d25636a227606f8960e5179741d8f4ad7030ef41}"
|
||||
PJDFSTEST_REPO="${PJDFSTEST_REPO:-https://github.com/pjd/pjdfstest.git}"
|
||||
PJDFSTEST_REF="${PJDFSTEST_REF:-03eb25706d8dbf3611c3f820b45b7a5e09a36c06}"
|
||||
PJDFSTEST_TESTS="${PJDFSTEST_TESTS:-tests/}"
|
||||
|
||||
mini_pid=""
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
test-volume-data/
|
||||
weed-test.log
|
||||
weed-server.pid
|
||||
@@ -1,100 +0,0 @@
|
||||
# S3 Checksum Integration Tests
|
||||
# Covers flexible-checksum behavior on presigned URL uploads (issue #9075).
|
||||
|
||||
.PHONY: help build-weed check-deps start-server stop-server test test-with-server logs clean health-check
|
||||
|
||||
WEED_BINARY := ../../../weed/weed_binary
|
||||
S3_PORT := 8333
|
||||
ACCESS_KEY ?= some_access_key1
|
||||
SECRET_KEY ?= some_secret_key1
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN ?= TestPresignedPut
|
||||
SERVER_DIR := ./test-volume-data/server-data
|
||||
|
||||
help:
|
||||
@echo "S3 Checksum Integration Tests"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build-weed Build the SeaweedFS binary"
|
||||
@echo " start-server Start a local SeaweedFS S3 server for testing"
|
||||
@echo " stop-server Stop the local SeaweedFS server"
|
||||
@echo " test Run checksum tests (server must already be running)"
|
||||
@echo " test-with-server Start server, run tests, stop server"
|
||||
@echo " logs Tail server log"
|
||||
@echo " clean Remove test artifacts"
|
||||
|
||||
build-weed:
|
||||
@echo "Building SeaweedFS binary..."
|
||||
@cd ../../../weed && go build -o weed_binary .
|
||||
@chmod +x $(WEED_BINARY)
|
||||
|
||||
check-deps: build-weed
|
||||
@command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1)
|
||||
@test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1)
|
||||
@go list -m github.com/aws/aws-sdk-go-v2 >/dev/null 2>&1 || (echo "AWS SDK Go v2 not found. Run 'go mod tidy'." && exit 1)
|
||||
@go list -m github.com/stretchr/testify >/dev/null 2>&1 || (echo "Testify not found. Run 'go mod tidy'." && exit 1)
|
||||
|
||||
start-server: check-deps
|
||||
@echo "Starting SeaweedFS server..."
|
||||
@rm -f weed-server.pid
|
||||
@mkdir -p $(SERVER_DIR)
|
||||
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \
|
||||
-dir=$(SERVER_DIR) \
|
||||
-s3.port=$(S3_PORT) \
|
||||
> weed-test.log 2>&1 & \
|
||||
echo $$! > weed-server.pid
|
||||
@for i in $$(seq 1 90); do \
|
||||
if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
|
||||
echo "✅ SeaweedFS server started on port $(S3_PORT) after $$i seconds"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "❌ Server failed to start within 90 seconds"; \
|
||||
if [ -f weed-server.pid ]; then \
|
||||
PID=$$(cat weed-server.pid); \
|
||||
if ps -p $$PID >/dev/null 2>&1; then \
|
||||
kill -TERM $$PID 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
ps -p $$PID >/dev/null 2>&1 && kill -KILL $$PID 2>/dev/null || true; \
|
||||
fi; \
|
||||
rm -f weed-server.pid; \
|
||||
fi; \
|
||||
if [ -f weed-test.log ]; then tail -100 weed-test.log; fi; \
|
||||
exit 1
|
||||
|
||||
stop-server:
|
||||
@if [ -f weed-server.pid ]; then \
|
||||
PID=$$(cat weed-server.pid); \
|
||||
if ps -p $$PID >/dev/null 2>&1; then \
|
||||
kill -TERM $$PID 2>/dev/null || true; \
|
||||
sleep 2; \
|
||||
ps -p $$PID >/dev/null 2>&1 && kill -KILL $$PID 2>/dev/null || true; \
|
||||
fi; \
|
||||
rm -f weed-server.pid; \
|
||||
fi
|
||||
@echo "✅ SeaweedFS server stopped"
|
||||
|
||||
test: check-deps
|
||||
@echo "Running checksum tests (pattern: $(TEST_PATTERN))..."
|
||||
@S3_ENDPOINT=http://localhost:$(S3_PORT) \
|
||||
S3_ACCESS_KEY=$(ACCESS_KEY) \
|
||||
S3_SECRET_KEY=$(SECRET_KEY) \
|
||||
go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" .
|
||||
|
||||
test-with-server: start-server
|
||||
@sleep 3
|
||||
@$(MAKE) test || (echo "Tests failed, stopping server..." && $(MAKE) stop-server && exit 1)
|
||||
@$(MAKE) stop-server
|
||||
@echo "✅ Checksum tests completed"
|
||||
|
||||
logs:
|
||||
@if [ -f weed-test.log ]; then tail -f weed-test.log; else echo "No log file"; fi
|
||||
|
||||
health-check:
|
||||
@curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1 && echo "✅ S3 on $(S3_PORT)" || (echo "❌ S3 not up" && exit 1)
|
||||
|
||||
clean:
|
||||
@$(MAKE) stop-server
|
||||
@rm -f weed-test.log weed-server.pid
|
||||
@rm -rf ./test-volume-data
|
||||
@@ -1,256 +0,0 @@
|
||||
package checksum_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Integration test for https://github.com/seaweedfs/seaweedfs/issues/9075:
|
||||
// presigned PUT URLs that request a flexible-checksum algorithm (SHA256, SHA1, ...)
|
||||
// must cause the server to compute and persist that checksum, and HEAD/GET must
|
||||
// return the x-amz-checksum-* header when the caller asks for it.
|
||||
//
|
||||
// AWS SDK presigners hoist headers like x-amz-sdk-checksum-algorithm into the
|
||||
// signed URL's query string, so we deliberately upload the body with a plain
|
||||
// http.Client to ensure the server sees the checksum algorithm via the query
|
||||
// string — which is exactly what a browser / curl / non-SDK client does.
|
||||
|
||||
type s3TestConfig struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
BucketPrefix string
|
||||
}
|
||||
|
||||
var defaultConfig = &s3TestConfig{
|
||||
Endpoint: "http://localhost:8333",
|
||||
AccessKey: "some_access_key1",
|
||||
SecretKey: "some_secret_key1",
|
||||
Region: "us-east-1",
|
||||
BucketPrefix: "test-checksum-",
|
||||
}
|
||||
|
||||
func getenvAny(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
if v := getenvAny("S3_ENDPOINT"); v != "" {
|
||||
defaultConfig.Endpoint = v
|
||||
}
|
||||
if v := getenvAny("S3_ACCESS_KEY", "AWS_ACCESS_KEY_ID"); v != "" {
|
||||
defaultConfig.AccessKey = v
|
||||
}
|
||||
if v := getenvAny("S3_SECRET_KEY", "AWS_SECRET_ACCESS_KEY"); v != "" {
|
||||
defaultConfig.SecretKey = v
|
||||
}
|
||||
if v := getenvAny("AWS_REGION", "AWS_DEFAULT_REGION"); v != "" {
|
||||
defaultConfig.Region = v
|
||||
}
|
||||
}
|
||||
|
||||
// presignedHTTPClient is used for non-SDK PUTs to a presigned URL. A fixed
|
||||
// timeout keeps tests from hanging forever if the server stalls.
|
||||
var presignedHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
func getS3Client(t *testing.T) *s3.Client {
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(defaultConfig.Region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
defaultConfig.AccessKey, defaultConfig.SecretKey, "")),
|
||||
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
|
||||
func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: defaultConfig.Endpoint,
|
||||
SigningRegion: defaultConfig.Region,
|
||||
HostnameImmutable: true,
|
||||
}, nil
|
||||
})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
|
||||
}
|
||||
|
||||
func uniqueBucket() string {
|
||||
return fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func createBucket(t *testing.T, client *s3.Client, name string) {
|
||||
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{Bucket: aws.String(name)})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func cleanupBucket(t *testing.T, client *s3.Client, name string) {
|
||||
t.Helper()
|
||||
objs, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{Bucket: aws.String(name)})
|
||||
if err == nil {
|
||||
for _, o := range objs.Contents {
|
||||
_, _ = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(name), Key: o.Key,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{Bucket: aws.String(name)})
|
||||
}
|
||||
|
||||
// uploadViaPresignedURL PUTs body to the given presigned URL using a plain
|
||||
// http.Client, mirroring what a browser / curl / non-SDK client would do.
|
||||
// Crucially this path does NOT run any AWS SDK middleware, so the server must
|
||||
// compute and store the checksum based on the algorithm parameter that was
|
||||
// hoisted into the query string by the presigner.
|
||||
//
|
||||
// extraHeaders are additional HTTP headers the presigner signed (e.g.
|
||||
// Content-MD5). Their values must exactly match what was signed or SigV4
|
||||
// verification will return SignatureDoesNotMatch.
|
||||
func uploadViaPresignedURL(t *testing.T, url string, body []byte, extraHeaders map[string]string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.ContentLength = int64(len(body))
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := presignedHTTPClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err, "read presigned PUT response body")
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode,
|
||||
"presigned PUT failed: %d %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// presignPutURL builds a presigned S3 PutObject URL using the low-level
|
||||
// SigV4 presigner, with an optional x-amz-sdk-checksum-algorithm query
|
||||
// parameter baked into the signed canonical query string.
|
||||
//
|
||||
// We use the raw signer instead of s3.PresignClient because the SDK's
|
||||
// flexible-checksum middleware tries to inject a Content-MD5 header for
|
||||
// flexible-checksum PutObject calls, and at presign time (no body) it seeds
|
||||
// MD5-of-empty, which then mismatches any real body uploaded through a plain
|
||||
// http.Client. The raw signer has no such middleware and produces exactly
|
||||
// the kind of URL a browser, curl caller, or custom client would receive.
|
||||
func presignPutURL(t *testing.T, bucket, key, checksumAlgorithm string) string {
|
||||
t.Helper()
|
||||
putURL, err := url.Parse(fmt.Sprintf("%s/%s/%s", strings.TrimRight(defaultConfig.Endpoint, "/"), bucket, key))
|
||||
require.NoError(t, err)
|
||||
q := putURL.Query()
|
||||
// PresignHTTP does not add X-Amz-Expires on its own; the caller must
|
||||
// seed it so the signer picks it up into the canonical query string.
|
||||
q.Set("X-Amz-Expires", "600")
|
||||
if checksumAlgorithm != "" {
|
||||
q.Set("x-amz-sdk-checksum-algorithm", checksumAlgorithm)
|
||||
}
|
||||
putURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, putURL.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
signer := v4.NewSigner()
|
||||
creds := aws.Credentials{
|
||||
AccessKeyID: defaultConfig.AccessKey,
|
||||
SecretAccessKey: defaultConfig.SecretKey,
|
||||
}
|
||||
// For presigned URLs the AWS convention is UNSIGNED-PAYLOAD so the
|
||||
// signer doesn't require a body-hash up front.
|
||||
signedURL, _, err := signer.PresignHTTP(context.TODO(), creds, req,
|
||||
"UNSIGNED-PAYLOAD", "s3", defaultConfig.Region, time.Now(),
|
||||
func(o *v4.SignerOptions) {
|
||||
o.DisableURIPathEscaping = true
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return signedURL
|
||||
}
|
||||
|
||||
// TestPresignedPutWithChecksumSHA256 reproduces issue #9075: a presigned PUT
|
||||
// URL that carries x-amz-sdk-checksum-algorithm=SHA256 in its query string
|
||||
// must cause the object to be stored with an x-amz-checksum-sha256 attribute,
|
||||
// visible via HEAD when the caller requests ChecksumMode=ENABLED.
|
||||
func TestPresignedPutWithChecksumSHA256(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucket := uniqueBucket()
|
||||
createBucket(t, client, bucket)
|
||||
defer cleanupBucket(t, client, bucket)
|
||||
|
||||
key := "presigned-sha256.txt"
|
||||
body := []byte("hello seaweedfs checksum")
|
||||
sha256Sum := sha256.Sum256(body)
|
||||
expected := base64.StdEncoding.EncodeToString(sha256Sum[:])
|
||||
|
||||
signedURL := presignPutURL(t, bucket, key, "SHA256")
|
||||
uploadViaPresignedURL(t, signedURL, body, nil)
|
||||
|
||||
head, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
ChecksumMode: types.ChecksumModeEnabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, head.ChecksumSHA256, "x-amz-checksum-sha256 missing from HEAD response")
|
||||
assert.Equal(t, expected, aws.ToString(head.ChecksumSHA256),
|
||||
"stored SHA256 does not match body")
|
||||
|
||||
// GET with ChecksumMode=ENABLED should also return the header and the body should match.
|
||||
getOut, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
ChecksumMode: types.ChecksumModeEnabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer getOut.Body.Close()
|
||||
require.NotNil(t, getOut.ChecksumSHA256, "x-amz-checksum-sha256 missing from GET response")
|
||||
assert.Equal(t, expected, aws.ToString(getOut.ChecksumSHA256))
|
||||
got, err := io.ReadAll(getOut.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, got)
|
||||
}
|
||||
|
||||
// TestPresignedPutWithoutChecksumAlgorithm is a negative control: when the
|
||||
// caller doesn't request a checksum algorithm, HEAD should not return one.
|
||||
func TestPresignedPutWithoutChecksumAlgorithm(t *testing.T) {
|
||||
client := getS3Client(t)
|
||||
bucket := uniqueBucket()
|
||||
createBucket(t, client, bucket)
|
||||
defer cleanupBucket(t, client, bucket)
|
||||
|
||||
key := "presigned-nosum.txt"
|
||||
body := []byte("no checksum requested")
|
||||
|
||||
signedURL := presignPutURL(t, bucket, key, "")
|
||||
uploadViaPresignedURL(t, signedURL, body, nil)
|
||||
|
||||
head, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
ChecksumMode: types.ChecksumModeEnabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, head.ChecksumSHA256)
|
||||
assert.Nil(t, head.ChecksumSHA1)
|
||||
assert.Nil(t, head.ChecksumCRC32)
|
||||
assert.Nil(t, head.ChecksumCRC32C)
|
||||
}
|
||||
@@ -592,6 +592,7 @@ func (c *distributedLockCluster) tailLog(name string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
|
||||
func stopProcess(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
|
||||
@@ -39,19 +39,19 @@ const (
|
||||
|
||||
// TestCluster manages the weed mini instance for integration testing
|
||||
type TestCluster struct {
|
||||
dataDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
s3Client *s3.S3
|
||||
isRunning bool
|
||||
startOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
masterPort int
|
||||
volumePort int
|
||||
filerPort int
|
||||
s3Port int
|
||||
s3Endpoint string
|
||||
rustVolumeCmd *exec.Cmd
|
||||
dataDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
s3Client *s3.S3
|
||||
isRunning bool
|
||||
startOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
masterPort int
|
||||
volumePort int
|
||||
filerPort int
|
||||
s3Port int
|
||||
s3Endpoint string
|
||||
rustVolumeCmd *exec.Cmd
|
||||
}
|
||||
|
||||
// TestS3Integration demonstrates basic S3 operations against a running weed mini instance
|
||||
|
||||
@@ -317,3 +317,4 @@ func hasKey(policy map[string]interface{}, key string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -703,6 +703,7 @@ func uniqueName(prefix string) string {
|
||||
|
||||
// --- Test setup helpers ---
|
||||
|
||||
|
||||
func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
ports := testutil.MustAllocatePorts(t, 8)
|
||||
masterPort, masterGrpcPort := ports[0], ports[1]
|
||||
@@ -732,12 +733,10 @@ func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
err := os.WriteFile(securityToml, []byte("# Empty security config\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Configure credential store for IAM tests.
|
||||
// Use filer_etc instead of memory because the memory store does not
|
||||
// persist groups or service accounts through LoadConfiguration/SaveConfiguration.
|
||||
// Configure credential store for IAM tests
|
||||
credentialToml := filepath.Join(testDir, "credential.toml")
|
||||
credentialConfig := `
|
||||
[credential.filer_etc]
|
||||
[credential.memory]
|
||||
enabled = true
|
||||
`
|
||||
err = os.WriteFile(credentialToml, []byte(credentialConfig), 0644)
|
||||
@@ -807,6 +806,7 @@ enabled = true
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
|
||||
// startRustVolumeServer starts a Rust volume server that registers with the same master.
|
||||
func (c *TestCluster) startRustVolumeServer(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellAccessKeyLifecycle exercises s3.accesskey.* commands end-to-end.
|
||||
func TestShellAccessKeyLifecycle(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
userName := uniqueName("akuser")
|
||||
// Create user with explicit key so we know the initial value.
|
||||
initialAK := "INITIALAK1234567890X"
|
||||
initialSK := "initialsecret1234567890abcdefghijklmnop"
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.user.create -name %s -access_key %s -secret_key %s", userName, initialAK, initialSK))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
|
||||
t.Run("ListInitialKey", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.accesskey.list -user %s", userName))
|
||||
requireContains(t, out, initialAK, "accesskey.list initial")
|
||||
})
|
||||
|
||||
var createdAK string
|
||||
t.Run("CreateAdditionalKey", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.accesskey.create -user %s", userName))
|
||||
requireContains(t, out, "Access Key:", "accesskey.create output")
|
||||
requireContains(t, out, "Secret Key:", "accesskey.create output")
|
||||
createdAK = extractFieldAfter(out, "Access Key:")
|
||||
if createdAK == "" {
|
||||
t.Fatalf("failed to extract access key from create output:\n%s", out)
|
||||
}
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.accesskey.list -user %s", userName))
|
||||
requireContains(t, out, initialAK, "list contains original")
|
||||
requireContains(t, out, createdAK, "list contains new key")
|
||||
})
|
||||
|
||||
t.Run("RotateKey", func(t *testing.T) {
|
||||
if createdAK == "" {
|
||||
t.Fatal("createdAK is empty; CreateAdditionalKey must run successfully first")
|
||||
}
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.accesskey.rotate -user %s -access_key %s", userName, initialAK))
|
||||
requireContains(t, out, initialAK, "rotate shows old key")
|
||||
requireContains(t, out, "deleted", "rotate marks old key deleted")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.accesskey.list -user %s", userName))
|
||||
requireNotContains(t, out, initialAK, "old key removed")
|
||||
requireContains(t, out, createdAK, "other key still present")
|
||||
})
|
||||
|
||||
t.Run("DeleteKey", func(t *testing.T) {
|
||||
if createdAK == "" {
|
||||
t.Fatal("createdAK is empty; CreateAdditionalKey must run successfully first")
|
||||
}
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.accesskey.delete -user %s -access_key %s", userName, createdAK))
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.accesskey.list -user %s", userName))
|
||||
requireNotContains(t, out, createdAK, "deleted key removed from list")
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellAnonymousAccess exercises s3.anonymous.* commands end-to-end.
|
||||
func TestShellAnonymousAccess(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
bucketName := uniqueName("anon-bkt")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
t.Run("SetAndGet", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.anonymous.set -bucket %s -access Read,List", bucketName))
|
||||
requireContains(t, out, bucketName, "anonymous.set output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.anonymous.get -bucket %s", bucketName))
|
||||
requireContains(t, out, bucketName, "anonymous.get bucket")
|
||||
requireContains(t, out, "Read", "anonymous.get read action")
|
||||
requireContains(t, out, "List", "anonymous.get list action")
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer, "s3.anonymous.list")
|
||||
requireContains(t, out, bucketName, "anonymous.list contains bucket")
|
||||
})
|
||||
|
||||
t.Run("SetNone", func(t *testing.T) {
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.anonymous.set -bucket %s -access none", bucketName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.anonymous.get -bucket %s", bucketName))
|
||||
requireContains(t, out, "none", "anonymous.get after set none")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.anonymous.list")
|
||||
requireNotContains(t, out, bucketName, "anonymous.list after clearing")
|
||||
})
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellBucketLifecycle exercises s3.bucket.* commands end-to-end:
|
||||
// create/list/delete, owner, quota, versioning, lock, quota.enforce.
|
||||
func TestShellBucketLifecycle(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
t.Run("CreateListDelete", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt")
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
requireContains(t, out, bucketName, "bucket.create output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.bucket.list")
|
||||
requireContains(t, out, bucketName, "bucket.list contains created")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.bucket.list")
|
||||
requireNotContains(t, out, bucketName, "bucket.list after delete")
|
||||
})
|
||||
|
||||
t.Run("Owner", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt-own")
|
||||
ownerName := uniqueName("owner")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", ownerName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", ownerName))
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
// Initially no owner.
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.owner -name %s", bucketName))
|
||||
requireContains(t, out, "none", "initial owner none")
|
||||
|
||||
// Set owner.
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.bucket.owner -name %s -owner %s", bucketName, ownerName))
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.owner -name %s", bucketName))
|
||||
requireContains(t, out, ownerName, "owner set")
|
||||
|
||||
// Remove owner.
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.owner -name %s -delete", bucketName))
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.owner -name %s", bucketName))
|
||||
requireContains(t, out, "none", "owner removed")
|
||||
})
|
||||
|
||||
t.Run("Quota", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt-quota")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.bucket.quota -name %s -op=set -sizeMB=1024", bucketName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.quota -name %s -op=get", bucketName))
|
||||
requireContains(t, out, "1024", "quota.get shows size")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.quota -name %s -op=disable", bucketName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.quota -name %s -op=enable", bucketName))
|
||||
|
||||
// Enforce should run on an empty bucket without error.
|
||||
execShell(t, weedCmd, master, filer, "s3.bucket.quota.enforce -apply")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.quota -name %s -op=remove", bucketName))
|
||||
})
|
||||
|
||||
t.Run("Versioning", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt-ver")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.versioning -name %s -enable", bucketName))
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.versioning -name %s", bucketName))
|
||||
requireContains(t, out, "Enabled", "versioning enabled")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.versioning -name %s -suspend", bucketName))
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.versioning -name %s", bucketName))
|
||||
requireContains(t, out, "Suspended", "versioning suspended")
|
||||
})
|
||||
|
||||
t.Run("Lock", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt-lock")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.lock -name %s", bucketName))
|
||||
requireContains(t, out, "Disabled", "lock initially disabled")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.lock -name %s -enable", bucketName))
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.lock -name %s", bucketName))
|
||||
requireContains(t, out, "Enabled", "lock enabled")
|
||||
|
||||
// Versioning should have been auto-enabled.
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.versioning -name %s", bucketName))
|
||||
requireContains(t, out, "Enabled", "versioning auto-enabled by lock")
|
||||
})
|
||||
|
||||
t.Run("CreateWithLock", func(t *testing.T) {
|
||||
bucketName := uniqueName("bkt-wlock")
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.bucket.create -name %s -withLock", bucketName))
|
||||
// Cleanup may fail if the bucket contains locked objects; we created none.
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
|
||||
requireContains(t, out, "Object Lock", "create -withLock output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.lock -name %s", bucketName))
|
||||
requireContains(t, out, "Enabled", "lock enabled after create -withLock")
|
||||
})
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellConfigShow verifies s3.config.show outputs a summary of IAM config.
|
||||
func TestShellConfigShow(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
userName := uniqueName("cfg-user")
|
||||
groupName := uniqueName("cfg-grp")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.create -name %s", groupName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.delete -name %s", groupName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, "s3.config.show")
|
||||
requireContains(t, out, "S3 IAM Configuration Summary", "config.show header")
|
||||
requireContains(t, out, userName, "config.show contains user")
|
||||
requireContains(t, out, groupName, "config.show contains group")
|
||||
}
|
||||
|
||||
// TestShellIAMExportImport does a roundtrip: create resources, export, delete, import, verify.
|
||||
func TestShellIAMExportImport(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
userName := uniqueName("exp-user")
|
||||
groupName := uniqueName("exp-grp")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.create -name %s", groupName))
|
||||
|
||||
exportFile := filepath.Join(t.TempDir(), "iam_export.txt")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.iam.export -file %s", exportFile))
|
||||
|
||||
data, err := os.ReadFile(exportFile)
|
||||
require.NoError(t, err)
|
||||
content := string(data)
|
||||
requireContains(t, content, userName, "export file contains user")
|
||||
requireContains(t, content, groupName, "export file contains group")
|
||||
|
||||
// Delete the resources.
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.delete -name %s", groupName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, "s3.user.list")
|
||||
requireNotContains(t, out, userName, "user gone before import")
|
||||
|
||||
// Import.
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.iam.import -file %s -apply", exportFile))
|
||||
requireContains(t, out, "Imported IAM configuration", "import output")
|
||||
|
||||
// Verify resources restored.
|
||||
out = execShell(t, weedCmd, master, filer, "s3.user.list")
|
||||
requireContains(t, out, userName, "user restored after import")
|
||||
out = execShell(t, weedCmd, master, filer, "s3.group.list")
|
||||
requireContains(t, out, groupName, "group restored after import")
|
||||
|
||||
// Cleanup.
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.delete -name %s", groupName))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellGroupLifecycle exercises s3.group.* commands end-to-end.
|
||||
func TestShellGroupLifecycle(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
groupName := uniqueName("grp")
|
||||
userName := uniqueName("grpuser")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
|
||||
t.Run("CreateShowList", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.create -name %s", groupName))
|
||||
requireContains(t, out, groupName, "group.create output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.show -name %s", groupName))
|
||||
requireContains(t, out, groupName, "group.show output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.group.list")
|
||||
requireContains(t, out, groupName, "group.list")
|
||||
})
|
||||
|
||||
t.Run("AddRemoveUser", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.group.add-user -group %s -user %s", groupName, userName))
|
||||
requireContains(t, out, userName, "group.add-user output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.show -name %s", groupName))
|
||||
requireContains(t, out, userName, "group.show after add")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.group.remove-user -group %s -user %s", groupName, userName))
|
||||
requireContains(t, out, userName, "group.remove-user output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.show -name %s", groupName))
|
||||
requireNotContains(t, out, fmt.Sprintf("\"%s\"", userName), "group.show after remove")
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.group.delete -name %s", groupName))
|
||||
out := execShell(t, weedCmd, master, filer, "s3.group.list")
|
||||
requireNotContains(t, out, fmt.Sprintf("\"%s\"", groupName), "group.list after delete")
|
||||
})
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// requireContains fails the test if substr is not found in output.
|
||||
func requireContains(t *testing.T, output, substr, context string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(output, substr) {
|
||||
t.Fatalf("%s: expected output to contain %q\n--- output ---\n%s\n--- end ---", context, substr, output)
|
||||
}
|
||||
}
|
||||
|
||||
// requireNotContains fails the test if substr IS found in output.
|
||||
func requireNotContains(t *testing.T, output, substr, context string) {
|
||||
t.Helper()
|
||||
if strings.Contains(output, substr) {
|
||||
t.Fatalf("%s: expected output to NOT contain %q\n--- output ---\n%s\n--- end ---", context, substr, output)
|
||||
}
|
||||
}
|
||||
|
||||
// extractFieldAfter returns the first occurrence of the value after a "Prefix: " line.
|
||||
// Example: extractFieldAfter(out, "Access Key:") -> "AKIAXXXX..."
|
||||
// Returns "" if not found.
|
||||
func extractFieldAfter(output, prefix string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitLines splits output into trimmed non-empty lines.
|
||||
func splitLines(output string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// fieldsOf splits a line on whitespace.
|
||||
func fieldsOf(line string) []string {
|
||||
return strings.Fields(line)
|
||||
}
|
||||
|
||||
// extractServiceAccountID parses the tab-separated output of `s3.serviceaccount.list`
|
||||
// and returns the ID of the first row whose PARENT column matches parentUser.
|
||||
// The list output format is:
|
||||
//
|
||||
// ID PARENT STATUS DESCRIPTION
|
||||
// sa:user-yyy:a1b2c3d4e5f6... user-yyy enabled some desc
|
||||
func extractServiceAccountID(t *testing.T, listOutput, parentUser string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(listOutput, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "ID") || strings.HasPrefix(line, "No service accounts") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[1] == parentUser {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
t.Fatalf("could not find service account with parent=%q in output:\n%s", parentUser, listOutput)
|
||||
return ""
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellPolicyAttachDetach exercises s3.policy.attach and s3.policy.detach.
|
||||
func TestShellPolicyAttachDetach(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
// Create a policy via file.
|
||||
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
||||
tmpFile, err := os.CreateTemp("", "test_policy_*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
_, err = tmpFile.WriteString(policyJSON)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpFile.Close())
|
||||
|
||||
policyName := uniqueName("attach-pol")
|
||||
userName := uniqueName("attach-user")
|
||||
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.policy -put -name=%s -file=%s", policyName, tmpFile.Name()))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.policy -delete -name=%s", policyName))
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
|
||||
t.Run("AttachAndVerify", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.policy.attach -policy %s -user %s", policyName, userName))
|
||||
requireContains(t, out, policyName, "policy.attach output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, policyName, "user.show after attach")
|
||||
})
|
||||
|
||||
t.Run("AttachIdempotent", func(t *testing.T) {
|
||||
// Should succeed without error per the command's idempotent design.
|
||||
execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.policy.attach -policy %s -user %s", policyName, userName))
|
||||
})
|
||||
|
||||
t.Run("DetachAndVerify", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.policy.detach -policy %s -user %s", policyName, userName))
|
||||
requireContains(t, out, policyName, "policy.detach output")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireNotContains(t, out, fmt.Sprintf("\"%s\"", policyName), "user.show after detach")
|
||||
})
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellServiceAccountLifecycle exercises s3.serviceaccount.* commands end-to-end.
|
||||
func TestShellServiceAccountLifecycle(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
userName := uniqueName("sauser")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
defer execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
|
||||
var saID string
|
||||
|
||||
t.Run("CreateAndList", func(t *testing.T) {
|
||||
description := "integration-test-sa"
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.serviceaccount.create -user %s -description %s", userName, description))
|
||||
requireContains(t, out, "Created service account", "serviceaccount.create")
|
||||
requireContains(t, out, "Access Key:", "serviceaccount.create credentials")
|
||||
requireContains(t, out, "Secret Key:", "serviceaccount.create credentials")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.serviceaccount.list -user %s", userName))
|
||||
requireContains(t, out, userName, "serviceaccount.list parent column")
|
||||
saID = extractServiceAccountID(t, out, userName)
|
||||
})
|
||||
|
||||
t.Run("Show", func(t *testing.T) {
|
||||
if saID == "" {
|
||||
t.Skip("no saID extracted")
|
||||
}
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.serviceaccount.show -id %s", saID))
|
||||
requireContains(t, out, saID, "show contains id")
|
||||
requireContains(t, out, userName, "show contains parent")
|
||||
requireContains(t, out, "enabled", "show contains status")
|
||||
})
|
||||
|
||||
t.Run("CreateWithActions", func(t *testing.T) {
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.serviceaccount.create -user %s -actions Read,List -expiry 24h", userName))
|
||||
requireContains(t, out, "Access Key:", "serviceaccount.create with options")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.serviceaccount.list -user %s", userName))
|
||||
// Should now have at least 2 service accounts — count lines that start with parent in field[1]
|
||||
count := 0
|
||||
for _, line := range splitLines(out) {
|
||||
fields := fieldsOf(line)
|
||||
if len(fields) >= 2 && fields[1] == userName {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count < 2 {
|
||||
t.Fatalf("expected at least 2 service accounts for %s, got %d\n%s", userName, count, out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
if saID == "" {
|
||||
t.Skip("no saID extracted")
|
||||
}
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.serviceaccount.delete -id %s", saID))
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.serviceaccount.list -user %s", userName))
|
||||
requireNotContains(t, out, saID, "deleted sa removed from list")
|
||||
})
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestShellUserLifecycle exercises the s3.user.* commands end-to-end:
|
||||
// create, show, list, enable, disable, delete, and provision.
|
||||
func TestShellUserLifecycle(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()
|
||||
|
||||
const weedCmd = "weed"
|
||||
master := string(pb.NewServerAddress("127.0.0.1", cluster.masterPort, cluster.masterGrpcPort))
|
||||
filer := string(pb.NewServerAddress("127.0.0.1", cluster.filerPort, cluster.filerGrpcPort))
|
||||
|
||||
t.Run("CreateShowListDelete", func(t *testing.T) {
|
||||
userName := uniqueName("user")
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
requireContains(t, out, userName, "user.create output")
|
||||
requireContains(t, out, "access_key", "user.create JSON")
|
||||
requireContains(t, out, "Secret Key:", "user.create stderr")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, userName, "user.show")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.user.list")
|
||||
requireContains(t, out, userName, "user.list")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, "s3.user.list")
|
||||
requireNotContains(t, out, userName, "user.list after delete")
|
||||
})
|
||||
|
||||
t.Run("CreateWithExplicitKeys", func(t *testing.T) {
|
||||
userName := uniqueName("user-expl")
|
||||
ak := "TESTAK1234567890ABCD"
|
||||
sk := "testsecretkey1234567890abcdefghijklmnopq"
|
||||
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.user.create -name %s -access_key %s -secret_key %s", userName, ak, sk))
|
||||
requireContains(t, out, ak, "user.create with explicit keys")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, ak, "user.show reveals access key")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
})
|
||||
|
||||
t.Run("EnableDisable", func(t *testing.T) {
|
||||
userName := uniqueName("user-toggle")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.create -name %s", userName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.disable -name %s", userName))
|
||||
requireContains(t, out, "disabled", "user.disable")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, "disabled", "user.show after disable")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.enable -name %s", userName))
|
||||
requireContains(t, out, "enabled", "user.enable")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, "enabled", "user.show after enable")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
})
|
||||
|
||||
t.Run("Provision", func(t *testing.T) {
|
||||
userName := uniqueName("prov-user")
|
||||
bucketName := uniqueName("prov-bkt")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucketName))
|
||||
|
||||
out := execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.user.provision -name %s -bucket %s -role readwrite", userName, bucketName))
|
||||
requireContains(t, out, "Created policy", "provision output")
|
||||
requireContains(t, out, "Created user", "provision output")
|
||||
requireContains(t, out, "Access Key:", "provision credentials")
|
||||
requireContains(t, out, "Secret Key:", "provision credentials")
|
||||
|
||||
out = execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.show -name %s", userName))
|
||||
requireContains(t, out, userName, "user.show after provision")
|
||||
|
||||
// Second call with same user but different bucket/role should succeed without creating duplicate user.
|
||||
bucket2 := uniqueName("prov-bkt2")
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.create -name %s", bucket2))
|
||||
out = execShell(t, weedCmd, master, filer,
|
||||
fmt.Sprintf("s3.user.provision -name %s -bucket %s -role readonly", userName, bucket2))
|
||||
requireContains(t, out, "already exists", "provision on existing user")
|
||||
requireContains(t, out, "Created policy", "second policy created")
|
||||
requireContains(t, out, "Attached policy", "second policy attached to existing user")
|
||||
requireNotContains(t, out, "Access Key:", "no new credentials for existing user")
|
||||
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.user.delete -name %s", userName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucketName))
|
||||
execShell(t, weedCmd, master, filer, fmt.Sprintf("s3.bucket.delete -name %s", bucket2))
|
||||
})
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
// Reproduction tests for https://github.com/seaweedfs/seaweedfs/issues/9103
|
||||
//
|
||||
// The issue reports two distinct failure modes when using DuckDB against the
|
||||
// SeaweedFS Iceberg REST catalog:
|
||||
//
|
||||
// 1. `ATTACH 's3://test/' AS cat (TYPE 'ICEBERG', ...); SELECT * FROM cat.ovirt.disk;`
|
||||
// fails with "Table with name 'ovirt.disk' does not exist because schema
|
||||
// 'ovirt' does not exist." The namespace "ovirt" does exist in the bucket.
|
||||
//
|
||||
// 2. `SELECT * FROM iceberg_scan('s3://test/ovirt/disk');` fails with HTTP 403
|
||||
// AccessDenied when DuckDB tries to glob `s3://test/ovirt/disk/metadata/v*`
|
||||
// because the LoadTable response does not vend S3 file-io credentials back
|
||||
// to the client.
|
||||
//
|
||||
// These tests reproduce the underlying catalog-level misbehavior at the REST
|
||||
// protocol layer so they run quickly and deterministically. A DuckDB-based
|
||||
// end-to-end reproduction is also included (gated on Docker availability).
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
// TestIssue9103_ConfigDoesNotVendWarehousePrefix reproduces failure mode #2.
|
||||
//
|
||||
// Per the Iceberg REST spec, a client attaching with warehouse=s3://<bucket>/
|
||||
// calls GET /v1/config?warehouse=s3://<bucket>/ and expects the server to
|
||||
// return an `overrides.prefix` that identifies the catalog namespace for
|
||||
// subsequent requests. Without it the client falls back to unprefixed paths
|
||||
// like /v1/namespaces, which on SeaweedFS resolve to the hard-coded default
|
||||
// bucket ("warehouse") and therefore do not list the user's namespaces.
|
||||
//
|
||||
// Currently the handler ignores the warehouse query parameter and returns
|
||||
// empty defaults/overrides. That is exactly what makes DuckDB's ATTACH flow
|
||||
// report `schema "ovirt" does not exist`.
|
||||
func TestIssue9103_ConfigDoesNotVendWarehousePrefix(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
bucketName := "warehouse-9103cfg-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
|
||||
warehouse := fmt.Sprintf("s3://%s/", bucketName)
|
||||
u := fmt.Sprintf("%s/v1/config?warehouse=%s", env.IcebergURL(), url.QueryEscape(warehouse))
|
||||
|
||||
status, body, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/config?warehouse=%s", url.QueryEscape(warehouse)), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s failed: %v", u, err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200", u, status)
|
||||
}
|
||||
|
||||
overrides, _ := body["overrides"].(map[string]any)
|
||||
gotPrefix, _ := overrides["prefix"].(string)
|
||||
if gotPrefix != bucketName {
|
||||
t.Fatalf("GET /v1/config?warehouse=%s: overrides.prefix = %q, want %q (catalog must echo the warehouse's table bucket so clients like DuckDB know which /v1/{prefix}/namespaces to use)",
|
||||
warehouse, gotPrefix, bucketName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket
|
||||
// demonstrates the downstream effect of the /v1/config bug.
|
||||
//
|
||||
// The client created the namespace "ovirt" inside bucket "test" (via
|
||||
// /v1/{bucket}/namespaces), but when it later issues GET /v1/namespaces (no
|
||||
// prefix, because /v1/config didn't give it one), that request resolves to
|
||||
// the default bucket and returns no namespaces. DuckDB surfaces this as
|
||||
// `schema "ovirt" does not exist`.
|
||||
func TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
env := sharedEnv
|
||||
bucketName := "warehouse-9103ns-" + randomSuffix()
|
||||
createTableBucket(t, env, bucketName)
|
||||
|
||||
namespace := "ovirt"
|
||||
status, _, err := doIcebergJSONRequest(env, http.MethodPost,
|
||||
icebergPath(bucketName, "/v1/namespaces"),
|
||||
map[string]any{"namespace": []string{namespace}})
|
||||
if err != nil {
|
||||
t.Fatalf("create namespace: %v", err)
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusConflict {
|
||||
t.Fatalf("create namespace status = %d, want 200 or 409", status)
|
||||
}
|
||||
|
||||
// Sanity: prefixed listing finds the namespace.
|
||||
status, prefixedBody, err := doIcebergJSONRequest(env, http.MethodGet,
|
||||
icebergPath(bucketName, "/v1/namespaces"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("list namespaces with prefix: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("prefixed list status = %d, want 200", status)
|
||||
}
|
||||
if !containsNamespace(prefixedBody, namespace) {
|
||||
t.Fatalf("prefixed list missing namespace %q: %v", namespace, prefixedBody)
|
||||
}
|
||||
|
||||
// Simulate what a DuckDB-style client does when /v1/config did not vend a
|
||||
// prefix: it falls back to the unprefixed listing. It should still be
|
||||
// able to discover the namespace (e.g. via a warehouse query parameter),
|
||||
// but today it cannot, which is the user-visible bug.
|
||||
bareWarehouse := "s3://" + bucketName + "/"
|
||||
status, bareBody, err := doIcebergJSONRequest(env, http.MethodGet,
|
||||
fmt.Sprintf("/v1/namespaces?warehouse=%s", url.QueryEscape(bareWarehouse)), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("bare list namespaces: %v", err)
|
||||
}
|
||||
switch {
|
||||
case status != http.StatusOK:
|
||||
t.Fatalf("GET /v1/namespaces?warehouse=%s status = %d, want 200 (server ignores the warehouse query parameter and falls back to the default %q bucket, causing DuckDB to see no schemas)",
|
||||
bareWarehouse, status, "warehouse")
|
||||
case !containsNamespace(bareBody, namespace):
|
||||
t.Fatalf("GET /v1/namespaces?warehouse=%s did not return %q: %v (server must honor the warehouse query parameter here or vend overrides.prefix from /v1/config so the client can route to the right bucket)",
|
||||
bareWarehouse, namespace, bareBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue9103_DuckDBAttachCannotResolveNamespace is the end-to-end
|
||||
// reproduction of the issue using DuckDB in Docker, mirroring the exact
|
||||
// sequence of commands from the bug report. It runs under its own weed
|
||||
// mini with IAM configured so the OAuth2 client_credentials flow that
|
||||
// DuckDB's iceberg extension requires actually works (the shared env has
|
||||
// no credentials registered). Gated on Docker availability.
|
||||
func TestIssue9103_DuckDBAttachCannotResolveNamespace(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
if !testutil.HasDocker() {
|
||||
t.Skip("Docker not available, skipping DuckDB integration reproduction")
|
||||
}
|
||||
|
||||
env := newOAuthTestEnv(t)
|
||||
defer env.cleanup(t)
|
||||
env.start(t)
|
||||
|
||||
bucketName := "test9103-" + randomSuffix()
|
||||
createTableBucketViaShell(t, env, bucketName)
|
||||
|
||||
namespace := "ovirt"
|
||||
tableName := "disk"
|
||||
|
||||
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
|
||||
createNamespaceWithToken(t, env, token, bucketName, namespace)
|
||||
createTableWithToken(t, env, token, bucketName, namespace, tableName)
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
INSTALL iceberg;
|
||||
LOAD iceberg;
|
||||
|
||||
CREATE SECRET test_berg (
|
||||
TYPE ICEBERG,
|
||||
ENDPOINT 'http://host.docker.internal:%d',
|
||||
SCOPE 's3://%s/',
|
||||
CLIENT_ID '%s',
|
||||
CLIENT_SECRET '%s'
|
||||
);
|
||||
|
||||
CREATE SECRET s3_berg (
|
||||
TYPE S3,
|
||||
KEY_ID '%s',
|
||||
SECRET '%s',
|
||||
ENDPOINT 'host.docker.internal:%d',
|
||||
URL_STYLE 'path',
|
||||
USE_SSL false,
|
||||
SCOPE 's3://%s/'
|
||||
);
|
||||
|
||||
ATTACH 's3://%s/' AS test_catalog (TYPE 'ICEBERG', secret test_berg);
|
||||
SELECT * FROM test_catalog.%s.%s LIMIT 0;
|
||||
`, env.icebergPort, bucketName,
|
||||
env.accessKey, env.secretKey,
|
||||
env.accessKey, env.secretKey, env.s3Port, bucketName,
|
||||
bucketName, namespace, tableName)
|
||||
|
||||
sqlFile := filepath.Join(env.dataDir, "issue_9103.sql")
|
||||
if err := os.WriteFile(sqlFile, []byte(sql), 0644); err != nil {
|
||||
t.Fatalf("write SQL: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "run", "--rm",
|
||||
"-v", fmt.Sprintf("%s:/test", env.dataDir),
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-e", "AWS_REGION=us-east-1",
|
||||
"--entrypoint", "duckdb",
|
||||
"duckdb/duckdb:latest",
|
||||
"-init", "/test/issue_9103.sql",
|
||||
"-c", "SELECT 1",
|
||||
)
|
||||
|
||||
out, runErr := cmd.CombinedOutput()
|
||||
outStr := string(out)
|
||||
t.Logf("DuckDB output:\n%s", outStr)
|
||||
|
||||
if strings.Contains(outStr, "iceberg extension is not available") ||
|
||||
strings.Contains(outStr, "Failed to load") ||
|
||||
strings.Contains(outStr, "could not fetch extension") ||
|
||||
strings.Contains(outStr, "failed to download") {
|
||||
t.Skip("Iceberg extension not available in DuckDB Docker image")
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
if strings.Contains(outStr, "does not exist because schema") &&
|
||||
strings.Contains(outStr, namespace) {
|
||||
t.Fatalf("reproduced issue #9103: DuckDB cannot see namespace %q after ATTACH 's3://%s/'; see output above",
|
||||
namespace, bucketName)
|
||||
}
|
||||
t.Fatalf("DuckDB run failed for an unexpected reason: %v", runErr)
|
||||
}
|
||||
}
|
||||
|
||||
// createTableWithToken creates an Iceberg table using an OAuth bearer token.
|
||||
func createTableWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName string) {
|
||||
t.Helper()
|
||||
|
||||
payload := map[string]any{
|
||||
"name": tableName,
|
||||
"schema": map[string]any{
|
||||
"type": "struct",
|
||||
"schema-id": 0,
|
||||
"fields": []map[string]any{
|
||||
{"id": 1, "name": "id", "required": true, "type": "long"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create-table payload: %v", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.icebergURL(), bucketName, namespace)
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create table failed: status=%d body=%s", resp.StatusCode, respBody)
|
||||
}
|
||||
t.Logf("Created table %s.%s in bucket %s", namespace, tableName, bucketName)
|
||||
}
|
||||
|
||||
// containsNamespace checks whether an Iceberg REST ListNamespaces response
|
||||
// contains the given single-level namespace.
|
||||
func containsNamespace(body map[string]any, name string) bool {
|
||||
arr, _ := body["namespaces"].([]any)
|
||||
for _, item := range arr {
|
||||
parts, _ := item.([]any)
|
||||
if len(parts) == 1 {
|
||||
if s, ok := parts[0].(string); ok && s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package catalog_trino
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTrinoCreateDropRecreateTable is a regression test for
|
||||
// https://github.com/seaweedfs/seaweedfs/issues/9074
|
||||
//
|
||||
// Trino CTAS was failing with "Cannot create a table on a non-empty
|
||||
// location" because Trino's pre-write check saw leftover data files from
|
||||
// an earlier table at the same S3 path — DROP TABLE removed the catalog
|
||||
// entry but left the underlying files behind. The Iceberg REST server now
|
||||
// treats the S3 Tables catalog as the authority on table existence: when a
|
||||
// DROP succeeds, it purges the storage under the table's location so that
|
||||
// a subsequent CREATE at the same path finds it empty.
|
||||
//
|
||||
// This test pins an explicit location (so the recreated table lands at the
|
||||
// exact same path, matching what the bug reporter saw), then walks through:
|
||||
// 1. CREATE TABLE with explicit location
|
||||
// 2. INSERT rows (produces data files under that location)
|
||||
// 3. DROP TABLE (catalog entry removed, data files should also be purged)
|
||||
// 4. CREATE TABLE again at the same explicit location — previously failed
|
||||
// with "Cannot create a table on a non-empty location"
|
||||
// 5. CREATE TABLE AS SELECT on top of the recreated table, which is the
|
||||
// exact operation reported in the issue.
|
||||
func TestTrinoCreateDropRecreateTable(t *testing.T) {
|
||||
env := setupTrinoTest(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
tableBucket := "iceberg-tables"
|
||||
schemaName := "recreate_" + randomString(6)
|
||||
tableName := "events_" + randomString(6)
|
||||
ctasName := "events_ctas_" + randomString(6)
|
||||
tableLocation := fmt.Sprintf("s3://%s/%s/%s", tableBucket, schemaName, tableName)
|
||||
ctasLocation := fmt.Sprintf("s3://%s/%s/%s", tableBucket, schemaName, ctasName)
|
||||
qualified := fmt.Sprintf("iceberg.%s.%s", schemaName, tableName)
|
||||
ctasQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, ctasName)
|
||||
|
||||
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
|
||||
defer runTrinoSQLAllowNamespaceNotEmpty(t, env.trinoContainer, fmt.Sprintf("DROP SCHEMA IF EXISTS iceberg.%s", schemaName))
|
||||
// Registered after the DROP SCHEMA defer so LIFO ordering drops the
|
||||
// tables before the schema. Without this, a failure between the
|
||||
// CREATE TABLE below and the explicit DROP later in the test would
|
||||
// leak the table and block the deferred schema drop.
|
||||
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", qualified))
|
||||
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", ctasQualified))
|
||||
|
||||
createSQL := fmt.Sprintf(`CREATE TABLE %s (
|
||||
id INTEGER,
|
||||
label VARCHAR
|
||||
) WITH (
|
||||
format = 'PARQUET',
|
||||
location = '%s'
|
||||
)`, qualified, tableLocation)
|
||||
|
||||
t.Logf(">>> CREATE #1: %s (location=%s)", qualified, tableLocation)
|
||||
runTrinoSQL(t, env.trinoContainer, createSQL)
|
||||
|
||||
t.Logf(">>> INSERT into %s", qualified)
|
||||
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
|
||||
"INSERT INTO %s VALUES (1, 'alpha'), (2, 'beta')", qualified))
|
||||
|
||||
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", qualified))
|
||||
if got := mustParseCSVInt64(t, countOutput); got != 2 {
|
||||
t.Fatalf("after first insert: expected 2 rows, got %d", got)
|
||||
}
|
||||
|
||||
t.Logf(">>> DROP %s", qualified)
|
||||
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE %s", qualified))
|
||||
|
||||
// The recreate must succeed at the exact same location; DROP is expected
|
||||
// to have purged the data files so Trino's pre-write check sees an empty
|
||||
// path.
|
||||
t.Logf(">>> CREATE #2 at the same location: %s", qualified)
|
||||
runTrinoSQL(t, env.trinoContainer, createSQL)
|
||||
|
||||
t.Logf(">>> INSERT into recreated %s", qualified)
|
||||
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
|
||||
"INSERT INTO %s VALUES (10, 'gamma')", qualified))
|
||||
|
||||
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", qualified))
|
||||
if got := mustParseCSVInt64(t, countOutput); got != 1 {
|
||||
t.Fatalf("after recreate+insert: expected 1 row (not %d) — recreated table should not see dropped data", got)
|
||||
}
|
||||
|
||||
labelOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT label FROM %s WHERE id = 10", qualified))
|
||||
if !strings.Contains(labelOutput, "gamma") {
|
||||
t.Fatalf("recreated table missing expected row; got:\n%s", labelOutput)
|
||||
}
|
||||
|
||||
// CTAS on top of the recreated table is the exact operation from #9074.
|
||||
ctasSQL := fmt.Sprintf(`CREATE TABLE %s
|
||||
WITH (
|
||||
format = 'PARQUET',
|
||||
location = '%s'
|
||||
)
|
||||
AS SELECT * FROM %s`, ctasQualified, ctasLocation, qualified)
|
||||
|
||||
t.Logf(">>> CTAS %s FROM %s", ctasQualified, qualified)
|
||||
runTrinoSQL(t, env.trinoContainer, ctasSQL)
|
||||
|
||||
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
|
||||
if got := mustParseCSVInt64(t, countOutput); got != 1 {
|
||||
t.Fatalf("CTAS target: expected 1 row, got %d", got)
|
||||
}
|
||||
|
||||
// Drop the CTAS target and recreate it at the same path to make sure
|
||||
// the drop-cleanup path is exercised for CTAS too.
|
||||
t.Logf(">>> DROP then CTAS-recreate %s at the same location", ctasQualified)
|
||||
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE %s", ctasQualified))
|
||||
runTrinoSQL(t, env.trinoContainer, ctasSQL)
|
||||
|
||||
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
|
||||
if got := mustParseCSVInt64(t, countOutput); got != 1 {
|
||||
t.Fatalf("CTAS after drop-recreate: expected 1 row, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/shell"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type TestCluster struct {
|
||||
masterCmd *exec.Cmd
|
||||
volumeServers []*exec.Cmd
|
||||
}
|
||||
|
||||
func (c *TestCluster) Stop() {
|
||||
for _, cmd := range c.volumeServers {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}
|
||||
if c.masterCmd != nil && c.masterCmd.Process != nil {
|
||||
c.masterCmd.Process.Kill()
|
||||
c.masterCmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func startCluster(ctx context.Context, dataDir string) (*TestCluster, error) {
|
||||
weedBinary := findWeedBinary()
|
||||
if weedBinary == "" {
|
||||
return nil, fmt.Errorf("weed binary not found - build with 'cd weed && go build' first")
|
||||
}
|
||||
|
||||
cluster := &TestCluster{}
|
||||
|
||||
masterDir := filepath.Join(dataDir, "master")
|
||||
os.MkdirAll(masterDir, 0755)
|
||||
|
||||
// Empty security.toml to disable JWT in tests
|
||||
os.WriteFile(filepath.Join(dataDir, "security.toml"), []byte("# test\n"), 0644)
|
||||
|
||||
// Start master
|
||||
masterCmd := exec.CommandContext(ctx, weedBinary, "master",
|
||||
"-port", "9333",
|
||||
"-mdir", masterDir,
|
||||
"-volumeSizeLimitMB", "10",
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
masterCmd.Dir = dataDir
|
||||
masterLog, _ := os.Create(filepath.Join(masterDir, "master.log"))
|
||||
masterCmd.Stdout = masterLog
|
||||
masterCmd.Stderr = masterLog
|
||||
if err := masterCmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start master: %v", err)
|
||||
}
|
||||
cluster.masterCmd = masterCmd
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Start 2 volume servers (enough for vacuum testing)
|
||||
for i := 0; i < 2; i++ {
|
||||
volumeDir := filepath.Join(dataDir, fmt.Sprintf("volume%d", i))
|
||||
os.MkdirAll(volumeDir, 0755)
|
||||
|
||||
port := fmt.Sprintf("808%d", i)
|
||||
volumeCmd := exec.CommandContext(ctx, weedBinary, "volume",
|
||||
"-port", port,
|
||||
"-dir", volumeDir,
|
||||
"-max", "10",
|
||||
"-master", "127.0.0.1:9333",
|
||||
"-ip", "127.0.0.1",
|
||||
)
|
||||
volumeCmd.Dir = dataDir
|
||||
volumeLog, _ := os.Create(filepath.Join(volumeDir, "volume.log"))
|
||||
volumeCmd.Stdout = volumeLog
|
||||
volumeCmd.Stderr = volumeLog
|
||||
if err := volumeCmd.Start(); err != nil {
|
||||
cluster.Stop()
|
||||
return nil, fmt.Errorf("start volume server %d: %v", i, err)
|
||||
}
|
||||
cluster.volumeServers = append(cluster.volumeServers, volumeCmd)
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func findWeedBinary() string {
|
||||
candidates := []string{
|
||||
"../../weed/weed",
|
||||
"../weed/weed",
|
||||
"./weed",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
if abs, err := filepath.Abs(c); err == nil {
|
||||
return abs
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("weed"); err == nil {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForServer(address string, timeout time.Duration) error {
|
||||
start := time.Now()
|
||||
for time.Since(start) < timeout {
|
||||
if conn, err := net.DialTimeout("tcp", address, 1*time.Second); err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for server %s", address)
|
||||
}
|
||||
|
||||
func uploadData(masterAddr, collection string, data []byte) (string, needle.VolumeId, error) {
|
||||
assignResult, err := operation.Assign(context.Background(), func(ctx context.Context) pb.ServerAddress {
|
||||
return pb.ServerAddress(masterAddr)
|
||||
}, grpc.WithInsecure(), &operation.VolumeAssignRequest{
|
||||
Count: 1,
|
||||
Collection: collection,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("assign: %v", err)
|
||||
}
|
||||
|
||||
uploader, err := operation.NewUploader()
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("new uploader: %v", err)
|
||||
}
|
||||
|
||||
uploadResult, err, _ := uploader.Upload(context.Background(), bytes.NewReader(data), &operation.UploadOption{
|
||||
UploadUrl: "http://" + assignResult.Url + "/" + assignResult.Fid,
|
||||
Filename: "testfile.txt",
|
||||
MimeType: "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("upload: %v", err)
|
||||
}
|
||||
if uploadResult.Error != "" {
|
||||
return "", 0, fmt.Errorf("upload error: %s", uploadResult.Error)
|
||||
}
|
||||
|
||||
fid, err := needle.ParseFileIdFromString(assignResult.Fid)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return assignResult.Fid, fid.VolumeId, nil
|
||||
}
|
||||
|
||||
func deleteFile(masterAddr string, fid string) error {
|
||||
results := operation.DeleteFileIds(func(ctx context.Context) pb.ServerAddress {
|
||||
return pb.ServerAddress(masterAddr)
|
||||
}, false, grpc.WithInsecure(), []string{fid})
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
return fmt.Errorf("delete %s: %s", fid, r.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGarbageRatio(volumeServerAddr string, volumeId uint32) (float64, error) {
|
||||
var ratio float64
|
||||
err := operation.WithVolumeServerClient(false, pb.ServerAddress(volumeServerAddr), grpc.WithInsecure(),
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
resp, err := client.VacuumVolumeCheck(context.Background(), &volume_server_pb.VacuumVolumeCheckRequest{
|
||||
VolumeId: volumeId,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ratio = resp.GarbageRatio
|
||||
return nil
|
||||
})
|
||||
return ratio, err
|
||||
}
|
||||
|
||||
// TestVacuumIntegration tests the full vacuum flow:
|
||||
// upload data → delete some → verify garbage → vacuum → verify cleanup
|
||||
func TestVacuumIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := startCluster(ctx, testDir)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
require.NoError(t, waitForServer("127.0.0.1:9333", 30*time.Second))
|
||||
require.NoError(t, waitForServer("127.0.0.1:8080", 30*time.Second))
|
||||
require.NoError(t, waitForServer("127.0.0.1:8081", 30*time.Second))
|
||||
|
||||
masterAddr := "127.0.0.1:9333"
|
||||
collection := "vactest"
|
||||
|
||||
// Upload files large enough that deleting most creates significant garbage.
|
||||
// With volumeSizeLimitMB=10, we need several MB of garbage to exceed the
|
||||
// 10% threshold passed to vacuum.
|
||||
const fileSize = 500 * 1024 // 500 KB per file
|
||||
const totalFiles = 16
|
||||
const filesToDelete = 12 // delete 75% → ~6 MB garbage out of ~8 MB
|
||||
|
||||
var fids []string
|
||||
var payloads [][]byte
|
||||
var fileVolumes []needle.VolumeId
|
||||
for i := 0; i < totalFiles; i++ {
|
||||
data := bytes.Repeat([]byte{byte('A' + i%26)}, fileSize)
|
||||
fid, vid, err := uploadData(masterAddr, collection, data)
|
||||
require.NoError(t, err, "upload %d", i)
|
||||
fids = append(fids, fid)
|
||||
payloads = append(payloads, data)
|
||||
fileVolumes = append(fileVolumes, vid)
|
||||
}
|
||||
// Collect the set of volumes that will contain garbage after the deletes below.
|
||||
// The master may spread uploads across multiple volumes, so we cannot assume
|
||||
// a single volume id holds all the garbage.
|
||||
dirtyVolumesSet := map[needle.VolumeId]struct{}{}
|
||||
for i := 0; i < filesToDelete; i++ {
|
||||
dirtyVolumesSet[fileVolumes[i]] = struct{}{}
|
||||
}
|
||||
var dirtyVolumes []needle.VolumeId
|
||||
for v := range dirtyVolumesSet {
|
||||
dirtyVolumes = append(dirtyVolumes, v)
|
||||
}
|
||||
// Sort for deterministic log output and stable iteration order across runs.
|
||||
sort.Slice(dirtyVolumes, func(i, j int) bool { return dirtyVolumes[i] < dirtyVolumes[j] })
|
||||
t.Logf("Uploaded %d files (%d KB each) across volumes %v; will delete from volumes %v",
|
||||
totalFiles, fileSize/1024, fileVolumes, dirtyVolumes)
|
||||
|
||||
// Wait for heartbeat to report sizes
|
||||
time.Sleep(6 * time.Second)
|
||||
|
||||
// Delete most files to create garbage well above the threshold
|
||||
for i := 0; i < filesToDelete; i++ {
|
||||
err := deleteFile(masterAddr, fids[i])
|
||||
require.NoError(t, err, "delete %s", fids[i])
|
||||
}
|
||||
t.Logf("Deleted %d of %d files to create garbage", filesToDelete, totalFiles)
|
||||
|
||||
// Wait for heartbeat to report deletions
|
||||
time.Sleep(6 * time.Second)
|
||||
|
||||
// Verify garbage exists on every volume we deleted from.
|
||||
// Retry briefly in case heartbeats / deletions have not fully settled.
|
||||
// We require all dirty volumes to report garbage > threshold so that
|
||||
// the subsequent vacuum + cleanup check has a well-defined expectation
|
||||
// for every volume, not just the first one that happens to be ready.
|
||||
t.Run("verify_garbage_before_vacuum", func(t *testing.T) {
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
var lastMissing needle.VolumeId
|
||||
for {
|
||||
ready := true
|
||||
for _, vid := range dirtyVolumes {
|
||||
volumeReady := false
|
||||
for _, addr := range []string{"127.0.0.1:8080", "127.0.0.1:8081"} {
|
||||
ratio, err := getGarbageRatio(addr, uint32(vid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t.Logf("Garbage ratio for volume %d on %s: %.2f%%", vid, addr, ratio*100)
|
||||
if ratio > 0.1 {
|
||||
volumeReady = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !volumeReady {
|
||||
ready = false
|
||||
lastMissing = vid
|
||||
break
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
t.Fatalf("volume %d did not report garbage > 10%% — test data setup failed", lastMissing)
|
||||
})
|
||||
|
||||
// Execute vacuum via shell command
|
||||
t.Run("run_vacuum", func(t *testing.T) {
|
||||
options := &shell.ShellOptions{
|
||||
Masters: stringPtr(masterAddr),
|
||||
GrpcDialOption: grpc.WithInsecure(),
|
||||
FilerGroup: stringPtr("default"),
|
||||
}
|
||||
commandEnv := shell.NewCommandEnv(options)
|
||||
|
||||
shellCtx, shellCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer shellCancel()
|
||||
go commandEnv.MasterClient.KeepConnectedToMaster(shellCtx)
|
||||
commandEnv.MasterClient.WaitUntilConnected(shellCtx)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Acquire lock (required by shell commands)
|
||||
locked, unlock := tryLock(t, commandEnv, 30*time.Second)
|
||||
require.True(t, locked, "could not acquire shell lock")
|
||||
defer unlock()
|
||||
|
||||
// Find and execute vacuum command
|
||||
var output bytes.Buffer
|
||||
var found bool
|
||||
var err error
|
||||
for _, cmd := range shell.Commands {
|
||||
if cmd.Name() == "volume.vacuum" {
|
||||
err = cmd.Do(
|
||||
[]string{"-garbageThreshold", "0.1", "-collection", collection},
|
||||
commandEnv, &output,
|
||||
)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "volume.vacuum command not found")
|
||||
t.Logf("Vacuum output: %s", output.String())
|
||||
require.NoError(t, err, "vacuum command failed")
|
||||
t.Log("Vacuum completed successfully")
|
||||
})
|
||||
|
||||
// Verify garbage was cleaned on every volume we deleted from.
|
||||
// Vacuum + heartbeat reporting is asynchronous, so retry until each
|
||||
// volume reports a cleaned ratio or the deadline expires.
|
||||
t.Run("verify_cleanup_after_vacuum", func(t *testing.T) {
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
remaining := map[needle.VolumeId]struct{}{}
|
||||
for _, vid := range dirtyVolumes {
|
||||
remaining[vid] = struct{}{}
|
||||
}
|
||||
failureReasons := map[needle.VolumeId]string{}
|
||||
for {
|
||||
for vid := range remaining {
|
||||
var volumeFound, cleanupVerified bool
|
||||
for _, addr := range []string{"127.0.0.1:8080", "127.0.0.1:8081"} {
|
||||
ratio, err := getGarbageRatio(addr, uint32(vid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
volumeFound = true
|
||||
t.Logf("Garbage ratio for volume %d after vacuum on %s: %.2f%%", vid, addr, ratio*100)
|
||||
if ratio < 0.05 {
|
||||
cleanupVerified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !volumeFound:
|
||||
failureReasons[vid] = fmt.Sprintf("no server reported volume %d after vacuum", vid)
|
||||
case !cleanupVerified:
|
||||
failureReasons[vid] = fmt.Sprintf("garbage on volume %d was not cleaned up after vacuum", vid)
|
||||
default:
|
||||
delete(remaining, vid)
|
||||
delete(failureReasons, vid)
|
||||
}
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
stillFailing := make([]needle.VolumeId, 0, len(remaining))
|
||||
for vid := range remaining {
|
||||
stillFailing = append(stillFailing, vid)
|
||||
}
|
||||
sort.Slice(stillFailing, func(i, j int) bool { return stillFailing[i] < stillFailing[j] })
|
||||
msgs := make([]string, 0, len(stillFailing))
|
||||
for _, vid := range stillFailing {
|
||||
msgs = append(msgs, failureReasons[vid])
|
||||
}
|
||||
t.Fatalf("cleanup verification failed for %d volume(s): %s",
|
||||
len(stillFailing), strings.Join(msgs, "; "))
|
||||
})
|
||||
|
||||
// Verify remaining files are still readable with correct contents
|
||||
t.Run("verify_remaining_data", func(t *testing.T) {
|
||||
for i := filesToDelete; i < totalFiles; i++ {
|
||||
fid := fids[i]
|
||||
expected := payloads[i]
|
||||
|
||||
// Read file via HTTP from volume server
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
url := fmt.Sprintf("http://127.0.0.1:8080/%s", fid)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil || resp.StatusCode == http.StatusNotFound {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
url = fmt.Sprintf("http://127.0.0.1:8081/%s", fid)
|
||||
resp, err = client.Get(url)
|
||||
}
|
||||
require.NoError(t, err, "read fid %s", fid)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err, "read body of fid %s", fid)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "fid %s returned %d", fid, resp.StatusCode)
|
||||
require.Equal(t, len(expected), len(body), "fid %s size mismatch", fid)
|
||||
require.True(t, bytes.Equal(expected, body), "fid %s content mismatch", fid)
|
||||
t.Logf("File %s verified (%d bytes)", fid, len(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func tryLock(t *testing.T, commandEnv *shell.CommandEnv, timeout time.Duration) (locked bool, unlock func()) {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
for _, cmd := range shell.Commands {
|
||||
if cmd.Name() == "lock" {
|
||||
var out bytes.Buffer
|
||||
done <- result{err: cmd.Do([]string{}, commandEnv, &out)}
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- result{err: fmt.Errorf("lock command not found")}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
t.Logf("lock failed: %v", res.err)
|
||||
return false, nil
|
||||
}
|
||||
return true, func() {
|
||||
for _, cmd := range shell.Commands {
|
||||
if cmd.Name() == "unlock" {
|
||||
var out bytes.Buffer
|
||||
cmd.Do([]string{}, commandEnv, &out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
case <-time.After(timeout):
|
||||
t.Log("lock timed out")
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,7 @@ func stopProcess(cmd *exec.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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_")
|
||||
|
||||
@@ -778,8 +778,8 @@ func TestEcIndexConsistencyAfterEncode(t *testing.T) {
|
||||
needles := []testNeedle{
|
||||
{framework.NewFileID(volumeID, 1001, 0xAABB0001), []byte("small-needle-1")},
|
||||
{framework.NewFileID(volumeID, 1002, 0xAABB0002), make([]byte, 1024)}, // 1KB
|
||||
{framework.NewFileID(volumeID, 1003, 0xAABB0003), make([]byte, 64*1024)}, // 64KB
|
||||
{framework.NewFileID(volumeID, 1004, 0xAABB0004), make([]byte, 256*1024)}, // 256KB
|
||||
{framework.NewFileID(volumeID, 1003, 0xAABB0003), make([]byte, 64*1024)}, // 64KB
|
||||
{framework.NewFileID(volumeID, 1004, 0xAABB0004), make([]byte, 256*1024)}, // 256KB
|
||||
{framework.NewFileID(volumeID, 1005, 0xAABB0005), []byte("small-needle-2")},
|
||||
}
|
||||
|
||||
|
||||
@@ -459,10 +459,9 @@ func sortDurations(d []time.Duration) {
|
||||
// This reveals tail latency differences that short tests miss (GC pauses, lock contention, etc).
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
// VOLUME_SERVER_IMPL=rust go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
// LOADTEST_DURATION=120s VOLUME_SERVER_IMPL=rust go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
// go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
// VOLUME_SERVER_IMPL=rust go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
// LOADTEST_DURATION=120s VOLUME_SERVER_IMPL=rust go test -v -count=1 -timeout 600s -run TestSustainedP99 ./test/volume_server/loadtest/...
|
||||
func TestSustainedP99(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping sustained load test in short mode")
|
||||
|
||||
@@ -2,7 +2,6 @@ package dash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -217,65 +216,36 @@ func (s *AdminServer) ShowOverview(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, topology)
|
||||
}
|
||||
|
||||
// getMasterNodesStatus returns the full set of master nodes in the cluster.
|
||||
// It prefers the authoritative raft membership (RaftListClusterServers) and
|
||||
// falls back to the currently-connected master if the raft call fails, so the
|
||||
// dashboard never shows an empty list.
|
||||
// getMasterNodesStatus checks status of all master nodes
|
||||
func (s *AdminServer) getMasterNodesStatus() []MasterNode {
|
||||
masterMap := make(map[string]MasterNode)
|
||||
raftCallSucceeded := false
|
||||
var masterNodes []MasterNode
|
||||
|
||||
// Since we have a single master address, create one entry
|
||||
var isLeader bool = true // Assume leader since it's the only master we know about
|
||||
|
||||
// Try to get leader info from this master
|
||||
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
resp, err := client.RaftListClusterServers(ctx, &master_pb.RaftListClusterServersRequest{})
|
||||
_, err := client.GetMasterConfiguration(context.Background(), &master_pb.GetMasterConfigurationRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raftCallSucceeded = true
|
||||
for _, server := range resp.ClusterServers {
|
||||
// pb.GrpcAddressToServerAddress calls glog.Fatalf on a parse
|
||||
// error, so pre-validate the raft address with net.SplitHostPort
|
||||
// and skip malformed entries instead of taking the process down.
|
||||
if _, _, splitErr := net.SplitHostPort(server.Address); splitErr != nil {
|
||||
glog.Warningf("skip master with invalid raft address %q: %v", server.Address, splitErr)
|
||||
continue
|
||||
}
|
||||
httpAddress := pb.GrpcAddressToServerAddress(server.Address)
|
||||
masterMap[httpAddress] = MasterNode{
|
||||
Address: httpAddress,
|
||||
IsLeader: server.IsLeader,
|
||||
}
|
||||
}
|
||||
// For now, assume this master is the leader since we can connect to it
|
||||
isLeader = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
currentMaster := s.masterClient.GetMaster(context.Background())
|
||||
glog.Errorf("Failed to list raft cluster masters from %s: %v", currentMaster, err)
|
||||
isLeader = false
|
||||
}
|
||||
|
||||
if len(masterMap) == 0 {
|
||||
currentMaster := s.masterClient.GetMaster(context.Background())
|
||||
if currentMaster != "" {
|
||||
addr := pb.ServerAddress(currentMaster).ToHttpAddress()
|
||||
// A successful empty raft response means raft is not initialized
|
||||
// (standalone/non-raft cluster); the only master IS the leader.
|
||||
// A failed RPC means connectivity issue; do not claim leadership.
|
||||
masterMap[addr] = MasterNode{
|
||||
Address: addr,
|
||||
IsLeader: raftCallSucceeded,
|
||||
}
|
||||
}
|
||||
currentMaster := s.masterClient.GetMaster(context.Background())
|
||||
if currentMaster != "" {
|
||||
masterNodes = append(masterNodes, MasterNode{
|
||||
Address: pb.ServerAddress(currentMaster).ToHttpAddress(),
|
||||
IsLeader: isLeader,
|
||||
})
|
||||
}
|
||||
|
||||
masterNodes := make([]MasterNode, 0, len(masterMap))
|
||||
for _, m := range masterMap {
|
||||
masterNodes = append(masterNodes, m)
|
||||
}
|
||||
sort.Slice(masterNodes, func(i, j int) bool {
|
||||
return masterNodes[i].Address < masterNodes[j].Address
|
||||
})
|
||||
return masterNodes
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
@@ -543,6 +542,13 @@ func (s *AdminServer) sortBuckets(buckets []S3Bucket, sortBy, sortOrder string)
|
||||
}
|
||||
return a.CreatedAt.Before(b.CreatedAt)
|
||||
}
|
||||
case "objects":
|
||||
if a.ObjectCount != b.ObjectCount {
|
||||
if desc {
|
||||
return a.ObjectCount > b.ObjectCount
|
||||
}
|
||||
return a.ObjectCount < b.ObjectCount
|
||||
}
|
||||
case "logical_size":
|
||||
if a.LogicalSize != b.LogicalSize {
|
||||
if desc {
|
||||
@@ -639,11 +645,14 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
// Determine collection name for this bucket
|
||||
collectionName := getCollectionName(filerConfig.FilerGroup, bucketName)
|
||||
|
||||
// Get size and object count from collection data
|
||||
var physicalSize int64
|
||||
var logicalSize int64
|
||||
var objectCount int64
|
||||
if collectionData, exists := collectionMap[collectionName]; exists {
|
||||
physicalSize = collectionData.PhysicalSize
|
||||
logicalSize = collectionData.LogicalSize
|
||||
objectCount = collectionData.FileCount
|
||||
}
|
||||
|
||||
// Get quota information from entry
|
||||
@@ -685,6 +694,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
CreatedAt: createdAt,
|
||||
LogicalSize: logicalSize,
|
||||
PhysicalSize: physicalSize,
|
||||
ObjectCount: objectCount,
|
||||
LastModified: lastModified,
|
||||
Quota: quota,
|
||||
QuotaEnabled: quotaEnabled,
|
||||
@@ -739,6 +749,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
||||
} else if data, ok := stats[collectionName]; ok {
|
||||
details.Bucket.LogicalSize = data.LogicalSize
|
||||
details.Bucket.PhysicalSize = data.PhysicalSize
|
||||
details.Bucket.ObjectCount = data.FileCount
|
||||
}
|
||||
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
@@ -1660,23 +1671,8 @@ type collectionStats struct {
|
||||
FileCount int64
|
||||
}
|
||||
|
||||
// ecVolumeCounts combines EC volume counts reported by multiple nodes.
|
||||
// Every node holding any shard of an EC volume reports the same file_count
|
||||
// (total entries in the replicated .ecx), so we dedupe it per volume id by
|
||||
// taking the max — a node that has not yet finished loading .ecx would
|
||||
// otherwise pin the aggregate at 0 and zero out the bucket object count.
|
||||
// In contrast, a needle delete is recorded locally on the shard holder
|
||||
// that served it, so each node reports its own tombstone count and the
|
||||
// true delete total is the sum across nodes.
|
||||
type ecVolumeCounts struct {
|
||||
collection string
|
||||
fileCount uint64
|
||||
deleteCount uint64
|
||||
}
|
||||
|
||||
func collectCollectionStats(topologyInfo *master_pb.TopologyInfo) map[string]collectionStats {
|
||||
collectionMap := make(map[string]collectionStats)
|
||||
ecVolumeAgg := make(map[uint32]*ecVolumeCounts)
|
||||
for _, dc := range topologyInfo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for _, node := range rack.DataNodeInfos {
|
||||
@@ -1702,51 +1698,10 @@ func collectCollectionStats(topologyInfo *master_pb.TopologyInfo) map[string]col
|
||||
}
|
||||
collectionMap[collection] = data
|
||||
}
|
||||
for _, ecShardInfo := range diskInfo.EcShardInfos {
|
||||
collection := ecShardInfo.Collection
|
||||
if collection == "" {
|
||||
collection = "default"
|
||||
}
|
||||
shards := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(ecShardInfo)
|
||||
data := collectionMap[collection]
|
||||
data.PhysicalSize += int64(shards.TotalSize())
|
||||
data.LogicalSize += int64(shards.MinusParityShards().TotalSize())
|
||||
collectionMap[collection] = data
|
||||
|
||||
// fileCount is volume-wide (same .ecx on every shard
|
||||
// holder) so take the max to dedupe — a node that has
|
||||
// not yet finished loading .ecx reports 0 and must not
|
||||
// pin the aggregate. deleteCount is node-local and is
|
||||
// summed across shard holders.
|
||||
agg, ok := ecVolumeAgg[ecShardInfo.Id]
|
||||
if !ok {
|
||||
agg = &ecVolumeCounts{collection: collection}
|
||||
ecVolumeAgg[ecShardInfo.Id] = agg
|
||||
}
|
||||
if ecShardInfo.FileCount > agg.fileCount {
|
||||
agg.fileCount = ecShardInfo.FileCount
|
||||
}
|
||||
agg.deleteCount += ecShardInfo.DeleteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fold EC per-volume counts into the collection totals. fileCount is
|
||||
// deduped via max across every node reporting shards for the volume;
|
||||
// deleteCount is summed across the same nodes.
|
||||
for vid, agg := range ecVolumeAgg {
|
||||
data := collectionMap[agg.collection]
|
||||
if agg.fileCount >= agg.deleteCount {
|
||||
data.FileCount += int64(agg.fileCount - agg.deleteCount)
|
||||
} else {
|
||||
glog.Warningf("ec volume %d in collection %q: summed delete_count=%d exceeds file_count=%d; skipping object count",
|
||||
vid, agg.collection, agg.deleteCount, agg.fileCount)
|
||||
}
|
||||
collectionMap[agg.collection] = data
|
||||
}
|
||||
|
||||
return collectionMap
|
||||
}
|
||||
|
||||
|
||||
@@ -117,13 +117,6 @@ func (s *AdminServer) getTopologyViaGRPC(topology *ClusterTopology) error {
|
||||
}
|
||||
|
||||
if resp.TopologyInfo != nil {
|
||||
// Dedupe EC volume file counts across the nodes that report
|
||||
// shards for the same volume: every shard holder reports the
|
||||
// same .ecx-derived file_count, so we keep the max and sum
|
||||
// node-local tombstones.
|
||||
ecFile := make(map[uint32]uint64)
|
||||
ecDel := make(map[uint32]uint64)
|
||||
|
||||
// Process gRPC response
|
||||
for _, dc := range resp.TopologyInfo.DataCenterInfos {
|
||||
dataCenter := DataCenter{
|
||||
@@ -154,20 +147,11 @@ func (s *AdminServer) getTopologyViaGRPC(topology *ClusterTopology) error {
|
||||
totalFiles += int64(volInfo.FileCount)
|
||||
}
|
||||
|
||||
// Sum up EC shard sizes on this node and collect
|
||||
// volume-wide file/delete counts for later folding
|
||||
// into topology.TotalFiles. ShardSizes is local to
|
||||
// this node, so summing across nodes is correct;
|
||||
// FileCount/DeleteCount are per-volume and must be
|
||||
// deduped per volume id.
|
||||
// Sum up EC shard sizes
|
||||
for _, ecShardInfo := range diskInfo.EcShardInfos {
|
||||
for _, shardSize := range ecShardInfo.ShardSizes {
|
||||
totalSize += shardSize
|
||||
}
|
||||
if ecShardInfo.FileCount > ecFile[ecShardInfo.Id] {
|
||||
ecFile[ecShardInfo.Id] = ecShardInfo.FileCount
|
||||
}
|
||||
ecDel[ecShardInfo.Id] += ecShardInfo.DeleteCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,18 +191,6 @@ func (s *AdminServer) getTopologyViaGRPC(topology *ClusterTopology) error {
|
||||
|
||||
topology.DataCenters = append(topology.DataCenters, dataCenter)
|
||||
}
|
||||
|
||||
// Fold deduped EC file counts into the cluster total so the
|
||||
// dashboard header does not drop after volumes are converted
|
||||
// to erasure coding.
|
||||
for vid, fc := range ecFile {
|
||||
dc := ecDel[vid]
|
||||
if fc >= dc {
|
||||
topology.TotalFiles += int64(fc - dc)
|
||||
} else {
|
||||
glog.Warningf("ec volume %d: summed delete_count=%d exceeds file_count=%d; skipping from TotalFiles", vid, dc, fc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// TestCollectCollectionStatsECUnevenShards verifies that EC shards spread
|
||||
// across multiple disks are aggregated correctly, including the case where
|
||||
// the last data shard is smaller than the rest.
|
||||
func TestCollectCollectionStatsECUnevenShards(t *testing.T) {
|
||||
// Standard 10+4 EC layout. Shards 0..9 are data, 10..13 are parity.
|
||||
// Data shards 0..8 are 1000 bytes; data shard 9 is 500 bytes (uneven tail).
|
||||
// Parity shards 10..13 are 1000 bytes each.
|
||||
//
|
||||
// Physical (raw) = 9*1000 + 500 + 4*1000 = 13500
|
||||
// Logical (data only) = 9*1000 + 500 = 9500
|
||||
|
||||
nodeA := &master_pb.DataNodeInfo{
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"disk1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 42,
|
||||
Collection: "bucket-a",
|
||||
// Shards 0..6 held here.
|
||||
EcIndexBits: (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6),
|
||||
ShardSizes: []int64{1000, 1000, 1000, 1000, 1000, 1000, 1000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
nodeB := &master_pb.DataNodeInfo{
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"disk1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 42,
|
||||
Collection: "bucket-a",
|
||||
// Shards 7..13 held here. Shard 9 (data) is the short tail.
|
||||
EcIndexBits: (1 << 7) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 11) | (1 << 12) | (1 << 13),
|
||||
ShardSizes: []int64{1000, 1000, 500, 1000, 1000, 1000, 1000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
topo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{nodeA, nodeB},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := collectCollectionStats(topo)
|
||||
|
||||
got, ok := stats["bucket-a"]
|
||||
if !ok {
|
||||
t.Fatalf("expected collection bucket-a in stats, got: %v", stats)
|
||||
}
|
||||
|
||||
const wantPhysical int64 = 13500
|
||||
const wantLogical int64 = 9500
|
||||
|
||||
if got.PhysicalSize != wantPhysical {
|
||||
t.Errorf("PhysicalSize: got %d, want %d", got.PhysicalSize, wantPhysical)
|
||||
}
|
||||
if got.LogicalSize != wantLogical {
|
||||
t.Errorf("LogicalSize: got %d, want %d", got.LogicalSize, wantLogical)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCollectionStatsECEmptyCollection verifies that EC shards with
|
||||
// an empty collection name are bucketed under "default".
|
||||
func TestCollectCollectionStatsECEmptyCollection(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"disk1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 1,
|
||||
Collection: "",
|
||||
EcIndexBits: (1 << 0) | (1 << 10),
|
||||
ShardSizes: []int64{2000, 2000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := collectCollectionStats(topo)
|
||||
|
||||
got, ok := stats["default"]
|
||||
if !ok {
|
||||
t.Fatalf("expected collection default in stats, got: %v", stats)
|
||||
}
|
||||
if got.PhysicalSize != 4000 {
|
||||
t.Errorf("PhysicalSize: got %d, want 4000", got.PhysicalSize)
|
||||
}
|
||||
// Only shard 0 is a data shard; shard 10 is parity.
|
||||
if got.LogicalSize != 2000 {
|
||||
t.Errorf("LogicalSize: got %d, want 2000", got.LogicalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCollectionStatsECFileAndDeleteCountAggregation verifies that
|
||||
// FileCount for an EC volume is deduped across nodes (every shard holder has
|
||||
// an identical .ecx, so the total entry count is taken once) while
|
||||
// DeleteCount is summed (each needle delete tombstones exactly one node's
|
||||
// .ecx, so the true delete total is the sum of every holder's local count).
|
||||
func TestCollectCollectionStatsECFileAndDeleteCountAggregation(t *testing.T) {
|
||||
// Volume id=7 reported by three nodes. Every node reports file_count=100
|
||||
// (same .ecx). Local delete counts: 5 + 3 + 2 = 10 deletes total.
|
||||
// Expected live object count = 100 - 10 = 90.
|
||||
makeNode := func(bits uint32, sizes []int64, deleteCount uint64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"disk1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 7,
|
||||
Collection: "bucket-a",
|
||||
EcIndexBits: bits,
|
||||
ShardSizes: sizes,
|
||||
FileCount: 100,
|
||||
DeleteCount: deleteCount,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
topo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
makeNode((1<<0)|(1<<1)|(1<<2)|(1<<3), []int64{1000, 1000, 1000, 1000}, 5),
|
||||
makeNode((1<<4)|(1<<5)|(1<<6)|(1<<7), []int64{1000, 1000, 1000, 1000}, 3),
|
||||
makeNode((1<<8)|(1<<9)|(1<<10)|(1<<11)|(1<<12)|(1<<13), []int64{1000, 1000, 1000, 1000, 1000, 1000}, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := collectCollectionStats(topo)
|
||||
|
||||
got, ok := stats["bucket-a"]
|
||||
if !ok {
|
||||
t.Fatalf("expected collection bucket-a in stats, got: %v", stats)
|
||||
}
|
||||
if got.FileCount != 90 {
|
||||
t.Errorf("FileCount: got %d, want 90 (100 total - 10 deletes summed)", got.FileCount)
|
||||
}
|
||||
// Sanity: 14 shards × 1000 bytes physical, 10 data shards logical.
|
||||
if got.PhysicalSize != 14000 {
|
||||
t.Errorf("PhysicalSize: got %d, want 14000", got.PhysicalSize)
|
||||
}
|
||||
if got.LogicalSize != 10000 {
|
||||
t.Errorf("LogicalSize: got %d, want 10000", got.LogicalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCollectionStatsECFileCountMaxDedupe verifies that EC file_count
|
||||
// is taken as the max across reporting nodes rather than the first-seen
|
||||
// value. A node that has not yet finished loading .ecx reports file_count=0,
|
||||
// which previously poisoned the aggregate and rendered buckets backed by EC
|
||||
// volumes as "0 objects".
|
||||
func TestCollectCollectionStatsECFileCountMaxDedupe(t *testing.T) {
|
||||
makeNode := func(bits uint32, sizes []int64, fileCount uint64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"disk1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 11,
|
||||
Collection: "bucket-b",
|
||||
EcIndexBits: bits,
|
||||
ShardSizes: sizes,
|
||||
FileCount: fileCount,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
topo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
// First-reporting node has a stale fileCount of 0,
|
||||
// second node reports the authoritative 6.
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
makeNode((1<<0)|(1<<1)|(1<<2)|(1<<3)|(1<<4)|(1<<5)|(1<<6), []int64{1, 1, 1, 1, 1, 1, 1}, 0),
|
||||
makeNode((1<<7)|(1<<8)|(1<<9)|(1<<10)|(1<<11)|(1<<12)|(1<<13), []int64{1, 1, 1, 1, 1, 1, 1}, 6),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := collectCollectionStats(topo)
|
||||
got, ok := stats["bucket-b"]
|
||||
if !ok {
|
||||
t.Fatalf("expected collection bucket-b in stats, got: %v", stats)
|
||||
}
|
||||
if got.FileCount != 6 {
|
||||
t.Errorf("FileCount: got %d, want 6 (max across reporters)", got.FileCount)
|
||||
}
|
||||
}
|
||||
@@ -289,8 +289,8 @@ func (cp *ConfigPersistence) LoadVacuumTaskConfig() (*VacuumTaskConfig, error) {
|
||||
|
||||
// Return default config if no valid config found
|
||||
return &VacuumTaskConfig{
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -305,8 +305,8 @@ func (cp *ConfigPersistence) LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, erro
|
||||
CheckIntervalSeconds: 6 * 3600, // 6 hours in seconds
|
||||
TaskConfig: &worker_pb.TaskPolicy_VacuumConfig{
|
||||
VacuumConfig: &worker_pb.VacuumTaskConfig{
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
@@ -325,8 +325,8 @@ func (cp *ConfigPersistence) LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, erro
|
||||
CheckIntervalSeconds: 6 * 3600, // 6 hours in seconds
|
||||
TaskConfig: &worker_pb.TaskPolicy_VacuumConfig{
|
||||
VacuumConfig: &worker_pb.VacuumTaskConfig{
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
GarbageThreshold: 0.3,
|
||||
MinVolumeAgeHours: 24,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
@@ -705,8 +705,8 @@ func buildPolicyFromTaskConfigs() *worker_pb.MaintenancePolicy {
|
||||
CheckIntervalSeconds: int32(vacuumConfig.ScanIntervalSeconds),
|
||||
TaskConfig: &worker_pb.TaskPolicy_VacuumConfig{
|
||||
VacuumConfig: &worker_pb.VacuumTaskConfig{
|
||||
GarbageThreshold: float64(vacuumConfig.GarbageThreshold),
|
||||
MinVolumeAgeHours: int32(vacuumConfig.MinVolumeAgeSeconds / 3600), // Convert seconds to hours
|
||||
GarbageThreshold: float64(vacuumConfig.GarbageThreshold),
|
||||
MinVolumeAgeHours: int32(vacuumConfig.MinVolumeAgeSeconds / 3600), // Convert seconds to hours
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -452,13 +452,6 @@ func (s *AdminServer) UpdatePluginJobTypeConfigAPI(w http.ResponseWriter, r *htt
|
||||
}
|
||||
config.UpdatedBy = username
|
||||
|
||||
// Reapply descriptor defaults so a save from an older form (or a UI
|
||||
// that omits new fields) cannot silently clear a baseline like
|
||||
// execution_timeout_seconds back to zero.
|
||||
if descriptor, err := s.LoadPluginJobTypeDescriptor(jobType); err == nil && descriptor != nil {
|
||||
applyDescriptorDefaultsToPersistedConfig(config, descriptor)
|
||||
}
|
||||
|
||||
if err := s.SavePluginJobTypeConfig(config); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -923,9 +916,6 @@ func applyDescriptorDefaultsToPersistedConfig(
|
||||
if runtime.JobTypeMaxRuntimeSeconds <= 0 {
|
||||
runtime.JobTypeMaxRuntimeSeconds = defaults.JobTypeMaxRuntimeSeconds
|
||||
}
|
||||
if runtime.ExecutionTimeoutSeconds <= 0 {
|
||||
runtime.ExecutionTimeoutSeconds = defaults.ExecutionTimeoutSeconds
|
||||
}
|
||||
if runtime.RetryBackoffSeconds <= 0 {
|
||||
runtime.RetryBackoffSeconds = defaults.RetryBackoffSeconds
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ type S3Bucket struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LogicalSize int64 `json:"logical_size"` // Actual data size (used space)
|
||||
PhysicalSize int64 `json:"physical_size"` // Total allocated volume space
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota
|
||||
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user