mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-30 18:12:14 +00:00
Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9824171995 | |||
| ecceb8fd1c | |||
| 1184806c3f | |||
| 4411c625e2 | |||
| c07ed61989 | |||
| da58f8e291 | |||
| aa011ade19 | |||
| ccf3b29df5 | |||
| 682b5bbb2f | |||
| f4e9ef2edc | |||
| ff8c1c782a | |||
| ca6076fe18 | |||
| 58ee140324 | |||
| 927f3a57d7 | |||
| a574285ab2 | |||
| 60793c17d7 | |||
| 0b870d6301 | |||
| d635ee8d2e | |||
| 5c2eda356e | |||
| a2b88a79ec | |||
| 6eb90e7df9 | |||
| 466429b958 | |||
| c8411fd62c | |||
| 6bba41f11f | |||
| e30781654d | |||
| 7a42af922d | |||
| 4d19b069c3 | |||
| a4563f7b41 | |||
| 174e12bf66 | |||
| f03034b99e | |||
| 36f14acbe9 | |||
| 2f66f15524 | |||
| cb468fb32f | |||
| d1a70176a2 | |||
| ec4458f846 | |||
| 00ccc19e27 | |||
| 07cf2feaad | |||
| 087f58b7c8 | |||
| 6fc35e442c | |||
| a798b20308 | |||
| 38b779b924 | |||
| 1aba8c10b9 | |||
| 90ed75a3dc | |||
| dce117840c | |||
| 1c085590ca | |||
| 2ee81496b0 | |||
| 022e3dfc21 | |||
| 6b15e727f5 | |||
| ab84da24ef | |||
| e377c7e7f9 | |||
| 7c497a30b2 | |||
| 51e8a4820f | |||
| a81bbed551 | |||
| 072de6b025 | |||
| 9269cb779b | |||
| fff175dcdd | |||
| 26c0230e8f | |||
| 8edb1affc0 | |||
| 74759b6e99 | |||
| db29c0cae2 | |||
| 6ab7b75fd4 | |||
| 2108c4ad28 | |||
| d251b9fb35 | |||
| 172bed0ff2 | |||
| 1db7bac2dc | |||
| 4890fb25c1 | |||
| 3838a13606 | |||
| c28134c957 | |||
| 173dad27d1 | |||
| 9285acba06 | |||
| db5e72e475 | |||
| 16160b7b84 | |||
| 461ba3aeba | |||
| 65de487eba | |||
| e5284a85ed | |||
| fd08be7be2 | |||
| 43bf846633 | |||
| db253c01a9 | |||
| 6631407416 | |||
| 6c5f8e591a | |||
| 3b5f7fb3ff | |||
| 87ead2bea3 | |||
| 0320508f8d | |||
| 47ec125589 | |||
| 9fc1c264b0 | |||
| ae50760fcc | |||
| 60d54af749 | |||
| f59380ae17 | |||
| 7c8fd8518f | |||
| 14ce251e3b | |||
| d578707f95 | |||
| 46126ade81 | |||
| 51bfb9c4f2 | |||
| 99be71e4c2 | |||
| a9f499282c | |||
| c9e2d7da2a | |||
| e52a60e64e | |||
| 28e2af0829 | |||
| 3012119b81 | |||
| 76fa86fdc5 | |||
| 2ab6f8c029 | |||
| 0927f937a7 | |||
| 548a39ffe7 | |||
| ed4329d50c | |||
| 18b22eedd9 | |||
| 55e4cdec5d | |||
| dceb7aac8a | |||
| e3a7eb2d3d | |||
| 1e683f12ef | |||
| 6a63fba5c2 | |||
| df502f2ac6 | |||
| cb53ee13cd | |||
| 6928221b56 | |||
| 2d58eea702 | |||
| 109ca7a100 | |||
| 15e6d4dbd0 | |||
| 68c5c0b834 | |||
| 27480f7625 | |||
| f795299d53 | |||
| 650fae71fb | |||
| dc76e4472e | |||
| b5140f0098 | |||
| 5f2e594480 | |||
| bec51bb783 | |||
| 1fad8167af | |||
| f0da8ce216 | |||
| f9d3a908f0 | |||
| 29d86036b1 | |||
| 78b13f3ff2 | |||
| 760cb1d734 | |||
| 6b2eebee1d | |||
| ddaa9e35ea | |||
| 703d961168 | |||
| e614e530cf | |||
| 00119548d2 | |||
| d532c7c972 | |||
| 04f441361e | |||
| 9e162b6e9e | |||
| 900f7724b8 | |||
| 4f5653e656 | |||
| a95e549430 | |||
| 00f3275603 | |||
| 359c9d2d26 | |||
| 3ce99939a3 | |||
| 02f809312b | |||
| 356dc7e0c2 | |||
| e4ad86ada6 | |||
| b95bee64b2 | |||
| 18fb920fa4 | |||
| 5f19eef945 | |||
| 40ad2a6ea9 | |||
| e7a3129be4 | |||
| b142563127 | |||
| 5660208e89 | |||
| 0b6f3302ce | |||
| 60103f0f72 | |||
| ab752458ce | |||
| 1d6c8750e7 | |||
| 9c44f71a0a | |||
| 9c432fc963 | |||
| f86761fae9 | |||
| 377ed507c5 | |||
| e063306ac3 | |||
| 8009ad5692 | |||
| fb89a16086 | |||
| 666c0a9a38 | |||
| 486a4b58e6 | |||
| f5f6ea4a5c | |||
| 38c2d74d36 | |||
| ffbcd3852f | |||
| 75b144b7d4 | |||
| d06397cf4a | |||
| f995943832 | |||
| de4a3fa766 | |||
| 4d0045ff18 | |||
| d96e04a579 | |||
| cc916926ff | |||
| 134e7e237c | |||
| cf53a9d84a | |||
| 8d7cd4cb1b | |||
| 61b3100260 | |||
| b19e8070a2 | |||
| b8aa8214e2 |
@@ -69,6 +69,9 @@ services:
|
||||
- TZ=Asia/Shanghai
|
||||
networks:
|
||||
- rustfs-network
|
||||
volumes:
|
||||
- ../../.docker/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ../../.docker/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
|
||||
node1:
|
||||
build:
|
||||
@@ -79,7 +82,7 @@ services:
|
||||
- RUSTFS_VOLUMES=http://node{1...4}:9000/root/data/target/volume/test{1...4}
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4317
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
@@ -96,7 +99,7 @@ services:
|
||||
- RUSTFS_VOLUMES=http://node{1...4}:9000/root/data/target/volume/test{1...4}
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4317
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
@@ -113,7 +116,7 @@ services:
|
||||
- RUSTFS_VOLUMES=http://node{1...4}:9000/root/data/target/volume/test{1...4}
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4317
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
@@ -130,7 +133,7 @@ services:
|
||||
- RUSTFS_VOLUMES=http://node{1...4}:9000/root/data/target/volume/test{1...4}
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4317
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
|
||||
@@ -56,7 +56,7 @@ docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
|
||||
- docker compose v2.0.0 or before
|
||||
|
||||
```bash
|
||||
docke-compose -f docker-compose.yml -f docker-compose.override.yml up -d
|
||||
docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d
|
||||
```
|
||||
|
||||
To access the Grafana dashboard, navigate to `http://localhost:3000` in your browser. The default username and password
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,6 @@ exporters:
|
||||
queue_size: 5000 # Queue size
|
||||
prometheus: # Prometheus exporter for metrics data
|
||||
endpoint: "0.0.0.0:8889" # Prometheus scraping endpoint
|
||||
namespace: "metrics" # indicator prefix
|
||||
send_timestamps: true # Send timestamp
|
||||
metric_expiration: 5m # Metric expiration time
|
||||
resource_to_telemetry_conversion:
|
||||
|
||||
Regular → Executable
@@ -67,7 +67,7 @@ To send telemetry data from your application, configure your OpenTelemetry SDK t
|
||||
For example, in a Rust application using the `rustfs-obs` library:
|
||||
|
||||
```bash
|
||||
export RUSTFS_OBS_ENDPOINT=http://localhost:4317
|
||||
export RUSTFS_OBS_ENDPOINT=http://localhost:4318
|
||||
export RUSTFS_OBS_SERVICE_NAME=yourservice
|
||||
export RUSTFS_OBS_SERVICE_VERSION=1.0.0
|
||||
export RUSTFS_OBS_ENVIRONMENT=development
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
@@ -26,6 +26,9 @@ updates:
|
||||
day: "monday"
|
||||
timezone: "Asia/Shanghai"
|
||||
time: "08:00"
|
||||
ignore:
|
||||
- dependency-name: "object_store"
|
||||
versions: [ "0.13.x" ]
|
||||
groups:
|
||||
s3s:
|
||||
update-types:
|
||||
@@ -36,4 +39,4 @@ updates:
|
||||
- "s3s-*"
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- "*"
|
||||
@@ -120,7 +120,11 @@ secret_key = ${S3_ALT_SECRET_KEY}
|
||||
display_name = RustFS Tenant Tester
|
||||
|
||||
# tenant user_id
|
||||
user_id = rustfstenant
|
||||
# Note: Using same user_id as main to avoid teardown failures.
|
||||
# RustFS does not currently support multi-tenancy, so the tenant client
|
||||
# effectively operates as the main user. This ensures nuke_prefixed_buckets()
|
||||
# in s3-tests teardown can successfully clean up resources.
|
||||
user_id = rustfsadmin
|
||||
|
||||
# tenant AWS access key
|
||||
access_key = ${S3_ACCESS_KEY}
|
||||
@@ -132,7 +136,11 @@ secret_key = ${S3_SECRET_KEY}
|
||||
email = tenant@rustfs.local
|
||||
|
||||
# tenant name
|
||||
tenant = testx
|
||||
# Note: Empty tenant name to avoid multi-tenant path issues during teardown.
|
||||
# When s3-tests calls get_tenant_client(), it uses this tenant value in requests.
|
||||
# An empty value makes the tenant client behave like the main client, preventing
|
||||
# "bucket not found" errors when teardown tries to clean up test buckets.
|
||||
tenant =
|
||||
|
||||
#following section needs to be added for all sts-tests
|
||||
[iam]
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Upload audit results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: security-audit-results-${{ github.run_number }}
|
||||
path: audit-results.json
|
||||
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
echo "📊 Version: ${VERSION}"
|
||||
|
||||
- name: Upload to GitHub artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.package.outputs.package_name }}
|
||||
path: "rustfs-*.zip"
|
||||
@@ -679,7 +679,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./artifacts
|
||||
pattern: rustfs-*
|
||||
|
||||
@@ -159,8 +159,8 @@ jobs:
|
||||
uses: taiki-e/cache-cargo-install-action@v2
|
||||
with:
|
||||
tool: s3s-e2e
|
||||
git: https://github.com/Nugine/s3s.git
|
||||
rev: b7714bfaa17ddfa9b23ea01774a1e7bbdbfc2ca3
|
||||
git: https://github.com/s3s-project/s3s.git
|
||||
rev: 4a04a670cf41274d9be9ab65dc36f4aa3f92fbad
|
||||
|
||||
- name: Build debug binary
|
||||
run: |
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-test-logs-${{ github.run_number }}
|
||||
path: /tmp/rustfs.log
|
||||
|
||||
@@ -266,6 +266,17 @@ jobs:
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: musl
|
||||
file: Dockerfile
|
||||
suffix: ""
|
||||
- variant: glibc
|
||||
file: Dockerfile.glibc
|
||||
suffix: "-glibc"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -296,6 +307,7 @@ jobs:
|
||||
VERSION="${{ needs.build-check.outputs.version }}"
|
||||
SHORT_SHA="${{ needs.build-check.outputs.short_sha }}"
|
||||
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
||||
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
# Convert version format for Dockerfile compatibility
|
||||
case "$VERSION" in
|
||||
@@ -326,14 +338,14 @@ jobs:
|
||||
|
||||
# Generate tags based on build type
|
||||
# Only support release and prerelease builds (no development builds)
|
||||
TAGS="${{ env.REGISTRY_DOCKERHUB }}:${VERSION}"
|
||||
TAGS="${{ env.REGISTRY_DOCKERHUB }}:${VERSION}${VARIANT_SUFFIX}"
|
||||
|
||||
# Add channel tags for prereleases and latest for stable
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
# TODO: Temporary change - the current alpha version will also create the latest tag
|
||||
# After the version is stabilized, the logic here remains unchanged, but the upstream CREATE_LATEST setting needs to be restored.
|
||||
# Stable release (and temporary alpha versions)
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest"
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX}"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
if [[ "$VERSION" == *"alpha"* ]]; then
|
||||
@@ -345,7 +357,7 @@ jobs:
|
||||
fi
|
||||
|
||||
if [[ -n "$CHANNEL" ]]; then
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:${CHANNEL}"
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:${CHANNEL}${VARIANT_SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -372,15 +384,15 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: |
|
||||
type=gha,scope=docker-binary
|
||||
type=gha,scope=docker-${{ matrix.variant }}
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=docker-binary
|
||||
type=gha,mode=max,scope=docker-${{ matrix.variant }}
|
||||
build-args: |
|
||||
BUILDTIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
VERSION=${{ needs.build-check.outputs.version }}
|
||||
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts
|
||||
if: always() && env.ACT != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: s3tests-single
|
||||
path: artifacts/**
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts
|
||||
if: always() && env.ACT != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: s3tests-multi
|
||||
path: artifacts/**
|
||||
|
||||
@@ -44,7 +44,6 @@ jobs:
|
||||
set -x
|
||||
old_version=$(grep "^appVersion:" helm/rustfs/Chart.yaml | awk '{print $2}')
|
||||
sed -i "s/$old_version/$new_version/g" helm/rustfs/Chart.yaml
|
||||
sed -i "/^image:/,/^[^ ]/ s/tag:.*/tag: "$new_version"/" helm/rustfs/values.yaml
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.0
|
||||
@@ -56,7 +55,7 @@ jobs:
|
||||
helm package ./helm/rustfs --destination helm/rustfs/ --version "0.0.$package_version"
|
||||
|
||||
- name: Upload helm package as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: helm-package
|
||||
path: helm/rustfs/*.tgz
|
||||
@@ -74,7 +73,7 @@ jobs:
|
||||
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
||||
|
||||
- name: Download helm package
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: helm-package
|
||||
path: ./
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Upload profile data
|
||||
if: steps.profiling.outputs.profile_generated == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: performance-profile-${{ github.run_number }}
|
||||
path: samply-profile.json
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
tee benchmark-results.json
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: benchmark-results-${{ github.run_number }}
|
||||
path: benchmark-results.json
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: "Mark stale issues"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.'
|
||||
stale-issue-label: 'stale'
|
||||
## Mark if there is no activity for more than 7 days
|
||||
days-before-stale: 7
|
||||
# If no one responds after 3 days, the tag will be closed.
|
||||
days-before-close: 3
|
||||
# These tags are exempt and will not close automatically.
|
||||
exempt-issue-labels: 'pinned,security'
|
||||
@@ -6,6 +6,8 @@
|
||||
/test
|
||||
/logs
|
||||
/data
|
||||
/docs
|
||||
/rustfs-data/
|
||||
.devcontainer
|
||||
rustfs/static/*
|
||||
!rustfs/static/.gitkeep
|
||||
@@ -34,3 +36,7 @@ artifacts/
|
||||
*.events
|
||||
*.audit
|
||||
*.snappy
|
||||
PR_DESCRIPTION.md
|
||||
IMPLEMENTATION_PLAN.md
|
||||
scripts/s3-tests/selected_tests.txt
|
||||
docs
|
||||
Vendored
+108
-3
@@ -99,7 +99,17 @@
|
||||
"name": "Debug executable target/debug/rustfs",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"run",
|
||||
"--bin",
|
||||
"rustfs",
|
||||
"-j",
|
||||
"1",
|
||||
"--profile",
|
||||
"dev"
|
||||
]
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
@@ -107,7 +117,7 @@
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
//"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
|
||||
@@ -116,11 +126,31 @@
|
||||
// "RUSTFS_COMPRESS_ENABLE": "true",
|
||||
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||
"RUST_LOG":"rustfs=debug,ecstore=debug,s3s=debug,iam=debug",
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug test_lifecycle_transition_basic",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"-p",
|
||||
"rustfs-scanner",
|
||||
"--test",
|
||||
"lifecycle_integration_test",
|
||||
"serial_tests::test_lifecycle_transition_basic",
|
||||
"-j",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/test",
|
||||
"type": "lldb",
|
||||
@@ -140,6 +170,81 @@
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
//"preLaunchTask": "cargo build",
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
|
||||
// "RUSTFS_OBS_METRIC_ENDPOINT": "http://127.0.0.1:4318/v1/metrics", // default otlp http endpoint
|
||||
// "RUSTFS_OBS_LOG_ENDPOINT": "http://127.0.0.1:4318/v1/logs", // default otlp http endpoint
|
||||
// "RUSTFS_COMPRESS_ENABLE": "true",
|
||||
|
||||
// 1. simple sse test key (no kms system)
|
||||
// "__RUSTFS_SSE_SIMPLE_CMK": "2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM=",
|
||||
|
||||
// 2. kms local backend test key
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/kms-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
// 3. kms vault backend test key
|
||||
// "RUSTFS_KMS_ENABLE": "true",
|
||||
// "RUSTFS_KMS_BACKEND": "vault",
|
||||
// "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||
// "RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "E2E test executable target/debug/rustfs",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
//"preLaunchTask": "cargo build",
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug,iam=debug",
|
||||
"RUST_BACKTRACE": "full",
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/e2e-test/test{1...4}",
|
||||
"RUSTFS_REGION": "us-east-1",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/e2e-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,25 +1,101 @@
|
||||
# Repository Guidelines
|
||||
|
||||
This file provides guidance for AI agents and developers working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
RustFS is a high-performance distributed object storage software built with Rust, providing S3-compatible APIs and advanced features like data lakes, AI, and big data support. It's designed as an alternative to MinIO with better performance and a more business-friendly Apache 2.0 license.
|
||||
|
||||
## ⚠️ Pre-Commit Checklist (MANDATORY)
|
||||
|
||||
**Before EVERY commit, you MUST run and pass ALL of the following:**
|
||||
```bash
|
||||
cargo fmt --all --check # Code formatting
|
||||
cargo clippy --all-targets --all-features -- -D warnings # Lints
|
||||
cargo test --workspace --exclude e2e_test # Unit tests
|
||||
```
|
||||
Or simply run `make pre-commit` which covers all checks. **DO NOT commit if any check fails.**
|
||||
|
||||
## Communication Rules
|
||||
|
||||
- Respond to the user in Chinese; use English in all other contexts.
|
||||
- Code and documentation must be written in English only. Chinese text is allowed solely as test data/fixtures when a case explicitly requires Chinese-language content for validation.
|
||||
- **Pull Request titles and descriptions must be written in English** to ensure consistency and accessibility for all contributors.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
The workspace root hosts shared dependencies in `Cargo.toml`. The service binary lives under `rustfs/src/main.rs`, while reusable crates sit in `crates/` (`crypto`, `iam`, `kms`, and `e2e_test`). Local fixtures for standalone flows reside in `test_standalone/`, deployment manifests are under `deploy/`, Docker assets sit at the root, and automation lives in `scripts/`. Skim each crate’s README or module docs before contributing changes.
|
||||
## Project Structure
|
||||
|
||||
The workspace root hosts shared dependencies in `Cargo.toml`. The service binary lives under `rustfs/src/main.rs`, while reusable crates sit in `crates/` (including `ecstore`, `iam`, `kms`, `madmin`, `s3select-api`, `s3select-query`, `config`, `crypto`, `lock`, `filemeta`, `rio`, `common`, `protos`, `audit-logger`, `notify`, `obs`, `workers`, `appauth`, `ahm`, `mcp`, `signer`, `checksums`, `utils`, `zip`, `targets`, and `e2e_test`). Deployment manifests are under `deploy/`, Docker assets sit at the root, and automation lives in `scripts/`.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
- **Main Binary (`rustfs/`):** Entry point at `rustfs/src/main.rs`, includes admin, auth, config, server, storage, license management, and profiling modules.
|
||||
- **Key Crates:** Cargo workspace with 25+ crates supporting S3-compatible APIs, erasure coding storage, IAM, KMS, S3 Select, and observability.
|
||||
- **Build System:** Custom `build-rustfs.sh` script, multi-architecture Docker builds, Make/Just task runners, cross-compilation support.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run `cargo check --all-targets` for fast validation. Build release binaries via `cargo build --release` or the pipeline-aligned `make build`. Use `./build-rustfs.sh --dev` for iterative development and `./build-rustfs.sh --platform <target>` for cross-compiles. Prefer `make pre-commit` before pushing to cover formatting, clippy, checks, and tests.
|
||||
Always ensure `cargo fmt --all --check`, `cargo test --workspace --exclude e2e_test`, and `cargo clippy --all-targets --all-features -- -D warnings` complete successfully after each code change to keep the tree healthy and warning-free.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Formatting follows the repo `rustfmt.toml` (130-column width). Use `snake_case` for items, `PascalCase` for types, and `SCREAMING_SNAKE_CASE` for constants. Avoid `unwrap()` or `expect()` outside tests; bubble errors with `Result` and crate-specific `thiserror` types. Keep async code non-blocking and offload CPU-heavy work with `tokio::task::spawn_blocking` when necessary.
|
||||
### Quick Commands
|
||||
- `cargo check --all-targets` - Fast validation
|
||||
- `cargo build --release` or `make build` - Release build
|
||||
- `./build-rustfs.sh --dev` - Development build with debug symbols
|
||||
- `make pre-commit` - Run all quality checks (fmt, clippy, check, test)
|
||||
|
||||
### Testing
|
||||
- `cargo test --workspace --exclude e2e_test` - Unit tests
|
||||
- `cargo test --package e2e_test` - E2E tests
|
||||
- For KMS tests: `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1`
|
||||
|
||||
### Cross-Platform Builds
|
||||
- `./build-rustfs.sh --platform x86_64-unknown-linux-musl` - Build for musl
|
||||
- `./build-rustfs.sh --platform aarch64-unknown-linux-gnu` - Build for ARM64
|
||||
- `make build-cross-all` - Build all supported architectures
|
||||
|
||||
## Coding Style & Safety Requirements
|
||||
|
||||
- **Formatting:** Follow `rustfmt.toml` (130-column width). Use `snake_case` for items, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants.
|
||||
- **Safety:** `unsafe_code = "deny"` enforced at workspace level. Never use `unwrap()`, `expect()`, or panic-inducing code except in tests.
|
||||
- **Error Handling:** Prefer `anyhow` for applications, `thiserror` for libraries. Use proper error handling with `Result<T, E>` and `Option<T>`.
|
||||
- **Async:** Keep async code non-blocking. Offload CPU-heavy work with `tokio::task::spawn_blocking` when necessary.
|
||||
- **Language:** Code comments, function names, variable names, and all text in source files must be in English only.
|
||||
|
||||
## Testing Guidelines
|
||||
Co-locate unit tests with their modules and give behavior-led names such as `handles_expired_token`. Integration suites belong in each crate’s `tests/` directory, while exhaustive end-to-end scenarios live in `crates/e2e_test/`. Run `cargo test --workspace --exclude e2e_test` during iteration, `cargo nextest run --all --exclude e2e_test` when available, and finish with `cargo test --all` before requesting review. Use `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY=` for KMS e2e tests.
|
||||
When fixing bugs or adding features, include regression tests that capture the new behavior so future changes cannot silently break it.
|
||||
|
||||
Co-locate unit tests with their modules and give behavior-led names. Integration suites belong in each crate's `tests/` directory, while exhaustive end-to-end scenarios live in `crates/e2e_test/`. When fixing bugs or adding features, include regression tests that capture the new behavior.
|
||||
|
||||
## KMS (Key Management Service)
|
||||
|
||||
- **Implementation:** Complete with Local and Vault backends, auto-configures on startup with `--kms-enable` flag.
|
||||
- **Encryption:** Full S3-compatible server-side encryption (SSE-S3, SSE-KMS, SSE-C).
|
||||
- **Testing:** Comprehensive E2E tests in `crates/e2e_test/src/kms/`. Requires proxy bypass for local testing.
|
||||
- **Key Files:** `crates/kms/`, `rustfs/src/storage/ecfs.rs`, `rustfs/src/admin/handlers/kms*.rs`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `RUSTFS_ENABLE_SCANNER` - Enable/disable background data scanner (default: true)
|
||||
- `RUSTFS_ENABLE_HEAL` - Enable/disable auto-heal functionality (default: true)
|
||||
- For KMS tests: `NO_PROXY=127.0.0.1,localhost` and clear proxy environment variables
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Work on feature branches (e.g., `feat/...`) after syncing `main`. Follow Conventional Commits under 72 characters (e.g., `feat: add kms key rotation`). Each commit must compile, format cleanly, and pass `make pre-commit`. Open PRs with a concise summary, note verification commands, link relevant issues, and wait for reviewer approval.
|
||||
|
||||
Work on feature branches (e.g., `feat/...`) after syncing `main`. Follow Conventional Commits under 72 characters. Each commit must compile, format cleanly, and pass `make pre-commit`.
|
||||
|
||||
**Pull Request Requirements:**
|
||||
- PR titles and descriptions **MUST be written in English**
|
||||
- Open PRs with a concise summary, note verification commands, link relevant issues
|
||||
- Follow the PR template format and fill in all required sections
|
||||
- Wait for reviewer approval before merging
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit secrets or cloud credentials; prefer environment variables or vault tooling. Review IAM- and KMS-related changes with a second maintainer. Confirm proxy settings before running sensitive tests to avoid leaking traffic outside localhost.
|
||||
|
||||
## Important Reminders
|
||||
|
||||
- Always compile after code changes: Use `cargo build` to catch errors early
|
||||
- Don't bypass tests: All functionality must be properly tested, not worked around
|
||||
- Use proper error handling: Never use `unwrap()` or `expect()` in production code (except tests)
|
||||
- Do what has been asked; nothing more, nothing less
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal
|
||||
- ALWAYS prefer editing an existing file to creating a new one
|
||||
- NEVER proactively create documentation files (*.md) or README files unless explicitly requested
|
||||
- NEVER commit PR description files (e.g., PR_DESCRIPTION.md): These are temporary reference files for creating pull requests and should remain local only
|
||||
|
||||
@@ -83,6 +83,3 @@ that body of laws known as conflict of laws. The parties expressly agree that th
|
||||
for the International Sale of Goods will not apply. Any legal action or proceeding arising under this Agreement will be
|
||||
brought exclusively in the courts located in Beijing, China, and the parties hereby irrevocably consent to the personal
|
||||
jurisdiction and venue therein.
|
||||
|
||||
For your reading convenience, this Agreement is written in parallel English and Chinese sections. To the extent there is
|
||||
a conflict between the English and Chinese sections, the English sections shall govern.
|
||||
@@ -1,275 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
RustFS is a high-performance distributed object storage software built with Rust, providing S3-compatible APIs and
|
||||
advanced features like data lakes, AI, and big data support. It's designed as an alternative to MinIO with better
|
||||
performance and a more business-friendly Apache 2.0 license.
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Primary Build Commands
|
||||
|
||||
- `cargo build --release` - Build the main RustFS binary
|
||||
- `./build-rustfs.sh` - Recommended build script that handles console resources and cross-platform compilation
|
||||
- `./build-rustfs.sh --dev` - Development build with debug symbols
|
||||
- `make build` or `just build` - Use Make/Just for standardized builds
|
||||
|
||||
### Platform-Specific Builds
|
||||
|
||||
- `./build-rustfs.sh --platform x86_64-unknown-linux-musl` - Build for musl target
|
||||
- `./build-rustfs.sh --platform aarch64-unknown-linux-gnu` - Build for ARM64
|
||||
- `make build-musl` or `just build-musl` - Build musl variant
|
||||
- `make build-cross-all` - Build all supported architectures
|
||||
|
||||
### Testing Commands
|
||||
|
||||
- `cargo test --workspace --exclude e2e_test` - Run unit tests (excluding e2e tests)
|
||||
- `cargo nextest run --all --exclude e2e_test` - Use nextest if available (faster)
|
||||
- `cargo test --all --doc` - Run documentation tests
|
||||
- `make test` or `just test` - Run full test suite
|
||||
- `make pre-commit` - Run all quality checks (fmt, clippy, check, test)
|
||||
|
||||
### End-to-End Testing
|
||||
|
||||
- `cargo test --package e2e_test` - Run all e2e tests
|
||||
- `./scripts/run_e2e_tests.sh` - Run e2e tests via script
|
||||
- `./scripts/run_scanner_benchmarks.sh` - Run scanner performance benchmarks
|
||||
|
||||
### KMS-Specific Testing (with proxy bypass)
|
||||
|
||||
-
|
||||
`NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1` -
|
||||
Run complete KMS end-to-end test
|
||||
-
|
||||
`NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test kms:: -- --nocapture --test-threads=1` -
|
||||
Run all KMS tests
|
||||
- `cargo test --package e2e_test test_local_kms_key_isolation -- --nocapture --test-threads=1` - Test KMS key isolation
|
||||
- `cargo test --package e2e_test test_local_kms_large_file -- --nocapture --test-threads=1` - Test KMS with large files
|
||||
|
||||
### Code Quality
|
||||
|
||||
- `cargo fmt --all` - Format code
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings` - Lint code
|
||||
- `make pre-commit` or `just pre-commit` - Run all quality checks (fmt, clippy, check, test)
|
||||
|
||||
### Quick Development Commands
|
||||
|
||||
- `make help` or `just help` - Show all available commands with descriptions
|
||||
- `make help-build` - Show detailed build options and cross-compilation help
|
||||
- `make help-docker` - Show comprehensive Docker build and deployment options
|
||||
- `./scripts/dev_deploy.sh <IP>` - Deploy development build to remote server
|
||||
- `./scripts/run.sh` - Start local development server
|
||||
- `./scripts/probe.sh` - Health check and connectivity testing
|
||||
|
||||
### Docker Build Commands
|
||||
|
||||
- `make docker-buildx` - Build multi-architecture production images
|
||||
- `make docker-dev-local` - Build development image for local use
|
||||
- `./docker-buildx.sh --push` - Build and push production images
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Components
|
||||
|
||||
**Main Binary (`rustfs/`):**
|
||||
|
||||
- Entry point at `rustfs/src/main.rs`
|
||||
- Core modules: admin, auth, config, server, storage, license management, profiling
|
||||
- HTTP server with S3-compatible APIs
|
||||
- Service state management and graceful shutdown
|
||||
- Parallel service initialization with DNS resolver, bucket metadata, and IAM
|
||||
|
||||
**Key Crates (`crates/`):**
|
||||
|
||||
- `ecstore` - Erasure coding storage implementation (core storage layer)
|
||||
- `iam` - Identity and Access Management
|
||||
- `kms` - Key Management Service for encryption and key handling
|
||||
- `madmin` - Management dashboard and admin API interface
|
||||
- `s3select-api` & `s3select-query` - S3 Select API and query engine
|
||||
- `config` - Configuration management with notify features
|
||||
- `crypto` - Cryptography and security features
|
||||
- `lock` - Distributed locking implementation
|
||||
- `filemeta` - File metadata management
|
||||
- `rio` - Rust I/O utilities and abstractions
|
||||
- `common` - Shared utilities and data structures
|
||||
- `protos` - Protocol buffer definitions
|
||||
- `audit-logger` - Audit logging for file operations
|
||||
- `notify` - Event notification system
|
||||
- `obs` - Observability utilities
|
||||
- `workers` - Worker thread pools and task scheduling
|
||||
- `appauth` - Application authentication and authorization
|
||||
- `ahm` - Asynchronous Hash Map for concurrent data structures
|
||||
- `mcp` - MCP server for S3 operations
|
||||
- `signer` - Client request signing utilities
|
||||
- `checksums` - Client checksum calculation utilities
|
||||
- `utils` - General utility functions and helpers
|
||||
- `zip` - ZIP file handling and compression
|
||||
- `targets` - Target-specific configurations and utilities
|
||||
|
||||
### Build System
|
||||
|
||||
- Cargo workspace with 25+ crates (including new KMS functionality)
|
||||
- Custom `build-rustfs.sh` script for advanced build options
|
||||
- Multi-architecture Docker builds via `docker-buildx.sh`
|
||||
- Both Make and Just task runners supported with comprehensive help
|
||||
- Cross-compilation support for multiple Linux targets
|
||||
- Automated CI/CD with GitHub Actions for testing, building, and Docker publishing
|
||||
- Performance benchmarking and audit workflows
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
- `axum` - HTTP framework for S3 API server
|
||||
- `tokio` - Async runtime
|
||||
- `s3s` - S3 protocol implementation library
|
||||
- `datafusion` - For S3 Select query processing
|
||||
- `hyper`/`hyper-util` - HTTP client/server utilities
|
||||
- `rustls` - TLS implementation
|
||||
- `serde`/`serde_json` - Serialization
|
||||
- `tracing` - Structured logging and observability
|
||||
- `pprof` - Performance profiling with flamegraph support
|
||||
- `tikv-jemallocator` - Memory allocator for Linux GNU builds
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- Console resources are embedded during build via `rust-embed`
|
||||
- Protocol buffers generated via custom `gproto` binary
|
||||
- E2E tests in separate crate (`e2e_test`) with comprehensive KMS testing
|
||||
- Shadow build for version/metadata embedding
|
||||
- Support for both GNU and musl libc targets
|
||||
- Development scripts in `scripts/` directory for common tasks
|
||||
- Git hooks setup available via `make setup-hooks` or `just setup-hooks`
|
||||
|
||||
### Performance & Observability
|
||||
|
||||
- Performance profiling available with `pprof` integration (disabled on Windows)
|
||||
- Profiling enabled via environment variables in production
|
||||
- Built-in observability with OpenTelemetry integration
|
||||
- Background services (scanner, heal) can be controlled via environment variables:
|
||||
- `RUSTFS_ENABLE_SCANNER` (default: true)
|
||||
- `RUSTFS_ENABLE_HEAL` (default: true)
|
||||
|
||||
### Service Architecture
|
||||
|
||||
- Service state management with graceful shutdown handling
|
||||
- Parallel initialization of core systems (DNS, bucket metadata, IAM)
|
||||
- Event notification system with MQTT and webhook support
|
||||
- Auto-heal and data scanner for storage integrity
|
||||
- Jemalloc allocator for Linux GNU targets for better performance
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `RUSTFS_ENABLE_SCANNER` - Enable/disable background data scanner (default: true)
|
||||
- `RUSTFS_ENABLE_HEAL` - Enable/disable auto-heal functionality (default: true)
|
||||
- Various profiling and observability controls
|
||||
- Build-time variables for Docker builds (RELEASE, REGISTRY, etc.)
|
||||
- Test environment configurations in `scripts/dev_rustfs.env`
|
||||
|
||||
### KMS Environment Variables
|
||||
|
||||
- `NO_PROXY=127.0.0.1,localhost` - Required for KMS E2E tests to bypass proxy
|
||||
- `HTTP_PROXY=` `HTTPS_PROXY=` `http_proxy=` `https_proxy=` - Clear proxy settings for local KMS testing
|
||||
|
||||
## KMS (Key Management Service) Architecture
|
||||
|
||||
### KMS Implementation Status
|
||||
|
||||
- **Full KMS Integration:** Complete implementation with Local and Vault backends
|
||||
- **Automatic Configuration:** KMS auto-configures on startup with `--kms-enable` flag
|
||||
- **Encryption Support:** Full S3-compatible server-side encryption (SSE-S3, SSE-KMS, SSE-C)
|
||||
- **Admin API:** Complete KMS management via HTTP admin endpoints
|
||||
- **Production Ready:** Comprehensive testing including large files and key isolation
|
||||
|
||||
### KMS Configuration
|
||||
|
||||
- **Local Backend:** `--kms-backend local --kms-key-dir <path> --kms-default-key-id <id>`
|
||||
- **Vault Backend:** `--kms-backend vault --kms-vault-endpoint <url> --kms-vault-key-name <name>`
|
||||
- **Auto-startup:** KMS automatically initializes when `--kms-enable` is provided
|
||||
- **Manual Configuration:** Also supports dynamic configuration via admin API
|
||||
|
||||
### S3 Encryption Support
|
||||
|
||||
- **SSE-S3:** Server-side encryption with S3-managed keys (`ServerSideEncryption: AES256`)
|
||||
- **SSE-KMS:** Server-side encryption with KMS-managed keys (`ServerSideEncryption: aws:kms`)
|
||||
- **SSE-C:** Server-side encryption with customer-provided keys
|
||||
- **Response Headers:** All encryption types return correct `server_side_encryption` headers in PUT/GET responses
|
||||
|
||||
### KMS Testing Architecture
|
||||
|
||||
- **Comprehensive E2E Tests:** Located in `crates/e2e_test/src/kms/`
|
||||
- **Test Environments:** Automated test environment setup with temporary directories
|
||||
- **Encryption Coverage:** Tests all three encryption types (SSE-S3, SSE-KMS, SSE-C)
|
||||
- **API Coverage:** Tests all KMS admin APIs (CreateKey, DescribeKey, ListKeys, etc.)
|
||||
- **Edge Cases:** Key isolation, large file handling, error scenarios
|
||||
|
||||
### Key Files for KMS
|
||||
|
||||
- `crates/kms/` - Core KMS implementation with Local/Vault backends
|
||||
- `rustfs/src/main.rs` - KMS auto-initialization in `init_kms_system()`
|
||||
- `rustfs/src/storage/ecfs.rs` - SSE encryption/decryption in PUT/GET operations
|
||||
- `rustfs/src/admin/handlers/kms*.rs` - KMS admin endpoints
|
||||
- `crates/e2e_test/src/kms/` - Comprehensive KMS test suite
|
||||
- `crates/rio/src/encrypt_reader.rs` - Streaming encryption for large files
|
||||
|
||||
## Code Style and Safety Requirements
|
||||
|
||||
- **Language Requirements:**
|
||||
- Communicate with me in Chinese, but **only English can be used in code files**
|
||||
- Code comments, function names, variable names, and all text in source files must be in English only
|
||||
- No Chinese characters, emojis, or non-ASCII characters are allowed in any source code files
|
||||
- This includes comments, strings, documentation, and any other text within code files
|
||||
- **Safety-Critical Rules:**
|
||||
- `unsafe_code = "deny"` enforced at workspace level
|
||||
- Never use `unwrap()`, `expect()`, or panic-inducing code except in tests
|
||||
- Avoid blocking I/O operations in async contexts
|
||||
- Use proper error handling with `Result<T, E>` and `Option<T>`
|
||||
- Follow Rust's ownership and borrowing rules strictly
|
||||
- **Performance Guidelines:**
|
||||
- Use `cargo clippy --all-targets --all-features -- -D warnings` to catch issues
|
||||
- Prefer `anyhow` for error handling in applications, `thiserror` for libraries
|
||||
- Use appropriate async runtimes and avoid blocking calls
|
||||
- **Testing Standards:**
|
||||
- All new features must include comprehensive tests
|
||||
- Use `#[cfg(test)]` for test-only code that may use panic macros
|
||||
- E2E tests should cover KMS integration scenarios
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Running KMS Tests Locally
|
||||
|
||||
1. **Clear proxy settings:** KMS tests require direct localhost connections
|
||||
2. **Use serial execution:** `--test-threads=1` prevents port conflicts
|
||||
3. **Enable output:** `--nocapture` shows detailed test logs
|
||||
4. **Full command:**
|
||||
`NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1`
|
||||
|
||||
### KMS Development Workflow
|
||||
|
||||
1. **Code changes:** Modify KMS-related code in `crates/kms/` or `rustfs/src/`
|
||||
2. **Compile:** Always run `cargo build` after changes
|
||||
3. **Test specific functionality:** Use targeted test commands for faster iteration
|
||||
4. **Full validation:** Run complete end-to-end tests before commits
|
||||
|
||||
### Debugging KMS Issues
|
||||
|
||||
- **Server startup:** Check that KMS auto-initializes with debug logs
|
||||
- **Encryption failures:** Verify SSE headers are correctly set in both PUT and GET responses
|
||||
- **Test failures:** Use `--nocapture` to see detailed error messages
|
||||
- **Key management:** Test admin API endpoints with proper authentication
|
||||
|
||||
## Important Reminders
|
||||
|
||||
- **Always compile after code changes:** Use `cargo build` to catch errors early
|
||||
- **Don't bypass tests:** All functionality must be properly tested, not worked around
|
||||
- **Use proper error handling:** Never use `unwrap()` or `expect()` in production code (except tests)
|
||||
- **Follow S3 compatibility:** Ensure all encryption types return correct HTTP response headers
|
||||
|
||||
# important-instruction-reminders
|
||||
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
ALWAYS prefer editing an existing file to creating a new one.
|
||||
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly
|
||||
requested by the User.
|
||||
@@ -186,6 +186,39 @@ cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo clippy --fix --all-targets --all-features
|
||||
```
|
||||
|
||||
## 📝 Pull Request Guidelines
|
||||
|
||||
### Language Requirements
|
||||
|
||||
**All Pull Request titles and descriptions MUST be written in English.**
|
||||
|
||||
This ensures:
|
||||
- Consistency across all contributions
|
||||
- Accessibility for international contributors
|
||||
- Better integration with automated tools and CI/CD systems
|
||||
- Clear communication in a globally understood language
|
||||
|
||||
#### PR Description Requirements
|
||||
|
||||
When creating a Pull Request, ensure:
|
||||
|
||||
1. **Title**: Use English and follow Conventional Commits format (e.g., `fix: improve s3-tests readiness detection`)
|
||||
2. **Description**: Write in English, following the PR template format
|
||||
3. **Code Comments**: Must be in English (as per coding standards)
|
||||
4. **Commit Messages**: Must be in English (as per commit guidelines)
|
||||
|
||||
#### PR Template
|
||||
|
||||
Always use the PR template (`.github/pull_request_template.md`) and fill in all sections:
|
||||
- Type of Change
|
||||
- Related Issues
|
||||
- Summary of Changes
|
||||
- Checklist
|
||||
- Impact
|
||||
- Additional Notes
|
||||
|
||||
**Note**: While you may communicate with reviewers in Chinese during discussions, the PR itself (title, description, and all formal documentation) must be in English.
|
||||
|
||||
---
|
||||
|
||||
Following these guidelines ensures high code quality and smooth collaboration across the RustFS project! 🚀
|
||||
|
||||
Generated
+1877
-1826
File diff suppressed because it is too large
Load Diff
+95
-81
@@ -17,6 +17,7 @@ members = [
|
||||
"rustfs", # Core file system implementation
|
||||
"crates/appauth", # Application authentication and authorization
|
||||
"crates/audit", # Audit target management system with multi-target fan-out
|
||||
"crates/checksums", # client checksums
|
||||
"crates/common", # Shared utilities and data structures
|
||||
"crates/config", # Configuration management
|
||||
"crates/credentials", # Credential management system
|
||||
@@ -24,25 +25,28 @@ members = [
|
||||
"crates/ecstore", # Erasure coding storage implementation
|
||||
"crates/e2e_test", # End-to-end test suite
|
||||
"crates/filemeta", # File metadata management
|
||||
"crates/heal", # Erasure set and object healing
|
||||
"crates/iam", # Identity and Access Management
|
||||
"crates/kms", # Key Management Service
|
||||
"crates/lock", # Distributed locking implementation
|
||||
"crates/madmin", # Management dashboard and admin API interface
|
||||
"crates/mcp", # MCP server for S3 operations
|
||||
"crates/metrics", # Metrics collection and reporting
|
||||
"crates/notify", # Notification system for events
|
||||
"crates/obs", # Observability utilities
|
||||
"crates/policy", # Policy management
|
||||
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
||||
"crates/protos", # Protocol buffer definitions
|
||||
"crates/rio", # Rust I/O utilities and abstractions
|
||||
"crates/targets", # Target-specific configurations and utilities
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||
"crates/signer", # client signer
|
||||
"crates/checksums", # client checksums
|
||||
"crates/targets", # Target-specific configurations and utilities
|
||||
"crates/trusted-proxies", # Trusted proxies management
|
||||
"crates/utils", # Utility functions and helpers
|
||||
"crates/workers", # Worker thread pools and task scheduling
|
||||
"crates/zip", # ZIP file handling and compression
|
||||
"crates/ahm", # Asynchronous Hash Map for concurrent data structures
|
||||
"crates/mcp", # MCP server for S3 operations
|
||||
"crates/kms", # Key Management Service
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -50,12 +54,13 @@ resolver = "2"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.93.0"
|
||||
version = "0.0.5"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
categories = ["web-programming", "development-tools", "filesystem", "network-programming"]
|
||||
authors = ["RustFS Team"]
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
@@ -66,7 +71,7 @@ all = "warn"
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "0.0.5" }
|
||||
rustfs-ahm = { path = "crates/ahm", version = "0.0.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "0.0.5" }
|
||||
rustfs-appauth = { path = "crates/appauth", version = "0.0.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "0.0.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "0.0.5" }
|
||||
@@ -81,6 +86,7 @@ rustfs-kms = { path = "crates/kms", version = "0.0.5" }
|
||||
rustfs-lock = { path = "crates/lock", version = "0.0.5" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "0.0.5" }
|
||||
rustfs-mcp = { path = "crates/mcp", version = "0.0.5" }
|
||||
rustfs-metrics = { path = "crates/metrics", version = "0.0.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "0.0.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "0.0.5" }
|
||||
rustfs-policy = { path = "crates/policy", version = "0.0.5" }
|
||||
@@ -88,173 +94,176 @@ rustfs-protos = { path = "crates/protos", version = "0.0.5" }
|
||||
rustfs-rio = { path = "crates/rio", version = "0.0.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "0.0.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "0.0.5" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "0.0.5" }
|
||||
rustfs-signer = { path = "crates/signer", version = "0.0.5" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "0.0.5" }
|
||||
rustfs-targets = { path = "crates/targets", version = "0.0.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "0.0.5" }
|
||||
rustfs-workers = { path = "crates/workers", version = "0.0.5" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "0.0.5" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "0.0.5" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async-compression = { version = "0.4.19" }
|
||||
async-compression = { version = "0.4.39" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.89"
|
||||
axum = "0.8.8"
|
||||
axum-server = { version = "0.8.0", features = ["tls-rustls-no-provider"], default-features = false }
|
||||
axum-server = { version = "0.8.0", features = ["tls-rustls"], default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-core = "0.3.31"
|
||||
futures-util = "0.3.31"
|
||||
pollster = "0.4.0"
|
||||
hyper = { version = "1.8.1", features = ["http2", "http1", "server"] }
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "ring", "webpki-roots"] }
|
||||
hyper-util = { version = "0.1.19", features = ["tokio", "server-auto", "server-graceful"] }
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
http = "1.4.0"
|
||||
http-body = "1.0.1"
|
||||
http-body-util = "0.1.3"
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-webpki-roots", "charset", "http2", "system-proxy", "stream", "json", "blocking"] }
|
||||
socket2 = "0.6.1"
|
||||
tokio = { version = "1.48.0", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "ring"] }
|
||||
tokio-stream = { version = "0.1.17" }
|
||||
tokio-test = "0.4.4"
|
||||
tokio-util = { version = "0.7.17", features = ["io", "compat"] }
|
||||
tonic = { version = "0.14.2", features = ["gzip"] }
|
||||
tonic-prost = { version = "0.14.2" }
|
||||
tonic-prost-build = { version = "0.14.2" }
|
||||
tower = { version = "0.5.2", features = ["timeout"] }
|
||||
reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
tokio = { version = "1.49.0", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
|
||||
tokio-stream = { version = "0.1.18" }
|
||||
tokio-test = "0.4.5"
|
||||
tokio-util = { version = "0.7.18", features = ["io", "compat"] }
|
||||
tonic = { version = "0.14.3", features = ["gzip"] }
|
||||
tonic-prost = { version = "0.14.3" }
|
||||
tonic-prost-build = { version = "0.14.3" }
|
||||
tower = { version = "0.5.3", features = ["timeout"] }
|
||||
tower-http = { version = "0.6.8", features = ["cors"] }
|
||||
|
||||
# Serialization and Data Formats
|
||||
bytes = { version = "1.11.0", features = ["serde"] }
|
||||
bytes = { version = "1.11.1", features = ["serde"] }
|
||||
bytesize = "2.3.1"
|
||||
byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.1"
|
||||
quick-xml = "0.38.4"
|
||||
rmcp = { version = "0.12.0" }
|
||||
prost = "0.14.3"
|
||||
quick-xml = "0.39.0"
|
||||
rmcp = { version = "0.15.0" }
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.148", features = ["raw_value"] }
|
||||
serde_json = { version = "1.0.149", features = ["raw_value"] }
|
||||
serde_urlencoded = "0.7.1"
|
||||
schemars = "1.2.0"
|
||||
schemars = "1.2.1"
|
||||
|
||||
# Cryptography and Security
|
||||
aes-gcm = { version = "0.11.0-rc.2", features = ["rand_core"] }
|
||||
argon2 = { version = "0.6.0-rc.5" }
|
||||
blake3 = { version = "1.8.2", features = ["rayon", "mmap"] }
|
||||
chacha20poly1305 = { version = "0.11.0-rc.2" }
|
||||
crc-fast = "1.6.0"
|
||||
hmac = { version = "0.13.0-rc.3" }
|
||||
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||
pbkdf2 = "0.13.0-rc.5"
|
||||
rsa = { version = "0.10.0-rc.10" }
|
||||
rustls = { version = "0.23.35", features = ["ring", "logging", "std", "tls12"], default-features = false }
|
||||
aes-gcm = { version = "0.11.0-rc.3", features = ["rand_core"] }
|
||||
argon2 = { version = "0.6.0-rc.7" }
|
||||
blake3 = { version = "1.8.3", features = ["rayon", "mmap"] }
|
||||
chacha20poly1305 = { version = "0.11.0-rc.3" }
|
||||
crc-fast = "1.9.0"
|
||||
hmac = { version = "0.13.0-rc.5" }
|
||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||
pbkdf2 = "0.13.0-rc.9"
|
||||
rsa = { version = "0.10.0-rc.15" }
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls-pemfile = "2.2.0"
|
||||
rustls-pki-types = "1.13.2"
|
||||
sha1 = "0.11.0-rc.3"
|
||||
sha2 = "0.11.0-rc.3"
|
||||
rustls-pki-types = "1.14.0"
|
||||
sha1 = "0.11.0-rc.5"
|
||||
sha2 = "0.11.0-rc.5"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
humantime = "2.3.0"
|
||||
time = { version = "0.3.44", features = ["std", "parsing", "formatting", "macros", "serde"] }
|
||||
jiff = { version = "0.2.19", features = ["serde"] }
|
||||
time = { version = "0.3.47", features = ["std", "parsing", "formatting", "macros", "serde"] }
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.100"
|
||||
arc-swap = "1.8.0"
|
||||
anyhow = "1.0.101"
|
||||
arc-swap = "1.8.1"
|
||||
astral-tokio-tar = "0.5.6"
|
||||
atoi = "2.0.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.8.12" }
|
||||
aws-config = { version = "1.8.13" }
|
||||
aws-credential-types = { version = "1.2.11" }
|
||||
aws-sdk-s3 = { version = "1.119.0", default-features = false, features = ["sigv4a", "rustls", "rt-tokio"] }
|
||||
aws-smithy-types = { version = "1.3.5" }
|
||||
aws-sdk-s3 = { version = "1.122.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
aws-smithy-types = { version = "1.4.4" }
|
||||
backtrace = "0.3.76"
|
||||
base64 = "0.22.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.2"
|
||||
cfg-if = "1.0.4"
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
const-str = { version = "0.7.1", features = ["std", "proc"] }
|
||||
convert_case = "0.10.0"
|
||||
clap = { version = "4.5.57", features = ["derive", "env"] }
|
||||
const-str = { version = "1.1.0", features = ["std", "proc"] }
|
||||
convert_case = "0.11.0"
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
crossbeam-queue = "0.3.12"
|
||||
datafusion = "51.0.0"
|
||||
datafusion = "52.1.0"
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.10"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.5"
|
||||
flexi_logger = { version = "0.31.7", features = ["trc", "dont_minimize_extra_stacks", "compress", "kv", "json"] }
|
||||
flate2 = "1.1.9"
|
||||
flexi_logger = { version = "0.31.8", features = ["trc", "dont_minimize_extra_stacks", "compress", "kv", "json"] }
|
||||
glob = "0.3.3"
|
||||
google-cloud-storage = "1.5.0"
|
||||
google-cloud-auth = "1.3.0"
|
||||
google-cloud-storage = "1.7.0"
|
||||
google-cloud-auth = "1.5.0"
|
||||
hashbrown = { version = "0.16.1", features = ["serde", "rayon"] }
|
||||
heed = { version = "0.22.0" }
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.178"
|
||||
libc = "0.2.181"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.8"
|
||||
local-ip-address = "0.6.10"
|
||||
lz4 = "1.28.1"
|
||||
matchit = "0.9.1"
|
||||
md-5 = "0.11.0-rc.3"
|
||||
md-5 = "0.11.0-rc.5"
|
||||
md5 = "0.8.0"
|
||||
mime_guess = "2.0.5"
|
||||
moka = { version = "0.12.12", features = ["future"] }
|
||||
moka = { version = "0.12.13", features = ["future"] }
|
||||
netif = "0.1.6"
|
||||
nix = { version = "0.30.1", features = ["fs"] }
|
||||
nu-ansi-term = "0.50.3"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.11.0"
|
||||
object_store = "0.12.4"
|
||||
object_store = "0.12.5"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "3.1.1"
|
||||
path-clean = "1.0.1"
|
||||
pin-project-lite = "0.2.16"
|
||||
pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.0-rc.5", features = ["serde"] }
|
||||
rand = { version = "0.10.0", features = ["serde"] }
|
||||
rayon = "1.11.0"
|
||||
reed-solomon-simd = { version = "3.1.0" }
|
||||
regex = { version = "1.12.2" }
|
||||
regex = { version = "1.12.3" }
|
||||
rumqttc = { version = "0.25.1" }
|
||||
rust-embed = { version = "8.9.0" }
|
||||
rustix = { version = "1.1.3", features = ["fs"] }
|
||||
rust-embed = { version = "8.11.0" }
|
||||
rustc-hash = { version = "2.1.1" }
|
||||
s3s = { version = "0.13.0-alpha", features = ["minio"], git = "https://github.com/s3s-project/s3s.git", branch = "main" }
|
||||
serial_test = "3.2.0"
|
||||
shadow-rs = { version = "1.5.0", default-features = false }
|
||||
siphasher = "1.0.1"
|
||||
s3s = { version = "0.13.0-alpha.3", features = ["minio"], git = "https://github.com/s3s-project/s3s.git", rev = "7b8e22b49bc76839e6085a4ddfa553323f1b8049" }
|
||||
serial_test = "3.3.1"
|
||||
shadow-rs = { version = "1.7.0", default-features = false }
|
||||
siphasher = "1.0.2"
|
||||
smallvec = { version = "1.15.1", features = ["serde"] }
|
||||
smartstring = "1.0.1"
|
||||
snafu = "0.8.9"
|
||||
snap = "1.1.1"
|
||||
starshard = { version = "0.6.0", features = ["rayon", "async", "serde"] }
|
||||
starshard = { version = "1.1.0", features = ["rayon", "async", "serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sysctl = "0.7.1"
|
||||
sysinfo = "0.37.2"
|
||||
sysinfo = "0.38.1"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.24.0"
|
||||
tempfile = "3.25.0"
|
||||
test-case = "3.3.1"
|
||||
thiserror = "2.0.17"
|
||||
thiserror = "2.0.18"
|
||||
tracing = { version = "0.1.44" }
|
||||
tracing-appender = "0.2.4"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-opentelemetry = "0.32.0"
|
||||
tracing-opentelemetry = "0.32.1"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "time"] }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.7"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.19.0", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
uuid = { version = "1.20.0", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
vaultrs = { version = "0.7.4" }
|
||||
walkdir = "2.5.0"
|
||||
wildmatch = { version = "2.6.1", features = ["serde"] }
|
||||
winapi = { version = "0.3.9" }
|
||||
windows = { version = "0.62.2" }
|
||||
xxhash-rust = { version = "0.8.15", features = ["xxh64", "xxh3"] }
|
||||
zip = "7.0.0"
|
||||
zip = "7.4.0"
|
||||
zstd = "0.13.3"
|
||||
|
||||
# Observability and Metrics
|
||||
@@ -266,6 +275,11 @@ opentelemetry_sdk = { version = "0.31.0" }
|
||||
opentelemetry-semantic-conventions = { version = "0.31.0", features = ["semconv_experimental"] }
|
||||
opentelemetry-stdout = { version = "0.31.0" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.22.0", features = ["experimental"] }
|
||||
suppaftp = { version = "8.0.1", features = ["tokio", "tokio-rustls-aws-lc-rs"] }
|
||||
rcgen = "0.14.7"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = "0.1"
|
||||
# Use tikv-jemallocator as memory allocator and enable performance analysis
|
||||
@@ -273,7 +287,7 @@ tikv-jemallocator = { version = "0.6", features = ["profiling", "stats", "unpref
|
||||
# Used to control and obtain statistics for jemalloc at runtime
|
||||
tikv-jemalloc-ctl = { version = "0.6", features = ["use_std", "stats", "profiling"] }
|
||||
# Used to generate pprof-compatible memory profiling data and support symbolization and flame graphs
|
||||
jemalloc_pprof = { version = "0.8.1", features = ["symbolize", "flamegraph"] }
|
||||
jemalloc_pprof = { version = "0.8.2", features = ["symbolize", "flamegraph"] }
|
||||
# Used to generate CPU performance analysis data and flame diagrams
|
||||
pprof = { version = "0.15.0", features = ["flamegraph", "protobuf-codec"] }
|
||||
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.22 AS build
|
||||
FROM alpine:3.23 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
@@ -40,7 +40,7 @@ RUN set -eux; \
|
||||
rm -rf rustfs.zip /build/.tmp || true
|
||||
|
||||
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.23
|
||||
|
||||
ARG RELEASE=latest
|
||||
ARG BUILD_DATE
|
||||
@@ -77,11 +77,12 @@ ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_EXTERNAL_ADDRESS="" \
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUST_LOG="warn"
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
RUSTFS_OBS_LOG_DIRECTORY=/logs \
|
||||
RUSTFS_OBS_ENVIRONMENT=production
|
||||
|
||||
EXPOSE 9000 9001
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
FROM ubuntu:24.04 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN set -eux; \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) ARCH_SUBSTR="x86_64-gnu" ;; \
|
||||
arm64) ARCH_SUBSTR="aarch64-gnu" ;; \
|
||||
*) echo "Unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
\
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
else \
|
||||
TAG="$RELEASE"; \
|
||||
fi; \
|
||||
\
|
||||
URL="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" \
|
||||
| grep -o "\"browser_download_url\": \"[^\"]*${ARCH_SUBSTR}[^\"]*\\.zip\"" \
|
||||
| cut -d'"' -f4 | head -n 1)"; \
|
||||
\
|
||||
if [ -z "$URL" ]; then echo "Failed to locate release asset for $ARCH_SUBSTR at tag $TAG" >&2; exit 1; fi; \
|
||||
\
|
||||
curl -fL "$URL" -o rustfs.zip; \
|
||||
unzip -q rustfs.zip -d /build; \
|
||||
\
|
||||
if [ ! -x /build/rustfs ]; then \
|
||||
BIN_PATH="$(unzip -Z -1 rustfs.zip | grep -E '(^|/)rustfs$' | head -n 1 || true)"; \
|
||||
if [ -n "$BIN_PATH" ]; then \
|
||||
mkdir -p /build/.tmp && unzip -q rustfs.zip "$BIN_PATH" -d /build/.tmp && \
|
||||
mv "/build/.tmp/$BIN_PATH" /build/rustfs; \
|
||||
fi; \
|
||||
fi; \
|
||||
[ -x /build/rustfs ] || { echo "rustfs binary not found in asset" >&2; exit 1; }; \
|
||||
chmod +x /build/rustfs; \
|
||||
rm -rf rustfs.zip /build/.tmp || true
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG RELEASE=latest
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
LABEL name="RustFS" \
|
||||
vendor="RustFS Team" \
|
||||
maintainer="RustFS Team <dev@rustfs.com>" \
|
||||
version="v${RELEASE#v}" \
|
||||
release="${RELEASE}" \
|
||||
build-date="${BUILD_DATE}" \
|
||||
vcs-ref="${VCS_REF}" \
|
||||
summary="High-performance distributed object storage system (glibc)" \
|
||||
url="https://rustfs.com" \
|
||||
license="Apache-2.0"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /build/rustfs /usr/bin/rustfs
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
|
||||
|
||||
RUN groupadd -g 10001 rustfs && \
|
||||
useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \
|
||||
mkdir -p /data /logs && \
|
||||
chown -R rustfs:rustfs /data /logs && \
|
||||
chmod 0750 /data /logs
|
||||
|
||||
ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_CONSOLE_ADDRESS=":9001" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
RUSTFS_OBS_LOG_DIRECTORY=/logs \
|
||||
RUSTFS_OBS_ENVIRONMENT=production
|
||||
|
||||
EXPOSE 9000 9001
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER rustfs
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
CMD ["rustfs"]
|
||||
+5
-1
@@ -16,7 +16,7 @@ ARG BUILDPLATFORM
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
# -----------------------------
|
||||
FROM rust:1.88-bookworm AS builder
|
||||
FROM rust:1.91-trixie AS builder
|
||||
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
@@ -83,6 +83,10 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
# 3) copy full sources (this is the main cache invalidation point)
|
||||
COPY . .
|
||||
|
||||
# Generate static files
|
||||
|
||||
RUN ./scripts/static.sh
|
||||
|
||||
# Cargo build configuration for lean release artifacts
|
||||
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
|
||||
@@ -19,7 +19,7 @@ NUM_CORES := $(shell nproc 2>/dev/null || sysctl -n hw.ncpu)
|
||||
MAKEFLAGS += -j$(NUM_CORES) -l$(NUM_CORES)
|
||||
MAKEFLAGS += --silent
|
||||
|
||||
SHELL:= /bin/bash
|
||||
SHELL := $(shell which bash)
|
||||
.SHELLFLAGS = -eu -o pipefail -c
|
||||
|
||||
DOCKER_CLI ?= docker
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](https://rustfs.com)
|
||||
[](https://rustfs.com)
|
||||
|
||||
<p align="center">RustFS is a high-performance, distributed object storage system built in Rust.</p>
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.rustfs.com/installation/">Getting Started</a>
|
||||
· <a href="https://docs.rustfs.com/">Docs</a>
|
||||
@@ -46,24 +46,21 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
|
||||
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
|
||||
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
||||
|
||||
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :---------------------- | :----------- | :----------------------- | :--------------- |
|
||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
||||
|
||||
## RustFS vs MinIO Performance
|
||||
|
||||
**Stress Test Environment:**
|
||||
|
||||
| Type | Parameter | Remark |
|
||||
|---------|-----------|----------------------------------------------------------|
|
||||
| ------- | --------- | -------------------------------------------------------- |
|
||||
| CPU | 2 Core | Intel Xeon (Sapphire Rapids) Platinum 8475B, 2.7/3.2 GHz |
|
||||
| Memory | 4GB | |
|
||||
| Network | 15Gbps | |
|
||||
@@ -73,15 +70,21 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
|
||||
|
||||
### RustFS vs Other Object Storage
|
||||
|
||||
| Feature | RustFS | Other Object Storage |
|
||||
| :--- | :--- | :--- |
|
||||
| **Console Experience** | **Powerful Console**<br>Comprehensive management interface. | **Basic / Limited Console**<br>Often overly simple or lacking critical features. |
|
||||
| **Language & Safety** | **Rust-based**<br>Memory safety by design. | **Go or C-based**<br>Potential for memory GC pauses or leaks. |
|
||||
| **Data Sovereignty** | **No Telemetry / Full Compliance**<br>Guards against unauthorized cross-border data egress. Compliant with GDPR (EU/UK), CCPA (US), and APPI (Japan). | **Potential Risk**<br>Possible legal exposure and unwanted data telemetry. |
|
||||
| **Licensing** | **Permissive Apache 2.0**<br>Business-friendly, no "poison pill" clauses. | **Restrictive AGPL v3**<br>Risk of license traps and intellectual property pollution. |
|
||||
| **Compatibility** | **100% S3 Compatible**<br>Works with any cloud provider or client, anywhere. | **Variable Compatibility**<br>May lack support for local cloud vendors or specific APIs. |
|
||||
| **Edge & IoT** | **Strong Edge Support**<br>Ideal for secure, innovative edge devices. | **Weak Edge Support**<br>Often too heavy for edge gateways. |
|
||||
| **Risk Profile** | **Enterprise Risk Mitigation**<br>Clear IP rights and safe for commercial use. | **Legal Risks**<br>Intellectual property ambiguity and usage restrictions. |
|
||||
| Feature | RustFS | Other Object Storage |
|
||||
| :--------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------- |
|
||||
| **Console Experience** | **Powerful Console**<br>Comprehensive management interface. | **Basic / Limited Console**<br>Often overly simple or lacking critical features. |
|
||||
| **Language & Safety** | **Rust-based**<br>Memory safety by design. | **Go or C-based**<br>Potential for memory GC pauses or leaks. |
|
||||
| **Data Sovereignty** | **No Telemetry / Full Compliance**<br>Guards against unauthorized cross-border data egress. Compliant with GDPR (EU/UK), CCPA (US), and APPI (Japan). | **Potential Risk**<br>Possible legal exposure and unwanted data telemetry. |
|
||||
| **Licensing** | **Permissive Apache 2.0**<br>Business-friendly, no "poison pill" clauses. | **Restrictive AGPL v3**<br>Risk of license traps and intellectual property pollution. |
|
||||
| **Compatibility** | **100% S3 Compatible**<br>Works with any cloud provider or client, anywhere. | **Variable Compatibility**<br>May lack support for local cloud vendors or specific APIs. |
|
||||
| **Edge & IoT** | **Strong Edge Support**<br>Ideal for secure, innovative edge devices. | **Weak Edge Support**<br>Often too heavy for edge gateways. |
|
||||
| **Risk Profile** | **Enterprise Risk Mitigation**<br>Clear IP rights and safe for commercial use. | **Legal Risks**<br>Intellectual property ambiguity and usage restrictions. |
|
||||
|
||||
## Staying ahead
|
||||
|
||||
Star RustFS on GitHub and be instantly notified of new releases.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/7ee40bb4-3e46-4eac-b0d0-5fbeb85ff8f3" />
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -89,9 +92,9 @@ To get started with RustFS, follow these steps:
|
||||
|
||||
### 1. One-click Installation (Option 1)
|
||||
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
````
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
```
|
||||
|
||||
### 2\. Docker Quick Start (Option 2)
|
||||
|
||||
@@ -111,12 +114,24 @@ The RustFS container runs as a non-root user `rustfs` (UID `10001`). If you run
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-alpha.76
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
```bash
|
||||
podman run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
```
|
||||
|
||||
You can also use Docker Compose. Using the `docker-compose.yml` file in the root directory:
|
||||
|
||||
```bash
|
||||
docker compose --profile observability up -d
|
||||
```
|
||||
|
||||
Similarly, you can run the command with podman
|
||||
|
||||
```bash
|
||||
podman compose --profile observability up -d
|
||||
```
|
||||
|
||||
**NOTE**: We recommend reviewing the `docker-compose.yaml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||
|
||||
### 3\. Build from Source (Option 3) - Advanced Users
|
||||
@@ -138,10 +153,11 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
||||
```
|
||||
|
||||
The `docker-buildx.sh` script supports:
|
||||
\- **Multi-architecture builds**: `linux/amd64`, `linux/arm64`
|
||||
\- **Automatic version detection**: Uses git tags or commit hashes
|
||||
\- **Registry flexibility**: Supports Docker Hub, GitHub Container Registry, etc.
|
||||
\- **Build optimization**: Includes caching and parallel builds
|
||||
|
||||
- **Multi-architecture builds**: `linux/amd64`, `linux/arm64`
|
||||
- **Automatic version detection**: Uses git tags or commit hashes
|
||||
- **Registry flexibility**: Supports Docker Hub, GitHub Container Registry, etc.
|
||||
- **Build optimization**: Includes caching and parallel builds
|
||||
|
||||
You can also use Make targets for convenience:
|
||||
|
||||
@@ -175,14 +191,14 @@ nix build
|
||||
nix run
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### Accessing RustFS
|
||||
|
||||
5. **Access the Console**: Open your web browser and navigate to `http://localhost:9001` to access the RustFS console.
|
||||
* Default credentials: `rustfsadmin` / `rustfsadmin`
|
||||
6. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||
7. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
||||
1. **Access the Console**: Open your web browser and navigate to `http://localhost:9001` to access the RustFS console.
|
||||
- Default credentials: `rustfsadmin` / `rustfsadmin`
|
||||
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
||||
|
||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html).
|
||||
|
||||
@@ -194,23 +210,23 @@ For detailed documentation, including configuration options, API references, and
|
||||
|
||||
If you have any questions or need assistance:
|
||||
|
||||
- Check the [FAQ](https://github.com/rustfs/rustfs/discussions/categories/q-a) for common issues and solutions.
|
||||
- Join our [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) to ask questions and share your experiences.
|
||||
- Open an issue on our [GitHub Issues](https://github.com/rustfs/rustfs/issues) page for bug reports or feature requests.
|
||||
- Check the [FAQ](https://github.com/rustfs/rustfs/discussions/categories/q-a) for common issues and solutions.
|
||||
- Join our [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) to ask questions and share your experiences.
|
||||
- Open an issue on our [GitHub Issues](https://github.com/rustfs/rustfs/issues) page for bug reports or feature requests.
|
||||
|
||||
## Links
|
||||
|
||||
- [Documentation](https://docs.rustfs.com) - The manual you should read
|
||||
- [Changelog](https://github.com/rustfs/rustfs/releases) - What we broke and fixed
|
||||
- [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) - Where the community lives
|
||||
- [Documentation](https://docs.rustfs.com) - The manual you should read
|
||||
- [Changelog](https://github.com/rustfs/rustfs/releases) - What we broke and fixed
|
||||
- [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) - Where the community lives
|
||||
|
||||
## Contact
|
||||
|
||||
- **Bugs**: [GitHub Issues](https://github.com/rustfs/rustfs/issues)
|
||||
- **Business**: [hello@rustfs.com](mailto:hello@rustfs.com)
|
||||
- **Jobs**: [jobs@rustfs.com](mailto:jobs@rustfs.com)
|
||||
- **General Discussion**: [GitHub Discussions](https://github.com/rustfs/rustfs/discussions)
|
||||
- **Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- **Bugs**: [GitHub Issues](https://github.com/rustfs/rustfs/issues)
|
||||
- **Business**: [hello@rustfs.com](mailto:hello@rustfs.com)
|
||||
- **Jobs**: [jobs@rustfs.com](mailto:jobs@rustfs.com)
|
||||
- **General Discussion**: [GitHub Discussions](https://github.com/rustfs/rustfs/discussions)
|
||||
- **Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -220,7 +236,6 @@ RustFS is a community-driven project, and we appreciate all contributions. Check
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
@@ -230,4 +245,3 @@ RustFS is a community-driven project, and we appreciate all contributions. Check
|
||||
[Apache 2.0](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
**RustFS** is a trademark of RustFS, Inc. All other trademarks are the property of their respective owners.
|
||||
|
||||
|
||||
+57
-59
@@ -1,4 +1,4 @@
|
||||
[](https://rustfs.com)
|
||||
[](https://rustfs.com.cn)
|
||||
|
||||
<p align="center">RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。</p>
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
<a href="https://hellogithub.com/repository/rustfs/rustfs" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=b95bcb72bdc340b68f16fdf6790b7d5b&claim_uid=MsbvjYeLDKAH457&theme=small" alt="Featured|HelloGitHub" /></a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -21,8 +23,6 @@
|
||||
· <a href="https://github.com/rustfs/rustfs/discussions">社区讨论</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/rustfs/rustfs/blob/main/README.md">English</a> | 简体中文 |
|
||||
<a href="https://readme-i18n.com/rustfs/rustfs?lang=de">Deutsch</a> |
|
||||
@@ -47,44 +47,46 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
||||
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||
- **简单易用**:设计简洁,易于部署和管理。
|
||||
|
||||
|
||||
| 功能 | 状态 | 功能 | 状态 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
||||
|
||||
|
||||
|
||||
| 功能 | 状态 | 功能 | 状态 |
|
||||
| :----------------- | :------ | :---------------------- | :-------- |
|
||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
||||
|
||||
## RustFS vs MinIO 性能对比
|
||||
|
||||
**压力测试环境参数:**
|
||||
|
||||
| 类型 | 参数 | 备注 |
|
||||
|---------|-----------|----------------------------------------------------------|
|
||||
| CPU | 2 核 | Intel Xeon (Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz |
|
||||
| 内存 | 4GB | |
|
||||
| 网络 | 15Gbps | |
|
||||
| 硬盘 | 40GB x 4 | IOPS 3800 / Drive |
|
||||
| 类型 | 参数 | 备注 |
|
||||
| ---- | -------- | --------------------------------------------------------- |
|
||||
| CPU | 2 核 | Intel Xeon (Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz |
|
||||
| 内存 | 4GB | |
|
||||
| 网络 | 15Gbps | |
|
||||
| 硬盘 | 40GB x 4 | IOPS 3800 / Drive |
|
||||
|
||||
<https://github.com/user-attachments/assets/2e4979b5-260c-4f2c-ac12-c87fd558072a>
|
||||
|
||||
### RustFS vs 其他对象存储
|
||||
|
||||
| 特性 | RustFS | 其他对象存储 |
|
||||
| :--- | :--- | :--- |
|
||||
| **控制台体验** | **功能强大的控制台**<br>提供全面的管理界面。 | **基础/简陋的控制台**<br>通常功能过于简单或缺失关键特性。 |
|
||||
| **语言与安全** | **基于 Rust 开发**<br>天生的内存安全。 | **基于 Go 或 C 开发**<br>存在内存 GC 停顿或内存泄漏的潜在风险。 |
|
||||
| **数据主权** | **无遥测 / 完全合规**<br>防止未经授权的数据跨境传输。完全符合 GDPR (欧盟/英国)、CCPA (美国) 和 APPI (日本) 等法规。 | **潜在风险**<br>可能存在法律风险和隐蔽的数据遥测(Telemetry)。 |
|
||||
| **开源协议** | **宽松的 Apache 2.0**<br>商业友好,无“毒丸”条款。 | **受限的 AGPL v3**<br>存在许可证陷阱和知识产权污染的风险。 |
|
||||
| **兼容性** | **100% S3 兼容**<br>适用于任何云提供商和客户端,随处运行。 | **兼容性不一**<br>虽然支持 S3,但可能缺乏对本地云厂商或特定 API 的支持。 |
|
||||
| **边缘与 IoT** | **强大的边缘支持**<br>非常适合安全、创新的边缘设备。 | **边缘支持较弱**<br>对于边缘网关来说通常过于沉重。 |
|
||||
| **成本** | **稳定且免费**<br>免费社区支持,稳定的商业定价。 | **高昂成本**<br>1PiB 的成本可能高达 250,000 美元。 |
|
||||
| **风险控制** | **企业级风险规避**<br>清晰的知识产权,商业使用安全无忧。 | **法律风险**<br>知识产权归属模糊及使用限制风险。 |
|
||||
| 特性 | RustFS | 其他对象存储 |
|
||||
| :------------- | :------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------- |
|
||||
| **控制台体验** | **功能强大的控制台**<br>提供全面的管理界面。 | **基础/简陋的控制台**<br>通常功能过于简单或缺失关键特性。 |
|
||||
| **语言与安全** | **基于 Rust 开发**<br>天生的内存安全。 | **基于 Go 或 C 开发**<br>存在内存 GC 停顿或内存泄漏的潜在风险。 |
|
||||
| **数据主权** | **无遥测 / 完全合规**<br>防止未经授权的数据跨境传输。完全符合 GDPR (欧盟/英国)、CCPA (美国) 和 APPI (日本) 等法规。 | **潜在风险**<br>可能存在法律风险和隐蔽的数据遥测(Telemetry)。 |
|
||||
| **开源协议** | **宽松的 Apache 2.0**<br>商业友好,无“毒丸”条款。 | **受限的 AGPL v3**<br>存在许可证陷阱和知识产权污染的风险。 |
|
||||
| **兼容性** | **100% S3 兼容**<br>适用于任何云提供商和客户端,随处运行。 | **兼容性不一**<br>虽然支持 S3,但可能缺乏对本地云厂商或特定 API 的支持。 |
|
||||
| **边缘与 IoT** | **强大的边缘支持**<br>非常适合安全、创新的边缘设备。 | **边缘支持较弱**<br>对于边缘网关来说通常过于沉重。 |
|
||||
| **成本** | **稳定且免费**<br>免费社区支持,稳定的商业定价。 | **高昂成本**<br>1PiB 的成本可能高达 250,000 美元。 |
|
||||
| **风险控制** | **企业级风险规避**<br>清晰的知识产权,商业使用安全无忧。 | **法律风险**<br>知识产权归属模糊及使用限制风险。 |
|
||||
|
||||
## 保持领先
|
||||
|
||||
在 GitHub 上为 RustFS 点赞,即可第一时间收到新版本发布通知。
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/7ee40bb4-3e46-4eac-b0d0-5fbeb85ff8f3" />
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -92,9 +94,9 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
||||
|
||||
### 1. 一键安装脚本 (选项 1)
|
||||
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
````
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
```
|
||||
|
||||
### 2\. Docker 快速启动 (选项 2)
|
||||
|
||||
@@ -141,10 +143,10 @@ docker compose --profile observability up -d
|
||||
```
|
||||
|
||||
`docker-buildx.sh` 脚本支持:
|
||||
\- **多架构构建**: `linux/amd64`, `linux/arm64`
|
||||
\- **自动版本检测**: 使用 git tags 或 commit hash
|
||||
\- **灵活的仓库支持**: 支持 Docker Hub, GitHub Container Registry 等
|
||||
\- **构建优化**: 包含缓存和并行构建
|
||||
- **多架构构建**: `linux/amd64`, `linux/arm64`
|
||||
- **自动版本检测**: 使用 git tags 或 commit hash
|
||||
- **灵活的仓库支持**: 支持 Docker Hub, GitHub Container Registry 等
|
||||
- **构建优化**: 包含缓存和并行构建
|
||||
|
||||
为了方便起见,您也可以使用 Make 命令:
|
||||
|
||||
@@ -161,14 +163,14 @@ make help-docker # 显示所有 Docker 相关命令
|
||||
|
||||
请按照 [Helm Chart README](https://charts.rustfs.com) 上的说明在 Kubernetes 集群上安装 RustFS。
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 访问 RustFS
|
||||
|
||||
5. **访问控制台**: 打开浏览器并访问 `http://localhost:9000` 进入 RustFS 控制台。
|
||||
* 默认账号/密码: `rustfsadmin` / `rustfsadmin`
|
||||
6. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
||||
7. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
||||
1. **访问控制台**: 打开浏览器并访问 `http://localhost:9000` 进入 RustFS 控制台。
|
||||
- 默认账号/密码: `rustfsadmin` / `rustfsadmin`
|
||||
2. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
||||
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
||||
|
||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured.html)。
|
||||
|
||||
@@ -180,23 +182,23 @@ make help-docker # 显示所有 Docker 相关命令
|
||||
|
||||
如果您有任何问题或需要帮助:
|
||||
|
||||
- 查看 [FAQ](https://github.com/rustfs/rustfs/discussions/categories/q-a) 寻找常见问题和解决方案。
|
||||
- 加入我们的 [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) 提问并分享您的经验。
|
||||
- 在我们的 [GitHub Issues](https://github.com/rustfs/rustfs/issues) 页面提交 Bug 报告或功能请求。
|
||||
- 查看 [FAQ](https://github.com/rustfs/rustfs/discussions/categories/q-a) 寻找常见问题和解决方案。
|
||||
- 加入我们的 [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) 提问并分享您的经验。
|
||||
- 在我们的 [GitHub Issues](https://github.com/rustfs/rustfs/issues) 页面提交 Bug 报告或功能请求。
|
||||
|
||||
## 链接
|
||||
|
||||
- [官方文档](https://docs.rustfs.com) - 必读手册
|
||||
- [更新日志](https://github.com/rustfs/rustfs/releases) - 版本变更记录
|
||||
- [社区讨论](https://github.com/rustfs/rustfs/discussions) - 社区交流地
|
||||
- [官方文档](https://docs.rustfs.com) - 必读手册
|
||||
- [更新日志](https://github.com/rustfs/rustfs/releases) - 版本变更记录
|
||||
- [社区讨论](https://github.com/rustfs/rustfs/discussions) - 社区交流地
|
||||
|
||||
## 联系方式
|
||||
|
||||
- **Bug 反馈**: [GitHub Issues](https://github.com/rustfs/rustfs/issues)
|
||||
- **商务合作**: [hello@rustfs.com](mailto:hello@rustfs.com)
|
||||
- **工作机会**: [jobs@rustfs.com](mailto:jobs@rustfs.com)
|
||||
- **一般讨论**: [GitHub Discussions](https://github.com/rustfs/rustfs/discussions)
|
||||
- **贡献指南**: [CONTRIBUTING.md](https://www.google.com/search?q=CONTRIBUTING.md)
|
||||
- **Bug 反馈**: [GitHub Issues](https://github.com/rustfs/rustfs/issues)
|
||||
- **商务合作**: [hello@rustfs.com](mailto:hello@rustfs.com)
|
||||
- **工作机会**: [jobs@rustfs.com](mailto:jobs@rustfs.com)
|
||||
- **一般讨论**: [GitHub Discussions](https://github.com/rustfs/rustfs/discussions)
|
||||
- **贡献指南**: [CONTRIBUTING.md](https://www.google.com/search?q=CONTRIBUTING.md)
|
||||
|
||||
## 贡献者
|
||||
|
||||
@@ -206,16 +208,12 @@ RustFS 是一个社区驱动的项目,我们感谢所有的贡献。请查看
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
## Star 历史
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
**RustFS** 是 RustFS, Inc. 的商标。所有其他商标均为其各自所有者的财产。
|
||||
|
||||
|
||||
+27
-6
@@ -1,19 +1,40 @@
|
||||
# Security Policy
|
||||
|
||||
## Security Philosophy
|
||||
|
||||
At RustFS, we take security seriously. We believe that **transparency leads to better security**. The more open our code is, the more eyes are on it, and the faster we can identify and resolve potential issues.
|
||||
|
||||
We highly value the contributions of the security community and welcome anyone to audit our code. Your efforts help us make RustFS safer for everyone.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest released version of this project.
|
||||
To help us focus our security efforts, please refer to the table below to see which versions of RustFS are currently supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| Latest | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities **privately** via GitHub Security Advisories:
|
||||
If you discover a security vulnerability in RustFS, we appreciate your help in disclosing it to us responsibly.
|
||||
|
||||
https://github.com/rustfs/rustfs/security/advisories/new
|
||||
**Please do not open a public GitHub issue for security vulnerabilities.** Publicly disclosing a vulnerability can put the entire community at risk before a fix is available.
|
||||
|
||||
Do **not** open a public issue for security-sensitive bugs.
|
||||
### How to Report
|
||||
|
||||
You can expect an initial response within a reasonable timeframe. Further updates will be provided as the report is triaged.
|
||||
1. https://github.com/rustfs/rustfs/security/advisories/new
|
||||
2. Please email us directly at: **security@rustfs.com**
|
||||
|
||||
In your email, please include:
|
||||
1. **Description**: A detailed description of the vulnerability.
|
||||
2. **Steps to Reproduce**: Steps or a script to reproduce the issue.
|
||||
3. **Impact**: The potential impact of the vulnerability.
|
||||
|
||||
### Our Response Process
|
||||
|
||||
1. **Acknowledgment**: We will acknowledge your email within 48 hours.
|
||||
2. **Assessment**: We will investigate the issue and determine its severity.
|
||||
3. **Fix & Disclosure**: We will work on a patch. Once the patch is released, we will publicly announce the vulnerability and acknowledge your contribution (unless you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep RustFS and its users safe!
|
||||
|
||||
+6
-1
@@ -37,6 +37,11 @@ datas = "datas"
|
||||
bre = "bre"
|
||||
abd = "abd"
|
||||
mak = "mak"
|
||||
gae = "gae"
|
||||
GAE = "GAE"
|
||||
# s3-tests original test names (cannot be changed)
|
||||
nonexisted = "nonexisted"
|
||||
consts = "consts"
|
||||
|
||||
[files]
|
||||
extend-exclude = []
|
||||
extend-exclude = []
|
||||
|
||||
+2
-1
@@ -31,7 +31,7 @@ detect_platform() {
|
||||
echo "armv7-unknown-linux-gnueabihf"
|
||||
;;
|
||||
"loongarch64")
|
||||
echo "loongarch64-unknown-linux-musl"
|
||||
echo "loongarch64-unknown-linux-gnu"
|
||||
;;
|
||||
*)
|
||||
echo "unknown-platform"
|
||||
@@ -126,6 +126,7 @@ usage() {
|
||||
echo " Supported platforms:"
|
||||
echo " x86_64-unknown-linux-gnu"
|
||||
echo " aarch64-unknown-linux-gnu"
|
||||
echo " loongarch64-unknown-linux-gnu"
|
||||
echo " armv7-unknown-linux-gnueabihf"
|
||||
echo " x86_64-unknown-linux-musl"
|
||||
echo " aarch64-unknown-linux-musl"
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::scanner::node_scanner::ScanProgress;
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CheckpointData {
|
||||
pub version: u32,
|
||||
pub timestamp: SystemTime,
|
||||
pub progress: ScanProgress,
|
||||
pub node_id: String,
|
||||
pub checksum: u64,
|
||||
}
|
||||
|
||||
impl CheckpointData {
|
||||
pub fn new(progress: ScanProgress, node_id: String) -> Self {
|
||||
let mut checkpoint = Self {
|
||||
version: 1,
|
||||
timestamp: SystemTime::now(),
|
||||
progress,
|
||||
node_id,
|
||||
checksum: 0,
|
||||
};
|
||||
|
||||
checkpoint.checksum = checkpoint.calculate_checksum();
|
||||
checkpoint
|
||||
}
|
||||
|
||||
fn calculate_checksum(&self) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.version.hash(&mut hasher);
|
||||
self.node_id.hash(&mut hasher);
|
||||
self.progress.current_cycle.hash(&mut hasher);
|
||||
self.progress.current_disk_index.hash(&mut hasher);
|
||||
|
||||
if let Some(ref bucket) = self.progress.current_bucket {
|
||||
bucket.hash(&mut hasher);
|
||||
}
|
||||
|
||||
if let Some(ref key) = self.progress.last_scan_key {
|
||||
key.hash(&mut hasher);
|
||||
}
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn verify_integrity(&self) -> bool {
|
||||
let calculated_checksum = self.calculate_checksum();
|
||||
self.checksum == calculated_checksum
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckpointManager {
|
||||
checkpoint_file: PathBuf,
|
||||
backup_file: PathBuf,
|
||||
temp_file: PathBuf,
|
||||
save_interval: Duration,
|
||||
last_save: RwLock<SystemTime>,
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
impl CheckpointManager {
|
||||
pub fn new(node_id: &str, data_dir: &Path) -> Self {
|
||||
if !data_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
error!("create data dir failed {:?}: {}", data_dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
let checkpoint_file = data_dir.join(format!("scanner_checkpoint_{node_id}.json"));
|
||||
let backup_file = data_dir.join(format!("scanner_checkpoint_{node_id}.backup"));
|
||||
let temp_file = data_dir.join(format!("scanner_checkpoint_{node_id}.tmp"));
|
||||
|
||||
Self {
|
||||
checkpoint_file,
|
||||
backup_file,
|
||||
temp_file,
|
||||
save_interval: Duration::from_secs(30), // 30s
|
||||
last_save: RwLock::new(SystemTime::UNIX_EPOCH),
|
||||
node_id: node_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_checkpoint(&self, progress: &ScanProgress) -> Result<()> {
|
||||
let now = SystemTime::now();
|
||||
let last_save = *self.last_save.read().await;
|
||||
|
||||
if now.duration_since(last_save).unwrap_or(Duration::ZERO) < self.save_interval {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let checkpoint_data = CheckpointData::new(progress.clone(), self.node_id.clone());
|
||||
|
||||
let json_data = serde_json::to_string_pretty(&checkpoint_data)
|
||||
.map_err(|e| Error::Serialization(format!("serialize checkpoint failed: {e}")))?;
|
||||
|
||||
tokio::fs::write(&self.temp_file, json_data)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("write temp checkpoint file failed: {e}")))?;
|
||||
|
||||
if self.checkpoint_file.exists() {
|
||||
tokio::fs::copy(&self.checkpoint_file, &self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("backup checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&self.temp_file, &self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("replace checkpoint file failed: {e}")))?;
|
||||
|
||||
*self.last_save.write().await = now;
|
||||
|
||||
debug!(
|
||||
"save checkpoint to {:?}, cycle: {}, disk index: {}",
|
||||
self.checkpoint_file, checkpoint_data.progress.current_cycle, checkpoint_data.progress.current_disk_index
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_checkpoint(&self) -> Result<Option<ScanProgress>> {
|
||||
// first try main checkpoint file
|
||||
match self.load_checkpoint_from_file(&self.checkpoint_file).await {
|
||||
Ok(checkpoint) => {
|
||||
info!(
|
||||
"restore scan progress from main checkpoint file: cycle={}, disk index={}, last scan key={:?}",
|
||||
checkpoint.current_cycle, checkpoint.current_disk_index, checkpoint.last_scan_key
|
||||
);
|
||||
Ok(Some(checkpoint))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("main checkpoint file is corrupted or not exists: {}", e);
|
||||
|
||||
// try backup file
|
||||
match self.load_checkpoint_from_file(&self.backup_file).await {
|
||||
Ok(checkpoint) => {
|
||||
warn!(
|
||||
"restore scan progress from backup file: cycle={}, disk index={}",
|
||||
checkpoint.current_cycle, checkpoint.current_disk_index
|
||||
);
|
||||
|
||||
// copy backup file to main checkpoint file
|
||||
if let Err(copy_err) = tokio::fs::copy(&self.backup_file, &self.checkpoint_file).await {
|
||||
warn!("restore main checkpoint file failed: {}", copy_err);
|
||||
}
|
||||
|
||||
Ok(Some(checkpoint))
|
||||
}
|
||||
Err(backup_e) => {
|
||||
warn!("backup file is corrupted or not exists: {}", backup_e);
|
||||
info!("cannot restore scan progress, will start fresh scan");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load checkpoint from file
|
||||
async fn load_checkpoint_from_file(&self, file_path: &Path) -> Result<ScanProgress> {
|
||||
if !file_path.exists() {
|
||||
return Err(Error::NotFound(format!("checkpoint file not exists: {file_path:?}")));
|
||||
}
|
||||
|
||||
// read file content
|
||||
let content = tokio::fs::read_to_string(file_path)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read checkpoint file failed: {e}")))?;
|
||||
|
||||
// deserialize
|
||||
let checkpoint_data: CheckpointData =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize checkpoint failed: {e}")))?;
|
||||
|
||||
// validate checkpoint data
|
||||
self.validate_checkpoint(&checkpoint_data)?;
|
||||
|
||||
Ok(checkpoint_data.progress)
|
||||
}
|
||||
|
||||
/// validate checkpoint data
|
||||
fn validate_checkpoint(&self, checkpoint: &CheckpointData) -> Result<()> {
|
||||
// validate data integrity
|
||||
if !checkpoint.verify_integrity() {
|
||||
return Err(Error::InvalidCheckpoint(
|
||||
"checkpoint data verification failed, may be corrupted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// validate node id match
|
||||
if checkpoint.node_id != self.node_id {
|
||||
return Err(Error::InvalidCheckpoint(format!(
|
||||
"checkpoint node id not match: expected {}, actual {}",
|
||||
self.node_id, checkpoint.node_id
|
||||
)));
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
let checkpoint_age = now.duration_since(checkpoint.timestamp).unwrap_or(Duration::MAX);
|
||||
|
||||
// checkpoint is too old (more than 24 hours), may be data expired
|
||||
if checkpoint_age > Duration::from_secs(24 * 3600) {
|
||||
return Err(Error::InvalidCheckpoint(format!("checkpoint data is too old: {checkpoint_age:?}")));
|
||||
}
|
||||
|
||||
// validate version compatibility
|
||||
if checkpoint.version > 1 {
|
||||
return Err(Error::InvalidCheckpoint(format!(
|
||||
"unsupported checkpoint version: {}",
|
||||
checkpoint.version
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// clean checkpoint file
|
||||
///
|
||||
/// called when scanner stops or resets
|
||||
pub async fn cleanup_checkpoint(&self) -> Result<()> {
|
||||
// delete main file
|
||||
if self.checkpoint_file.exists() {
|
||||
tokio::fs::remove_file(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete main checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete backup file
|
||||
if self.backup_file.exists() {
|
||||
tokio::fs::remove_file(&self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete backup checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete temp file
|
||||
if self.temp_file.exists() {
|
||||
tokio::fs::remove_file(&self.temp_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete temp checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
info!("cleaned up all checkpoint files");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get checkpoint file info
|
||||
pub async fn get_checkpoint_info(&self) -> Result<Option<CheckpointInfo>> {
|
||||
if !self.checkpoint_file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata = tokio::fs::metadata(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("get checkpoint file metadata failed: {e}")))?;
|
||||
|
||||
let content = tokio::fs::read_to_string(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read checkpoint file failed: {e}")))?;
|
||||
|
||||
let checkpoint_data: CheckpointData =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize checkpoint failed: {e}")))?;
|
||||
|
||||
Ok(Some(CheckpointInfo {
|
||||
file_size: metadata.len(),
|
||||
last_modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
checkpoint_timestamp: checkpoint_data.timestamp,
|
||||
current_cycle: checkpoint_data.progress.current_cycle,
|
||||
current_disk_index: checkpoint_data.progress.current_disk_index,
|
||||
completed_disks_count: checkpoint_data.progress.completed_disks.len(),
|
||||
is_valid: checkpoint_data.verify_integrity(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// force save checkpoint (ignore time interval limit)
|
||||
pub async fn force_save_checkpoint(&self, progress: &ScanProgress) -> Result<()> {
|
||||
// temporarily reset last save time, force save
|
||||
*self.last_save.write().await = SystemTime::UNIX_EPOCH;
|
||||
self.save_checkpoint(progress).await
|
||||
}
|
||||
|
||||
/// set save interval
|
||||
pub async fn set_save_interval(&mut self, interval: Duration) {
|
||||
self.save_interval = interval;
|
||||
info!("checkpoint save interval set to: {:?}", interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// checkpoint info
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckpointInfo {
|
||||
/// file size
|
||||
pub file_size: u64,
|
||||
/// file last modified time
|
||||
pub last_modified: SystemTime,
|
||||
/// checkpoint creation time
|
||||
pub checkpoint_timestamp: SystemTime,
|
||||
/// current scan cycle
|
||||
pub current_cycle: u64,
|
||||
/// current disk index
|
||||
pub current_disk_index: usize,
|
||||
/// completed disks count
|
||||
pub completed_disks_count: usize,
|
||||
/// checkpoint is valid
|
||||
pub is_valid: bool,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
/// Scanner metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScannerMetrics {
|
||||
/// Total objects scanned since server start
|
||||
pub objects_scanned: u64,
|
||||
/// Total object versions scanned since server start
|
||||
pub versions_scanned: u64,
|
||||
/// Total directories scanned since server start
|
||||
pub directories_scanned: u64,
|
||||
/// Total bucket scans started since server start
|
||||
pub bucket_scans_started: u64,
|
||||
/// Total bucket scans finished since server start
|
||||
pub bucket_scans_finished: u64,
|
||||
/// Total objects with health issues found
|
||||
pub objects_with_issues: u64,
|
||||
/// Total heal tasks queued
|
||||
pub heal_tasks_queued: u64,
|
||||
/// Total heal tasks completed
|
||||
pub heal_tasks_completed: u64,
|
||||
/// Total heal tasks failed
|
||||
pub heal_tasks_failed: u64,
|
||||
/// Total healthy objects found
|
||||
pub healthy_objects: u64,
|
||||
/// Total corrupted objects found
|
||||
pub corrupted_objects: u64,
|
||||
/// Last scan activity time
|
||||
pub last_activity: Option<SystemTime>,
|
||||
/// Current scan cycle
|
||||
pub current_cycle: u64,
|
||||
/// Total scan cycles completed
|
||||
pub total_cycles: u64,
|
||||
/// Current scan duration
|
||||
pub current_scan_duration: Option<Duration>,
|
||||
/// Average scan duration
|
||||
pub avg_scan_duration: Duration,
|
||||
/// Objects scanned per second
|
||||
pub objects_per_second: f64,
|
||||
/// Buckets scanned per second
|
||||
pub buckets_per_second: f64,
|
||||
/// Storage metrics by bucket
|
||||
pub bucket_metrics: HashMap<String, BucketMetrics>,
|
||||
/// Disk metrics
|
||||
pub disk_metrics: HashMap<String, DiskMetrics>,
|
||||
}
|
||||
|
||||
/// Bucket-specific metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BucketMetrics {
|
||||
/// Bucket name
|
||||
pub bucket: String,
|
||||
/// Total objects in bucket
|
||||
pub total_objects: u64,
|
||||
/// Total size of objects in bucket (bytes)
|
||||
pub total_size: u64,
|
||||
/// Objects with health issues
|
||||
pub objects_with_issues: u64,
|
||||
/// Last scan time
|
||||
pub last_scan_time: Option<SystemTime>,
|
||||
/// Scan duration
|
||||
pub scan_duration: Option<Duration>,
|
||||
/// Heal tasks queued for this bucket
|
||||
pub heal_tasks_queued: u64,
|
||||
/// Heal tasks completed for this bucket
|
||||
pub heal_tasks_completed: u64,
|
||||
/// Heal tasks failed for this bucket
|
||||
pub heal_tasks_failed: u64,
|
||||
}
|
||||
|
||||
/// Disk-specific metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DiskMetrics {
|
||||
/// Disk path
|
||||
pub disk_path: String,
|
||||
/// Total disk space (bytes)
|
||||
pub total_space: u64,
|
||||
/// Used disk space (bytes)
|
||||
pub used_space: u64,
|
||||
/// Free disk space (bytes)
|
||||
pub free_space: u64,
|
||||
/// Objects scanned on this disk
|
||||
pub objects_scanned: u64,
|
||||
/// Objects with issues on this disk
|
||||
pub objects_with_issues: u64,
|
||||
/// Last scan time
|
||||
pub last_scan_time: Option<SystemTime>,
|
||||
/// Whether disk is online
|
||||
pub is_online: bool,
|
||||
/// Whether disk is being scanned
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
/// Thread-safe metrics collector
|
||||
pub struct MetricsCollector {
|
||||
/// Atomic counters for real-time metrics
|
||||
objects_scanned: AtomicU64,
|
||||
versions_scanned: AtomicU64,
|
||||
directories_scanned: AtomicU64,
|
||||
bucket_scans_started: AtomicU64,
|
||||
bucket_scans_finished: AtomicU64,
|
||||
objects_with_issues: AtomicU64,
|
||||
heal_tasks_queued: AtomicU64,
|
||||
heal_tasks_completed: AtomicU64,
|
||||
heal_tasks_failed: AtomicU64,
|
||||
current_cycle: AtomicU64,
|
||||
total_cycles: AtomicU64,
|
||||
healthy_objects: AtomicU64,
|
||||
corrupted_objects: AtomicU64,
|
||||
}
|
||||
|
||||
impl MetricsCollector {
|
||||
/// Create a new metrics collector
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
objects_scanned: AtomicU64::new(0),
|
||||
versions_scanned: AtomicU64::new(0),
|
||||
directories_scanned: AtomicU64::new(0),
|
||||
bucket_scans_started: AtomicU64::new(0),
|
||||
bucket_scans_finished: AtomicU64::new(0),
|
||||
objects_with_issues: AtomicU64::new(0),
|
||||
heal_tasks_queued: AtomicU64::new(0),
|
||||
heal_tasks_completed: AtomicU64::new(0),
|
||||
heal_tasks_failed: AtomicU64::new(0),
|
||||
current_cycle: AtomicU64::new(0),
|
||||
total_cycles: AtomicU64::new(0),
|
||||
healthy_objects: AtomicU64::new(0),
|
||||
corrupted_objects: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment objects scanned count
|
||||
pub fn increment_objects_scanned(&self, count: u64) {
|
||||
self.objects_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment versions scanned count
|
||||
pub fn increment_versions_scanned(&self, count: u64) {
|
||||
self.versions_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment directories scanned count
|
||||
pub fn increment_directories_scanned(&self, count: u64) {
|
||||
self.directories_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment bucket scans started count
|
||||
pub fn increment_bucket_scans_started(&self, count: u64) {
|
||||
self.bucket_scans_started.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment bucket scans finished count
|
||||
pub fn increment_bucket_scans_finished(&self, count: u64) {
|
||||
self.bucket_scans_finished.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment objects with issues count
|
||||
pub fn increment_objects_with_issues(&self, count: u64) {
|
||||
self.objects_with_issues.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks queued count
|
||||
pub fn increment_heal_tasks_queued(&self, count: u64) {
|
||||
self.heal_tasks_queued.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks completed count
|
||||
pub fn increment_heal_tasks_completed(&self, count: u64) {
|
||||
self.heal_tasks_completed.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks failed count
|
||||
pub fn increment_heal_tasks_failed(&self, count: u64) {
|
||||
self.heal_tasks_failed.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set current cycle
|
||||
pub fn set_current_cycle(&self, cycle: u64) {
|
||||
self.current_cycle.store(cycle, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment total cycles
|
||||
pub fn increment_total_cycles(&self) {
|
||||
self.total_cycles.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment healthy objects count
|
||||
pub fn increment_healthy_objects(&self) {
|
||||
self.healthy_objects.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment corrupted objects count
|
||||
pub fn increment_corrupted_objects(&self) {
|
||||
self.corrupted_objects.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get current metrics snapshot
|
||||
pub fn get_metrics(&self) -> ScannerMetrics {
|
||||
ScannerMetrics {
|
||||
objects_scanned: self.objects_scanned.load(Ordering::Relaxed),
|
||||
versions_scanned: self.versions_scanned.load(Ordering::Relaxed),
|
||||
directories_scanned: self.directories_scanned.load(Ordering::Relaxed),
|
||||
bucket_scans_started: self.bucket_scans_started.load(Ordering::Relaxed),
|
||||
bucket_scans_finished: self.bucket_scans_finished.load(Ordering::Relaxed),
|
||||
objects_with_issues: self.objects_with_issues.load(Ordering::Relaxed),
|
||||
heal_tasks_queued: self.heal_tasks_queued.load(Ordering::Relaxed),
|
||||
heal_tasks_completed: self.heal_tasks_completed.load(Ordering::Relaxed),
|
||||
heal_tasks_failed: self.heal_tasks_failed.load(Ordering::Relaxed),
|
||||
healthy_objects: self.healthy_objects.load(Ordering::Relaxed),
|
||||
corrupted_objects: self.corrupted_objects.load(Ordering::Relaxed),
|
||||
last_activity: Some(SystemTime::now()),
|
||||
current_cycle: self.current_cycle.load(Ordering::Relaxed),
|
||||
total_cycles: self.total_cycles.load(Ordering::Relaxed),
|
||||
current_scan_duration: None, // Will be set by scanner
|
||||
avg_scan_duration: Duration::ZERO, // Will be calculated
|
||||
objects_per_second: 0.0, // Will be calculated
|
||||
buckets_per_second: 0.0, // Will be calculated
|
||||
bucket_metrics: HashMap::new(), // Will be populated by scanner
|
||||
disk_metrics: HashMap::new(), // Will be populated by scanner
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all metrics
|
||||
pub fn reset(&self) {
|
||||
self.objects_scanned.store(0, Ordering::Relaxed);
|
||||
self.versions_scanned.store(0, Ordering::Relaxed);
|
||||
self.directories_scanned.store(0, Ordering::Relaxed);
|
||||
self.bucket_scans_started.store(0, Ordering::Relaxed);
|
||||
self.bucket_scans_finished.store(0, Ordering::Relaxed);
|
||||
self.objects_with_issues.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_queued.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_completed.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_failed.store(0, Ordering::Relaxed);
|
||||
self.current_cycle.store(0, Ordering::Relaxed);
|
||||
self.total_cycles.store(0, Ordering::Relaxed);
|
||||
self.healthy_objects.store(0, Ordering::Relaxed);
|
||||
self.corrupted_objects.store(0, Ordering::Relaxed);
|
||||
|
||||
info!("Scanner metrics reset");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetricsCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_metrics_collector_creation() {
|
||||
let collector = MetricsCollector::new();
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 0);
|
||||
assert_eq!(metrics.versions_scanned, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_increment() {
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
collector.increment_objects_scanned(10);
|
||||
collector.increment_versions_scanned(5);
|
||||
collector.increment_objects_with_issues(2);
|
||||
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 10);
|
||||
assert_eq!(metrics.versions_scanned, 5);
|
||||
assert_eq!(metrics.objects_with_issues, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_reset() {
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
collector.increment_objects_scanned(10);
|
||||
collector.reset();
|
||||
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::Result;
|
||||
use crate::scanner::LoadLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// IO monitor config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IOMonitorConfig {
|
||||
/// monitor interval
|
||||
pub monitor_interval: Duration,
|
||||
/// history data retention time
|
||||
pub history_retention: Duration,
|
||||
/// load evaluation window size
|
||||
pub load_window_size: usize,
|
||||
/// whether to enable actual system monitoring
|
||||
pub enable_system_monitoring: bool,
|
||||
/// disk path list (for monitoring specific disks)
|
||||
pub disk_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for IOMonitorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitor_interval: Duration::from_secs(1), // 1 second monitor interval
|
||||
history_retention: Duration::from_secs(300), // keep 5 minutes history
|
||||
load_window_size: 30, // 30 sample points sliding window
|
||||
enable_system_monitoring: false, // default use simulated data
|
||||
disk_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IO monitor metrics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IOMetrics {
|
||||
/// timestamp
|
||||
pub timestamp: SystemTime,
|
||||
/// disk IOPS (read + write)
|
||||
pub iops: u64,
|
||||
/// read IOPS
|
||||
pub read_iops: u64,
|
||||
/// write IOPS
|
||||
pub write_iops: u64,
|
||||
/// disk queue depth
|
||||
pub queue_depth: u64,
|
||||
/// average latency (milliseconds)
|
||||
pub avg_latency: u64,
|
||||
/// read latency (milliseconds)
|
||||
pub read_latency: u64,
|
||||
/// write latency (milliseconds)
|
||||
pub write_latency: u64,
|
||||
/// CPU usage (0-100)
|
||||
pub cpu_usage: u8,
|
||||
/// memory usage (0-100)
|
||||
pub memory_usage: u8,
|
||||
/// disk usage (0-100)
|
||||
pub disk_utilization: u8,
|
||||
/// network IO (Mbps)
|
||||
pub network_io: u64,
|
||||
}
|
||||
|
||||
impl Default for IOMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timestamp: SystemTime::now(),
|
||||
iops: 0,
|
||||
read_iops: 0,
|
||||
write_iops: 0,
|
||||
queue_depth: 0,
|
||||
avg_latency: 0,
|
||||
read_latency: 0,
|
||||
write_latency: 0,
|
||||
cpu_usage: 0,
|
||||
memory_usage: 0,
|
||||
disk_utilization: 0,
|
||||
network_io: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load level stats
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoadLevelStats {
|
||||
/// low load duration (seconds)
|
||||
pub low_load_duration: u64,
|
||||
/// medium load duration (seconds)
|
||||
pub medium_load_duration: u64,
|
||||
/// high load duration (seconds)
|
||||
pub high_load_duration: u64,
|
||||
/// critical load duration (seconds)
|
||||
pub critical_load_duration: u64,
|
||||
/// load transitions
|
||||
pub load_transitions: u64,
|
||||
}
|
||||
|
||||
/// advanced IO monitor
|
||||
pub struct AdvancedIOMonitor {
|
||||
/// config
|
||||
config: Arc<RwLock<IOMonitorConfig>>,
|
||||
/// current metrics
|
||||
current_metrics: Arc<RwLock<IOMetrics>>,
|
||||
/// history metrics (sliding window)
|
||||
history_metrics: Arc<RwLock<VecDeque<IOMetrics>>>,
|
||||
/// current load level
|
||||
current_load_level: Arc<RwLock<LoadLevel>>,
|
||||
/// load level history
|
||||
load_level_history: Arc<RwLock<VecDeque<(SystemTime, LoadLevel)>>>,
|
||||
/// load level stats
|
||||
load_stats: Arc<RwLock<LoadLevelStats>>,
|
||||
/// business IO metrics (updated by external)
|
||||
business_metrics: Arc<BusinessIOMetrics>,
|
||||
/// cancel token
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
/// business IO metrics
|
||||
pub struct BusinessIOMetrics {
|
||||
/// business request latency (milliseconds)
|
||||
pub request_latency: AtomicU64,
|
||||
/// business request QPS
|
||||
pub request_qps: AtomicU64,
|
||||
/// business error rate (0-10000, 0.00%-100.00%)
|
||||
pub error_rate: AtomicU64,
|
||||
/// active connections
|
||||
pub active_connections: AtomicU64,
|
||||
/// last update time
|
||||
pub last_update: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
impl Default for BusinessIOMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request_latency: AtomicU64::new(0),
|
||||
request_qps: AtomicU64::new(0),
|
||||
error_rate: AtomicU64::new(0),
|
||||
active_connections: AtomicU64::new(0),
|
||||
last_update: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdvancedIOMonitor {
|
||||
/// create new advanced IO monitor
|
||||
pub fn new(config: IOMonitorConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
current_metrics: Arc::new(RwLock::new(IOMetrics::default())),
|
||||
history_metrics: Arc::new(RwLock::new(VecDeque::new())),
|
||||
current_load_level: Arc::new(RwLock::new(LoadLevel::Low)),
|
||||
load_level_history: Arc::new(RwLock::new(VecDeque::new())),
|
||||
load_stats: Arc::new(RwLock::new(LoadLevelStats::default())),
|
||||
business_metrics: Arc::new(BusinessIOMetrics::default()),
|
||||
cancel_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// start monitoring
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
info!("start advanced IO monitor");
|
||||
|
||||
let monitor = self.clone_for_background();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = monitor.monitoring_loop().await {
|
||||
error!("IO monitoring loop failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// stop monitoring
|
||||
pub async fn stop(&self) {
|
||||
info!("stop IO monitor");
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
|
||||
/// monitoring loop
|
||||
async fn monitoring_loop(&self) -> Result<()> {
|
||||
let mut interval = {
|
||||
let config = self.config.read().await;
|
||||
tokio::time::interval(config.monitor_interval)
|
||||
};
|
||||
|
||||
let mut last_load_level = LoadLevel::Low;
|
||||
let mut load_level_start_time = SystemTime::now();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
info!("IO monitoring loop cancelled");
|
||||
break;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
// collect system metrics
|
||||
let metrics = self.collect_system_metrics().await;
|
||||
|
||||
// update current metrics
|
||||
*self.current_metrics.write().await = metrics.clone();
|
||||
|
||||
// update history metrics
|
||||
self.update_metrics_history(metrics.clone()).await;
|
||||
|
||||
// calculate load level
|
||||
let new_load_level = self.calculate_load_level(&metrics).await;
|
||||
|
||||
// check if load level changed
|
||||
if new_load_level != last_load_level {
|
||||
self.handle_load_level_change(last_load_level, new_load_level, load_level_start_time).await;
|
||||
last_load_level = new_load_level;
|
||||
load_level_start_time = SystemTime::now();
|
||||
}
|
||||
|
||||
// update current load level
|
||||
*self.current_load_level.write().await = new_load_level;
|
||||
|
||||
debug!("IO monitor updated: IOPS={}, queue depth={}, latency={}ms, load level={:?}",
|
||||
metrics.iops, metrics.queue_depth, metrics.avg_latency, new_load_level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// collect system metrics
|
||||
async fn collect_system_metrics(&self) -> IOMetrics {
|
||||
let config = self.config.read().await;
|
||||
|
||||
if config.enable_system_monitoring {
|
||||
// actual system monitoring implementation
|
||||
self.collect_real_system_metrics().await
|
||||
} else {
|
||||
// simulated data
|
||||
self.generate_simulated_metrics().await
|
||||
}
|
||||
}
|
||||
|
||||
/// collect real system metrics (need to be implemented according to specific system)
|
||||
async fn collect_real_system_metrics(&self) -> IOMetrics {
|
||||
// TODO: implement actual system metrics collection
|
||||
// can use procfs, sysfs or other system API
|
||||
|
||||
let metrics = IOMetrics {
|
||||
timestamp: SystemTime::now(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// example: read /proc/diskstats
|
||||
if let Ok(diskstats) = tokio::fs::read_to_string("/proc/diskstats").await {
|
||||
// parse disk stats info
|
||||
// here need to implement specific parsing logic
|
||||
debug!("read disk stats info: {} bytes", diskstats.len());
|
||||
}
|
||||
|
||||
// example: read /proc/stat to get CPU info
|
||||
if let Ok(stat) = tokio::fs::read_to_string("/proc/stat").await {
|
||||
// parse CPU stats info
|
||||
debug!("read CPU stats info: {} bytes", stat.len());
|
||||
}
|
||||
|
||||
// example: read /proc/meminfo to get memory info
|
||||
if let Ok(meminfo) = tokio::fs::read_to_string("/proc/meminfo").await {
|
||||
// parse memory stats info
|
||||
debug!("read memory stats info: {} bytes", meminfo.len());
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// generate simulated metrics (for testing and development)
|
||||
async fn generate_simulated_metrics(&self) -> IOMetrics {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
|
||||
// get business metrics impact
|
||||
let business_latency = self.business_metrics.request_latency.load(Ordering::Relaxed);
|
||||
let business_qps = self.business_metrics.request_qps.load(Ordering::Relaxed);
|
||||
|
||||
// generate simulated system metrics based on business load
|
||||
let base_iops = 100 + (business_qps / 10);
|
||||
let base_latency = 5 + (business_latency / 10);
|
||||
|
||||
IOMetrics {
|
||||
timestamp: SystemTime::now(),
|
||||
iops: base_iops + rng.random_range(0..50),
|
||||
read_iops: (base_iops * 6 / 10) + rng.random_range(0..20),
|
||||
write_iops: (base_iops * 4 / 10) + rng.random_range(0..20),
|
||||
queue_depth: rng.random_range(1..20),
|
||||
avg_latency: base_latency + rng.random_range(0..10),
|
||||
read_latency: base_latency + rng.random_range(0..5),
|
||||
write_latency: base_latency + rng.random_range(0..15),
|
||||
cpu_usage: rng.random_range(10..70),
|
||||
memory_usage: rng.random_range(30..80),
|
||||
disk_utilization: rng.random_range(20..90),
|
||||
network_io: rng.random_range(10..1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// update metrics history
|
||||
async fn update_metrics_history(&self, metrics: IOMetrics) {
|
||||
let mut history = self.history_metrics.write().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
// add new metrics
|
||||
history.push_back(metrics);
|
||||
|
||||
// clean expired data
|
||||
let retention_cutoff = SystemTime::now() - config.history_retention;
|
||||
while let Some(front) = history.front() {
|
||||
if front.timestamp < retention_cutoff {
|
||||
history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// limit window size
|
||||
while history.len() > config.load_window_size {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// calculate load level
|
||||
async fn calculate_load_level(&self, metrics: &IOMetrics) -> LoadLevel {
|
||||
// multi-dimensional load evaluation algorithm
|
||||
let mut load_score = 0u32;
|
||||
|
||||
// IOPS load evaluation (weight: 25%)
|
||||
let iops_score = match metrics.iops {
|
||||
0..=200 => 0,
|
||||
201..=500 => 15,
|
||||
501..=1000 => 25,
|
||||
_ => 35,
|
||||
};
|
||||
load_score += iops_score;
|
||||
|
||||
// latency load evaluation (weight: 30%)
|
||||
let latency_score = match metrics.avg_latency {
|
||||
0..=10 => 0,
|
||||
11..=50 => 20,
|
||||
51..=100 => 30,
|
||||
_ => 40,
|
||||
};
|
||||
load_score += latency_score;
|
||||
|
||||
// queue depth evaluation (weight: 20%)
|
||||
let queue_score = match metrics.queue_depth {
|
||||
0..=5 => 0,
|
||||
6..=15 => 10,
|
||||
16..=30 => 20,
|
||||
_ => 25,
|
||||
};
|
||||
load_score += queue_score;
|
||||
|
||||
// CPU usage evaluation (weight: 15%)
|
||||
let cpu_score = match metrics.cpu_usage {
|
||||
0..=30 => 0,
|
||||
31..=60 => 8,
|
||||
61..=80 => 12,
|
||||
_ => 15,
|
||||
};
|
||||
load_score += cpu_score;
|
||||
|
||||
// disk usage evaluation (weight: 10%)
|
||||
let disk_score = match metrics.disk_utilization {
|
||||
0..=50 => 0,
|
||||
51..=75 => 5,
|
||||
76..=90 => 8,
|
||||
_ => 10,
|
||||
};
|
||||
load_score += disk_score;
|
||||
|
||||
// business metrics impact
|
||||
let business_latency = self.business_metrics.request_latency.load(Ordering::Relaxed);
|
||||
let business_error_rate = self.business_metrics.error_rate.load(Ordering::Relaxed);
|
||||
|
||||
if business_latency > 100 {
|
||||
load_score += 20; // business latency too high
|
||||
}
|
||||
if business_error_rate > 100 {
|
||||
// > 1%
|
||||
load_score += 15; // business error rate too high
|
||||
}
|
||||
|
||||
// history trend analysis
|
||||
let trend_score = self.calculate_trend_score().await;
|
||||
load_score += trend_score;
|
||||
|
||||
// determine load level based on total score
|
||||
match load_score {
|
||||
0..=30 => LoadLevel::Low,
|
||||
31..=60 => LoadLevel::Medium,
|
||||
61..=90 => LoadLevel::High,
|
||||
_ => LoadLevel::Critical,
|
||||
}
|
||||
}
|
||||
|
||||
/// calculate trend score
|
||||
async fn calculate_trend_score(&self) -> u32 {
|
||||
let history = self.history_metrics.read().await;
|
||||
|
||||
if history.len() < 5 {
|
||||
return 0; // data insufficient, cannot analyze trend
|
||||
}
|
||||
|
||||
// analyze trend of last 5 samples
|
||||
let recent: Vec<_> = history.iter().rev().take(5).collect();
|
||||
|
||||
// check IOPS rising trend
|
||||
let mut iops_trend = 0;
|
||||
for i in 1..recent.len() {
|
||||
if recent[i - 1].iops > recent[i].iops {
|
||||
iops_trend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// check latency rising trend
|
||||
let mut latency_trend = 0;
|
||||
for i in 1..recent.len() {
|
||||
if recent[i - 1].avg_latency > recent[i].avg_latency {
|
||||
latency_trend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// if IOPS and latency are both rising, increase load score
|
||||
if iops_trend >= 3 && latency_trend >= 3 {
|
||||
15 // obvious rising trend
|
||||
} else if iops_trend >= 2 || latency_trend >= 2 {
|
||||
5 // slight rising trend
|
||||
} else {
|
||||
0 // no obvious trend
|
||||
}
|
||||
}
|
||||
|
||||
/// handle load level change
|
||||
async fn handle_load_level_change(&self, old_level: LoadLevel, new_level: LoadLevel, start_time: SystemTime) {
|
||||
let duration = SystemTime::now().duration_since(start_time).unwrap_or(Duration::ZERO);
|
||||
|
||||
// update stats
|
||||
{
|
||||
let mut stats = self.load_stats.write().await;
|
||||
match old_level {
|
||||
LoadLevel::Low => stats.low_load_duration += duration.as_secs(),
|
||||
LoadLevel::Medium => stats.medium_load_duration += duration.as_secs(),
|
||||
LoadLevel::High => stats.high_load_duration += duration.as_secs(),
|
||||
LoadLevel::Critical => stats.critical_load_duration += duration.as_secs(),
|
||||
}
|
||||
stats.load_transitions += 1;
|
||||
}
|
||||
|
||||
// update history
|
||||
{
|
||||
let mut history = self.load_level_history.write().await;
|
||||
history.push_back((SystemTime::now(), new_level));
|
||||
|
||||
// keep history record in reasonable range
|
||||
while history.len() > 100 {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
info!("load level changed: {:?} -> {:?}, duration: {:?}", old_level, new_level, duration);
|
||||
|
||||
// if enter critical load state, record warning
|
||||
if new_level == LoadLevel::Critical {
|
||||
warn!("system entered critical load state, Scanner will pause running");
|
||||
}
|
||||
}
|
||||
|
||||
/// get current load level
|
||||
pub async fn get_business_load_level(&self) -> LoadLevel {
|
||||
*self.current_load_level.read().await
|
||||
}
|
||||
|
||||
/// get current metrics
|
||||
pub async fn get_current_metrics(&self) -> IOMetrics {
|
||||
self.current_metrics.read().await.clone()
|
||||
}
|
||||
|
||||
/// get history metrics
|
||||
pub async fn get_history_metrics(&self) -> Vec<IOMetrics> {
|
||||
self.history_metrics.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// get load stats
|
||||
pub async fn get_load_stats(&self) -> LoadLevelStats {
|
||||
self.load_stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// update business IO metrics
|
||||
pub async fn update_business_metrics(&self, latency: u64, qps: u64, error_rate: u64, connections: u64) {
|
||||
self.business_metrics.request_latency.store(latency, Ordering::Relaxed);
|
||||
self.business_metrics.request_qps.store(qps, Ordering::Relaxed);
|
||||
self.business_metrics.error_rate.store(error_rate, Ordering::Relaxed);
|
||||
self.business_metrics.active_connections.store(connections, Ordering::Relaxed);
|
||||
|
||||
*self.business_metrics.last_update.write().await = SystemTime::now();
|
||||
|
||||
debug!(
|
||||
"update business metrics: latency={}ms, QPS={}, error rate={}‰, connections={}",
|
||||
latency, qps, error_rate, connections
|
||||
);
|
||||
}
|
||||
|
||||
/// clone for background task
|
||||
fn clone_for_background(&self) -> Self {
|
||||
Self {
|
||||
config: self.config.clone(),
|
||||
current_metrics: self.current_metrics.clone(),
|
||||
history_metrics: self.history_metrics.clone(),
|
||||
current_load_level: self.current_load_level.clone(),
|
||||
load_level_history: self.load_level_history.clone(),
|
||||
load_stats: self.load_stats.clone(),
|
||||
business_metrics: self.business_metrics.clone(),
|
||||
cancel_token: self.cancel_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// reset stats
|
||||
pub async fn reset_stats(&self) {
|
||||
*self.load_stats.write().await = LoadLevelStats::default();
|
||||
self.load_level_history.write().await.clear();
|
||||
self.history_metrics.write().await.clear();
|
||||
info!("IO monitor stats reset");
|
||||
}
|
||||
|
||||
/// get load level history
|
||||
pub async fn get_load_level_history(&self) -> Vec<(SystemTime, LoadLevel)> {
|
||||
self.load_level_history.read().await.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::scanner::LoadLevel;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// IO throttler config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IOThrottlerConfig {
|
||||
/// max IOPS limit
|
||||
pub max_iops: u64,
|
||||
/// business priority baseline (percentage)
|
||||
pub base_business_priority: u8,
|
||||
/// scanner minimum delay (milliseconds)
|
||||
pub min_scan_delay: u64,
|
||||
/// scanner maximum delay (milliseconds)
|
||||
pub max_scan_delay: u64,
|
||||
/// whether enable dynamic adjustment
|
||||
pub enable_dynamic_adjustment: bool,
|
||||
/// adjustment response time (seconds)
|
||||
pub adjustment_response_time: u64,
|
||||
}
|
||||
|
||||
impl Default for IOThrottlerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iops: 1000, // default max 1000 IOPS
|
||||
base_business_priority: 95, // business priority 95%
|
||||
min_scan_delay: 5000, // minimum 5s delay
|
||||
max_scan_delay: 60000, // maximum 60s delay
|
||||
enable_dynamic_adjustment: true,
|
||||
adjustment_response_time: 5, // 5 seconds response time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// resource allocation strategy
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceAllocationStrategy {
|
||||
/// business priority strategy
|
||||
BusinessFirst,
|
||||
/// balanced strategy
|
||||
Balanced,
|
||||
/// maintenance priority strategy (only used in special cases)
|
||||
MaintenanceFirst,
|
||||
}
|
||||
|
||||
/// throttle decision
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleDecision {
|
||||
/// whether should pause scanning
|
||||
pub should_pause: bool,
|
||||
/// suggested scanning delay
|
||||
pub suggested_delay: Duration,
|
||||
/// resource allocation suggestion
|
||||
pub resource_allocation: ResourceAllocation,
|
||||
/// decision reason
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// resource allocation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceAllocation {
|
||||
/// business IO allocation percentage (0-100)
|
||||
pub business_percentage: u8,
|
||||
/// scanner IO allocation percentage (0-100)
|
||||
pub scanner_percentage: u8,
|
||||
/// allocation strategy
|
||||
pub strategy: ResourceAllocationStrategy,
|
||||
}
|
||||
|
||||
/// enhanced IO throttler
|
||||
///
|
||||
/// dynamically adjust the resource usage of the scanner based on real-time system load and business demand,
|
||||
/// ensure business IO gets priority protection.
|
||||
pub struct AdvancedIOThrottler {
|
||||
/// config
|
||||
config: Arc<RwLock<IOThrottlerConfig>>,
|
||||
/// current IOPS usage (reserved field)
|
||||
#[allow(dead_code)]
|
||||
current_iops: Arc<AtomicU64>,
|
||||
/// business priority weight (0-100)
|
||||
business_priority: Arc<AtomicU8>,
|
||||
/// scanning operation delay (milliseconds)
|
||||
scan_delay: Arc<AtomicU64>,
|
||||
/// resource allocation strategy
|
||||
allocation_strategy: Arc<RwLock<ResourceAllocationStrategy>>,
|
||||
/// throttle history record
|
||||
throttle_history: Arc<RwLock<Vec<ThrottleRecord>>>,
|
||||
/// last adjustment time (reserved field)
|
||||
#[allow(dead_code)]
|
||||
last_adjustment: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
/// throttle record
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleRecord {
|
||||
/// timestamp
|
||||
pub timestamp: SystemTime,
|
||||
/// load level
|
||||
pub load_level: LoadLevel,
|
||||
/// decision
|
||||
pub decision: ThrottleDecision,
|
||||
/// system metrics snapshot
|
||||
pub metrics_snapshot: MetricsSnapshot,
|
||||
}
|
||||
|
||||
/// metrics snapshot
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetricsSnapshot {
|
||||
/// IOPS
|
||||
pub iops: u64,
|
||||
/// latency
|
||||
pub latency: u64,
|
||||
/// CPU usage
|
||||
pub cpu_usage: u8,
|
||||
/// memory usage
|
||||
pub memory_usage: u8,
|
||||
}
|
||||
|
||||
impl AdvancedIOThrottler {
|
||||
/// create new advanced IO throttler
|
||||
pub fn new(config: IOThrottlerConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
current_iops: Arc::new(AtomicU64::new(0)),
|
||||
business_priority: Arc::new(AtomicU8::new(95)),
|
||||
scan_delay: Arc::new(AtomicU64::new(5000)),
|
||||
allocation_strategy: Arc::new(RwLock::new(ResourceAllocationStrategy::BusinessFirst)),
|
||||
throttle_history: Arc::new(RwLock::new(Vec::new())),
|
||||
last_adjustment: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
}
|
||||
}
|
||||
|
||||
/// adjust scanning delay based on load level
|
||||
pub async fn adjust_for_load_level(&self, load_level: LoadLevel) -> Duration {
|
||||
let config = self.config.read().await;
|
||||
|
||||
let delay_ms = match load_level {
|
||||
LoadLevel::Low => {
|
||||
// low load: use minimum delay
|
||||
self.scan_delay.store(config.min_scan_delay, Ordering::Relaxed);
|
||||
self.business_priority
|
||||
.store(config.base_business_priority.saturating_sub(5), Ordering::Relaxed);
|
||||
config.min_scan_delay
|
||||
}
|
||||
LoadLevel::Medium => {
|
||||
// medium load: increase delay moderately
|
||||
let delay = config.min_scan_delay * 5; // 500ms
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority.store(config.base_business_priority, Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
LoadLevel::High => {
|
||||
// high load: increase delay significantly
|
||||
let delay = config.min_scan_delay * 10; // 50s
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority
|
||||
.store(config.base_business_priority.saturating_add(3), Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
LoadLevel::Critical => {
|
||||
// critical load: maximum delay or pause
|
||||
let delay = config.max_scan_delay; // 60s
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority.store(99, Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
};
|
||||
|
||||
let duration = Duration::from_millis(delay_ms);
|
||||
|
||||
debug!("Adjust scanning delay based on load level {:?}: {:?}", load_level, duration);
|
||||
|
||||
duration
|
||||
}
|
||||
|
||||
/// create throttle decision
|
||||
pub async fn make_throttle_decision(&self, load_level: LoadLevel, metrics: Option<MetricsSnapshot>) -> ThrottleDecision {
|
||||
let _config = self.config.read().await;
|
||||
|
||||
let should_pause = matches!(load_level, LoadLevel::Critical);
|
||||
|
||||
let suggested_delay = self.adjust_for_load_level(load_level).await;
|
||||
|
||||
let resource_allocation = self.calculate_resource_allocation(load_level).await;
|
||||
|
||||
let reason = match load_level {
|
||||
LoadLevel::Low => "system load is low, scanner can run normally".to_string(),
|
||||
LoadLevel::Medium => "system load is moderate, scanner is running at reduced speed".to_string(),
|
||||
LoadLevel::High => "system load is high, scanner is running at significantly reduced speed".to_string(),
|
||||
LoadLevel::Critical => "system load is too high, scanner is paused".to_string(),
|
||||
};
|
||||
|
||||
let decision = ThrottleDecision {
|
||||
should_pause,
|
||||
suggested_delay,
|
||||
resource_allocation,
|
||||
reason,
|
||||
};
|
||||
|
||||
// record decision history
|
||||
if let Some(snapshot) = metrics {
|
||||
self.record_throttle_decision(load_level, decision.clone(), snapshot).await;
|
||||
}
|
||||
|
||||
decision
|
||||
}
|
||||
|
||||
/// calculate resource allocation
|
||||
async fn calculate_resource_allocation(&self, load_level: LoadLevel) -> ResourceAllocation {
|
||||
let strategy = *self.allocation_strategy.read().await;
|
||||
|
||||
let (business_pct, scanner_pct) = match (strategy, load_level) {
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Low) => (90, 10),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Medium) => (95, 5),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::High) => (98, 2),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Critical) => (99, 1),
|
||||
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Low) => (80, 20),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Medium) => (85, 15),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::High) => (90, 10),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Critical) => (95, 5),
|
||||
|
||||
(ResourceAllocationStrategy::MaintenanceFirst, _) => (70, 30), // special maintenance mode
|
||||
};
|
||||
|
||||
ResourceAllocation {
|
||||
business_percentage: business_pct,
|
||||
scanner_percentage: scanner_pct,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
|
||||
/// check whether should pause scanning
|
||||
pub async fn should_pause_scanning(&self, load_level: LoadLevel) -> bool {
|
||||
match load_level {
|
||||
LoadLevel::Critical => {
|
||||
warn!("System load reached critical level, pausing scanner");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// record throttle decision
|
||||
async fn record_throttle_decision(&self, load_level: LoadLevel, decision: ThrottleDecision, metrics: MetricsSnapshot) {
|
||||
let record = ThrottleRecord {
|
||||
timestamp: SystemTime::now(),
|
||||
load_level,
|
||||
decision,
|
||||
metrics_snapshot: metrics,
|
||||
};
|
||||
|
||||
let mut history = self.throttle_history.write().await;
|
||||
history.push(record);
|
||||
|
||||
// keep history record in reasonable range (last 1000 records)
|
||||
while history.len() > 1000 {
|
||||
history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// set resource allocation strategy
|
||||
pub async fn set_allocation_strategy(&self, strategy: ResourceAllocationStrategy) {
|
||||
*self.allocation_strategy.write().await = strategy;
|
||||
info!("Set resource allocation strategy: {:?}", strategy);
|
||||
}
|
||||
|
||||
/// get current resource allocation
|
||||
pub async fn get_current_allocation(&self) -> ResourceAllocation {
|
||||
let current_load = LoadLevel::Low; // need to get from external
|
||||
self.calculate_resource_allocation(current_load).await
|
||||
}
|
||||
|
||||
/// get throttle history
|
||||
pub async fn get_throttle_history(&self) -> Vec<ThrottleRecord> {
|
||||
self.throttle_history.read().await.clone()
|
||||
}
|
||||
|
||||
/// get throttle stats
|
||||
pub async fn get_throttle_stats(&self) -> ThrottleStats {
|
||||
let history = self.throttle_history.read().await;
|
||||
|
||||
let total_decisions = history.len();
|
||||
let pause_decisions = history.iter().filter(|r| r.decision.should_pause).count();
|
||||
|
||||
let mut delay_sum = Duration::ZERO;
|
||||
for record in history.iter() {
|
||||
delay_sum += record.decision.suggested_delay;
|
||||
}
|
||||
|
||||
let avg_delay = if total_decisions > 0 {
|
||||
delay_sum / total_decisions as u32
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
|
||||
// count by load level
|
||||
let low_count = history.iter().filter(|r| r.load_level == LoadLevel::Low).count();
|
||||
let medium_count = history.iter().filter(|r| r.load_level == LoadLevel::Medium).count();
|
||||
let high_count = history.iter().filter(|r| r.load_level == LoadLevel::High).count();
|
||||
let critical_count = history.iter().filter(|r| r.load_level == LoadLevel::Critical).count();
|
||||
|
||||
ThrottleStats {
|
||||
total_decisions,
|
||||
pause_decisions,
|
||||
average_delay: avg_delay,
|
||||
load_level_distribution: LoadLevelDistribution {
|
||||
low_count,
|
||||
medium_count,
|
||||
high_count,
|
||||
critical_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// reset throttle history
|
||||
pub async fn reset_history(&self) {
|
||||
self.throttle_history.write().await.clear();
|
||||
info!("Reset throttle history");
|
||||
}
|
||||
|
||||
/// update config
|
||||
pub async fn update_config(&self, new_config: IOThrottlerConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
info!("Updated IO throttler configuration");
|
||||
}
|
||||
|
||||
/// get current scanning delay
|
||||
pub fn get_current_scan_delay(&self) -> Duration {
|
||||
let delay_ms = self.scan_delay.load(Ordering::Relaxed);
|
||||
Duration::from_millis(delay_ms)
|
||||
}
|
||||
|
||||
/// get current business priority
|
||||
pub fn get_current_business_priority(&self) -> u8 {
|
||||
self.business_priority.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// simulate business load pressure test
|
||||
pub async fn simulate_business_pressure(&self, duration: Duration) -> SimulationResult {
|
||||
info!("Start simulating business load pressure test, duration: {:?}", duration);
|
||||
|
||||
let start_time = SystemTime::now();
|
||||
let mut simulation_records = Vec::new();
|
||||
|
||||
// simulate different load level changes
|
||||
let load_levels = [
|
||||
LoadLevel::Low,
|
||||
LoadLevel::Medium,
|
||||
LoadLevel::High,
|
||||
LoadLevel::Critical,
|
||||
LoadLevel::High,
|
||||
LoadLevel::Medium,
|
||||
LoadLevel::Low,
|
||||
];
|
||||
|
||||
let step_duration = duration / load_levels.len() as u32;
|
||||
|
||||
for (i, &load_level) in load_levels.iter().enumerate() {
|
||||
let _step_start = SystemTime::now();
|
||||
|
||||
// simulate metrics for this load level
|
||||
let metrics = MetricsSnapshot {
|
||||
iops: match load_level {
|
||||
LoadLevel::Low => 200,
|
||||
LoadLevel::Medium => 500,
|
||||
LoadLevel::High => 800,
|
||||
LoadLevel::Critical => 1200,
|
||||
},
|
||||
latency: match load_level {
|
||||
LoadLevel::Low => 10,
|
||||
LoadLevel::Medium => 25,
|
||||
LoadLevel::High => 60,
|
||||
LoadLevel::Critical => 150,
|
||||
},
|
||||
cpu_usage: match load_level {
|
||||
LoadLevel::Low => 30,
|
||||
LoadLevel::Medium => 50,
|
||||
LoadLevel::High => 75,
|
||||
LoadLevel::Critical => 95,
|
||||
},
|
||||
memory_usage: match load_level {
|
||||
LoadLevel::Low => 40,
|
||||
LoadLevel::Medium => 60,
|
||||
LoadLevel::High => 80,
|
||||
LoadLevel::Critical => 90,
|
||||
},
|
||||
};
|
||||
|
||||
let decision = self.make_throttle_decision(load_level, Some(metrics.clone())).await;
|
||||
|
||||
simulation_records.push(SimulationRecord {
|
||||
step: i + 1,
|
||||
load_level,
|
||||
metrics,
|
||||
decision: decision.clone(),
|
||||
step_duration,
|
||||
});
|
||||
|
||||
info!(
|
||||
"simulate step {}: load={:?}, delay={:?}, pause={}",
|
||||
i + 1,
|
||||
load_level,
|
||||
decision.suggested_delay,
|
||||
decision.should_pause
|
||||
);
|
||||
|
||||
// wait for step duration
|
||||
tokio::time::sleep(step_duration).await;
|
||||
}
|
||||
|
||||
let total_duration = SystemTime::now().duration_since(start_time).unwrap_or(Duration::ZERO);
|
||||
|
||||
SimulationResult {
|
||||
total_duration,
|
||||
simulation_records,
|
||||
final_stats: self.get_throttle_stats().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// throttle stats
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleStats {
|
||||
/// total decisions
|
||||
pub total_decisions: usize,
|
||||
/// pause decisions
|
||||
pub pause_decisions: usize,
|
||||
/// average delay
|
||||
pub average_delay: Duration,
|
||||
/// load level distribution
|
||||
pub load_level_distribution: LoadLevelDistribution,
|
||||
}
|
||||
|
||||
/// load level distribution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadLevelDistribution {
|
||||
/// low load count
|
||||
pub low_count: usize,
|
||||
/// medium load count
|
||||
pub medium_count: usize,
|
||||
/// high load count
|
||||
pub high_count: usize,
|
||||
/// critical load count
|
||||
pub critical_count: usize,
|
||||
}
|
||||
|
||||
/// simulation result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulationResult {
|
||||
/// total duration
|
||||
pub total_duration: Duration,
|
||||
/// simulation records
|
||||
pub simulation_records: Vec<SimulationRecord>,
|
||||
/// final stats
|
||||
pub final_stats: ThrottleStats,
|
||||
}
|
||||
|
||||
/// simulation record
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulationRecord {
|
||||
/// step number
|
||||
pub step: usize,
|
||||
/// load level
|
||||
pub load_level: LoadLevel,
|
||||
/// metrics snapshot
|
||||
pub metrics: MetricsSnapshot,
|
||||
/// throttle decision
|
||||
pub decision: ThrottleDecision,
|
||||
/// step duration
|
||||
pub step_duration: Duration,
|
||||
}
|
||||
|
||||
impl Default for AdvancedIOThrottler {
|
||||
fn default() -> Self {
|
||||
Self::new(IOThrottlerConfig::default())
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::Result;
|
||||
use rustfs_common::data_usage::SizeSummary;
|
||||
use rustfs_common::metrics::IlmAction;
|
||||
use rustfs_ecstore::bucket::{
|
||||
lifecycle::{
|
||||
bucket_lifecycle_audit::LcEventSrc,
|
||||
bucket_lifecycle_ops::{GLOBAL_ExpiryState, apply_lifecycle_action, eval_action_from_lifecycle},
|
||||
lifecycle,
|
||||
lifecycle::Lifecycle,
|
||||
},
|
||||
metadata_sys::get_object_lock_config,
|
||||
object_lock::objectlock_sys::{BucketObjectLockSys, enforce_retention_for_deletion},
|
||||
versioning::VersioningApi,
|
||||
versioning_sys::BucketVersioningSys,
|
||||
};
|
||||
use rustfs_ecstore::store_api::{ObjectInfo, ObjectToDelete};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use s3s::dto::{BucketLifecycleConfiguration as LifecycleConfig, VersioningConfiguration};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::info;
|
||||
|
||||
static SCANNER_EXCESS_OBJECT_VERSIONS: AtomicU64 = AtomicU64::new(100);
|
||||
static SCANNER_EXCESS_OBJECT_VERSIONS_TOTAL_SIZE: AtomicU64 = AtomicU64::new(1024 * 1024 * 1024 * 1024); // 1 TB
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ScannerItem {
|
||||
pub bucket: String,
|
||||
pub object_name: String,
|
||||
pub lifecycle: Option<Arc<LifecycleConfig>>,
|
||||
pub versioning: Option<Arc<VersioningConfiguration>>,
|
||||
}
|
||||
|
||||
impl ScannerItem {
|
||||
pub fn new(
|
||||
bucket: String,
|
||||
lifecycle: Option<Arc<LifecycleConfig>>,
|
||||
versioning: Option<Arc<VersioningConfiguration>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bucket,
|
||||
object_name: "".to_string(),
|
||||
lifecycle,
|
||||
versioning,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_versions_actions(&self, fivs: &[FileInfo]) -> Result<Vec<ObjectInfo>> {
|
||||
let obj_infos = self.apply_newer_noncurrent_version_limit(fivs).await?;
|
||||
if obj_infos.len() >= SCANNER_EXCESS_OBJECT_VERSIONS.load(Ordering::SeqCst) as usize {
|
||||
// todo
|
||||
}
|
||||
|
||||
let mut cumulative_size = 0;
|
||||
for obj_info in obj_infos.iter() {
|
||||
cumulative_size += obj_info.size;
|
||||
}
|
||||
|
||||
if cumulative_size >= SCANNER_EXCESS_OBJECT_VERSIONS_TOTAL_SIZE.load(Ordering::SeqCst) as i64 {
|
||||
//todo
|
||||
}
|
||||
|
||||
Ok(obj_infos)
|
||||
}
|
||||
|
||||
pub async fn apply_newer_noncurrent_version_limit(&self, fivs: &[FileInfo]) -> Result<Vec<ObjectInfo>> {
|
||||
let lock_enabled = if let Some(rcfg) = BucketObjectLockSys::get(&self.bucket).await {
|
||||
rcfg.mode.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let _vcfg = BucketVersioningSys::get(&self.bucket).await?;
|
||||
|
||||
let versioned = match BucketVersioningSys::get(&self.bucket).await {
|
||||
Ok(vcfg) => vcfg.versioned(&self.object_name),
|
||||
Err(_) => false,
|
||||
};
|
||||
let mut object_infos = Vec::with_capacity(fivs.len());
|
||||
|
||||
if self.lifecycle.is_none() {
|
||||
for info in fivs.iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(info, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
return Ok(object_infos);
|
||||
}
|
||||
|
||||
let event = self
|
||||
.lifecycle
|
||||
.as_ref()
|
||||
.expect("lifecycle err.")
|
||||
.clone()
|
||||
.noncurrent_versions_expiration_limit(&lifecycle::ObjectOpts {
|
||||
name: self.object_name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let lim = event.newer_noncurrent_versions;
|
||||
if lim == 0 || fivs.len() <= lim + 1 {
|
||||
for fi in fivs.iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
return Ok(object_infos);
|
||||
}
|
||||
|
||||
let overflow_versions = &fivs[lim + 1..];
|
||||
for fi in fivs[..lim + 1].iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
|
||||
let mut to_del = Vec::<ObjectToDelete>::with_capacity(overflow_versions.len());
|
||||
for fi in overflow_versions.iter() {
|
||||
let obj = ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned);
|
||||
if lock_enabled && enforce_retention_for_deletion(&obj) {
|
||||
//if enforce_retention_for_deletion(&obj) {
|
||||
/*if self.debug {
|
||||
if obj.version_id.is_some() {
|
||||
info!("lifecycle: {} v({}) is locked, not deleting\n", obj.name, obj.version_id.expect("err"));
|
||||
} else {
|
||||
info!("lifecycle: {} is locked, not deleting\n", obj.name);
|
||||
}
|
||||
}*/
|
||||
object_infos.push(obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
if OffsetDateTime::now_utc().unix_timestamp()
|
||||
< lifecycle::expected_expiry_time(obj.successor_mod_time.expect("err"), event.noncurrent_days as i32)
|
||||
.unix_timestamp()
|
||||
{
|
||||
object_infos.push(obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
to_del.push(ObjectToDelete {
|
||||
object_name: obj.name,
|
||||
version_id: obj.version_id,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if !to_del.is_empty() {
|
||||
let mut expiry_state = GLOBAL_ExpiryState.write().await;
|
||||
expiry_state.enqueue_by_newer_noncurrent(&self.bucket, to_del, event).await;
|
||||
}
|
||||
|
||||
Ok(object_infos)
|
||||
}
|
||||
|
||||
pub async fn apply_actions(&mut self, oi: &ObjectInfo, _size_s: &mut SizeSummary) -> (bool, i64) {
|
||||
let (action, _size) = self.apply_lifecycle(oi).await;
|
||||
|
||||
info!(
|
||||
"apply_actions {} {} {:?} {:?}",
|
||||
oi.bucket.clone(),
|
||||
oi.name.clone(),
|
||||
oi.version_id.clone(),
|
||||
oi.user_defined.clone()
|
||||
);
|
||||
|
||||
// Create a mutable clone if you need to modify fields
|
||||
/*let mut oi = oi.clone();
|
||||
oi.replication_status = ReplicationStatusType::from(
|
||||
oi.user_defined
|
||||
.get("x-amz-bucket-replication-status")
|
||||
.unwrap_or(&"PENDING".to_string()),
|
||||
);
|
||||
info!("apply status is: {:?}", oi.replication_status);
|
||||
self.heal_replication(&oi, _size_s).await;*/
|
||||
|
||||
if action.delete_all() {
|
||||
return (true, 0);
|
||||
}
|
||||
|
||||
(false, oi.size)
|
||||
}
|
||||
|
||||
async fn apply_lifecycle(&mut self, oi: &ObjectInfo) -> (IlmAction, i64) {
|
||||
let size = oi.size;
|
||||
if self.lifecycle.is_none() {
|
||||
info!("apply_lifecycle: No lifecycle config for object: {}", oi.name);
|
||||
return (IlmAction::NoneAction, size);
|
||||
}
|
||||
|
||||
info!("apply_lifecycle: Lifecycle config exists for object: {}", oi.name);
|
||||
|
||||
let (olcfg, rcfg) = if self.bucket != ".minio.sys" {
|
||||
(
|
||||
get_object_lock_config(&self.bucket).await.ok(),
|
||||
None, // FIXME: replication config
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
info!("apply_lifecycle: Evaluating lifecycle for object: {}", oi.name);
|
||||
|
||||
let lifecycle = match self.lifecycle.as_ref() {
|
||||
Some(lc) => lc,
|
||||
None => {
|
||||
info!("No lifecycle configuration found for object: {}", oi.name);
|
||||
return (IlmAction::NoneAction, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let lc_evt = eval_action_from_lifecycle(
|
||||
lifecycle,
|
||||
olcfg
|
||||
.as_ref()
|
||||
.and_then(|(c, _)| c.rule.as_ref().and_then(|r| r.default_retention.clone())),
|
||||
rcfg.clone(),
|
||||
oi, // Pass oi directly
|
||||
)
|
||||
.await;
|
||||
|
||||
info!("lifecycle: {} Initial scan: {} (action: {:?})", oi.name, lc_evt.action, lc_evt.action);
|
||||
|
||||
let mut new_size = size;
|
||||
match lc_evt.action {
|
||||
IlmAction::DeleteVersionAction | IlmAction::DeleteAllVersionsAction | IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
info!("apply_lifecycle: Object {} marked for version deletion, new_size=0", oi.name);
|
||||
new_size = 0;
|
||||
}
|
||||
IlmAction::DeleteAction => {
|
||||
info!("apply_lifecycle: Object {} marked for deletion", oi.name);
|
||||
if let Some(vcfg) = &self.versioning {
|
||||
if !vcfg.enabled() {
|
||||
info!("apply_lifecycle: Versioning disabled, setting new_size=0");
|
||||
new_size = 0;
|
||||
}
|
||||
} else {
|
||||
info!("apply_lifecycle: No versioning config, setting new_size=0");
|
||||
new_size = 0;
|
||||
}
|
||||
}
|
||||
IlmAction::NoneAction => {
|
||||
info!("apply_lifecycle: No action for object {}", oi.name);
|
||||
}
|
||||
_ => {
|
||||
info!("apply_lifecycle: Other action {:?} for object {}", lc_evt.action, oi.name);
|
||||
}
|
||||
}
|
||||
|
||||
if lc_evt.action != IlmAction::NoneAction {
|
||||
info!("apply_lifecycle: Applying lifecycle action {:?} for object {}", lc_evt.action, oi.name);
|
||||
apply_lifecycle_action(&lc_evt, &LcEventSrc::Scanner, oi).await;
|
||||
} else {
|
||||
info!("apply_lifecycle: Skipping lifecycle action for object {} as no action is needed", oi.name);
|
||||
}
|
||||
|
||||
(lc_evt.action, new_size)
|
||||
}
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use rustfs_common::data_usage::DiskUsageStatus;
|
||||
use rustfs_ecstore::data_usage::{
|
||||
LocalUsageSnapshot, LocalUsageSnapshotMeta, data_usage_state_dir, ensure_data_usage_layout, snapshot_file_name,
|
||||
write_local_snapshot,
|
||||
};
|
||||
use rustfs_ecstore::disk::DiskAPI;
|
||||
use rustfs_ecstore::store::ECStore;
|
||||
use rustfs_ecstore::store_api::ObjectInfo;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, FileMetaVersion, VersionType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{from_slice, to_vec};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::{fs, task};
|
||||
use tracing::warn;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const STATE_FILE_EXTENSION: &str = "";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LocalObjectUsage {
|
||||
pub bucket: String,
|
||||
pub object: String,
|
||||
pub last_modified_ns: Option<i128>,
|
||||
pub versions_count: u64,
|
||||
pub delete_markers_count: u64,
|
||||
pub total_size: u64,
|
||||
pub has_live_object: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct IncrementalScanState {
|
||||
last_scan_ns: Option<i128>,
|
||||
objects: HashMap<String, LocalObjectUsage>,
|
||||
}
|
||||
|
||||
struct DiskScanResult {
|
||||
snapshot: LocalUsageSnapshot,
|
||||
state: IncrementalScanState,
|
||||
objects_by_bucket: HashMap<String, Vec<LocalObjectRecord>>,
|
||||
status: DiskUsageStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalObjectRecord {
|
||||
pub usage: LocalObjectUsage,
|
||||
pub object_info: Option<rustfs_ecstore::store_api::ObjectInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LocalScanOutcome {
|
||||
pub snapshots: Vec<LocalUsageSnapshot>,
|
||||
pub bucket_objects: HashMap<String, Vec<LocalObjectRecord>>,
|
||||
pub disk_status: Vec<DiskUsageStatus>,
|
||||
}
|
||||
|
||||
/// Scan all local primary disks and persist refreshed usage snapshots.
|
||||
pub async fn scan_and_persist_local_usage(store: Arc<ECStore>) -> Result<LocalScanOutcome> {
|
||||
let mut snapshots = Vec::new();
|
||||
let mut bucket_objects: HashMap<String, Vec<LocalObjectRecord>> = HashMap::new();
|
||||
let mut disk_status = Vec::new();
|
||||
|
||||
for (pool_idx, pool) in store.pools.iter().enumerate() {
|
||||
for set_disks in pool.disk_set.iter() {
|
||||
let disks = {
|
||||
let guard = set_disks.disks.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
// Use the first local online disk in the set to avoid missing stats when disk 0 is down
|
||||
let mut picked = false;
|
||||
|
||||
for (disk_index, disk_opt) in disks.into_iter().enumerate() {
|
||||
let Some(disk) = disk_opt else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !disk.is_local() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if picked {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip offline disks; keep looking for an online candidate
|
||||
if !disk.is_online().await {
|
||||
continue;
|
||||
}
|
||||
|
||||
picked = true;
|
||||
|
||||
let disk_id = match disk.get_disk_id().await.map_err(Error::from)? {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
warn!("Skipping disk without ID: {}", disk.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let root = disk.path();
|
||||
ensure_data_usage_layout(root.as_path()).await.map_err(Error::from)?;
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: disk_id.clone(),
|
||||
pool_index: Some(pool_idx),
|
||||
set_index: Some(set_disks.set_index),
|
||||
disk_index: Some(disk_index),
|
||||
};
|
||||
|
||||
let state_path = state_file_path(root.as_path(), &disk_id);
|
||||
let state = read_scan_state(&state_path).await?;
|
||||
|
||||
let root_clone = root.clone();
|
||||
let meta_clone = meta.clone();
|
||||
|
||||
let handle = task::spawn_blocking(move || scan_disk_blocking(root_clone, meta_clone, state));
|
||||
|
||||
match handle.await {
|
||||
Ok(Ok(result)) => {
|
||||
write_local_snapshot(root.as_path(), &disk_id, &result.snapshot)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
write_scan_state(&state_path, &result.state).await?;
|
||||
snapshots.push(result.snapshot);
|
||||
for (bucket, records) in result.objects_by_bucket {
|
||||
bucket_objects.entry(bucket).or_default().extend(records.into_iter());
|
||||
}
|
||||
disk_status.push(result.status);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
warn!("Failed to scan disk {}: {}", disk.to_string(), err);
|
||||
}
|
||||
Err(join_err) => {
|
||||
warn!("Disk scan task panicked for disk {}: {}", disk.to_string(), join_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalScanOutcome {
|
||||
snapshots,
|
||||
bucket_objects,
|
||||
disk_status,
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_disk_blocking(root: PathBuf, meta: LocalUsageSnapshotMeta, mut state: IncrementalScanState) -> Result<DiskScanResult> {
|
||||
let now = SystemTime::now();
|
||||
let now_ns = system_time_to_ns(now);
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut emitted: HashSet<String> = HashSet::new();
|
||||
let mut objects_by_bucket: HashMap<String, Vec<LocalObjectRecord>> = HashMap::new();
|
||||
let mut status = DiskUsageStatus {
|
||||
disk_id: meta.disk_id.clone(),
|
||||
pool_index: meta.pool_index,
|
||||
set_index: meta.set_index,
|
||||
disk_index: meta.disk_index,
|
||||
last_update: None,
|
||||
snapshot_exists: false,
|
||||
};
|
||||
|
||||
for entry in WalkDir::new(&root).follow_links(false).into_iter().filter_map(|res| res.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if entry.file_name() != "xl.meta" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let xl_path = entry.path().to_path_buf();
|
||||
let Some(object_dir) = xl_path.parent() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(rel_path) = object_dir.strip_prefix(&root).ok().map(normalize_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut components = rel_path.split('/');
|
||||
let Some(bucket_name) = components.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if bucket_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let object_key = components.collect::<Vec<_>>().join("/");
|
||||
|
||||
visited.insert(rel_path.clone());
|
||||
|
||||
let metadata = match std::fs::metadata(&xl_path) {
|
||||
Ok(meta) => meta,
|
||||
Err(err) => {
|
||||
warn!("Failed to read metadata for {xl_path:?}: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mtime_ns = metadata.modified().ok().map(system_time_to_ns);
|
||||
|
||||
let should_parse = match state.objects.get(&rel_path) {
|
||||
Some(existing) => existing.last_modified_ns != mtime_ns,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_parse {
|
||||
match std::fs::read(&xl_path) {
|
||||
Ok(buf) => match FileMeta::load(&buf) {
|
||||
Ok(file_meta) => match compute_object_usage(bucket_name, object_key.as_str(), &file_meta) {
|
||||
Ok(Some(mut record)) => {
|
||||
record.usage.last_modified_ns = mtime_ns;
|
||||
state.objects.insert(rel_path.clone(), record.usage.clone());
|
||||
emitted.insert(rel_path.clone());
|
||||
objects_by_bucket.entry(record.usage.bucket.clone()).or_default().push(record);
|
||||
}
|
||||
Ok(None) => {
|
||||
state.objects.remove(&rel_path);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to parse usage from {:?}: {}", xl_path, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Failed to decode xl.meta {:?}: {}", xl_path, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Failed to read xl.meta {:?}: {}", xl_path, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.objects.retain(|key, _| visited.contains(key));
|
||||
state.last_scan_ns = Some(now_ns);
|
||||
|
||||
for (key, usage) in &state.objects {
|
||||
if emitted.contains(key) {
|
||||
continue;
|
||||
}
|
||||
objects_by_bucket
|
||||
.entry(usage.bucket.clone())
|
||||
.or_default()
|
||||
.push(LocalObjectRecord {
|
||||
usage: usage.clone(),
|
||||
object_info: None,
|
||||
});
|
||||
}
|
||||
|
||||
let snapshot = build_snapshot(meta, &state.objects, now);
|
||||
status.snapshot_exists = true;
|
||||
status.last_update = Some(now);
|
||||
|
||||
Ok(DiskScanResult {
|
||||
snapshot,
|
||||
state,
|
||||
objects_by_bucket,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_object_usage(bucket: &str, object: &str, file_meta: &FileMeta) -> Result<Option<LocalObjectRecord>> {
|
||||
let mut versions_count = 0u64;
|
||||
let mut delete_markers_count = 0u64;
|
||||
let mut total_size = 0u64;
|
||||
let mut has_live_object = false;
|
||||
|
||||
let mut latest_file_info: Option<FileInfo> = None;
|
||||
|
||||
for shallow in &file_meta.versions {
|
||||
match shallow.header.version_type {
|
||||
VersionType::Object => {
|
||||
let version = match FileMetaVersion::try_from(shallow.meta.as_slice()) {
|
||||
Ok(version) => version,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse file meta version: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(obj) = version.object {
|
||||
if !has_live_object {
|
||||
total_size = obj.size.max(0) as u64;
|
||||
}
|
||||
has_live_object = true;
|
||||
versions_count = versions_count.saturating_add(1);
|
||||
|
||||
if latest_file_info.is_none() {
|
||||
if let Ok(info) = file_meta.into_fileinfo(bucket, object, "", false, false) {
|
||||
latest_file_info = Some(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VersionType::Delete => {
|
||||
delete_markers_count = delete_markers_count.saturating_add(1);
|
||||
versions_count = versions_count.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_live_object && delete_markers_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let object_info = latest_file_info.as_ref().map(|fi| {
|
||||
let versioned = fi.version_id.is_some();
|
||||
ObjectInfo::from_file_info(fi, bucket, object, versioned)
|
||||
});
|
||||
|
||||
Ok(Some(LocalObjectRecord {
|
||||
usage: LocalObjectUsage {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
last_modified_ns: None,
|
||||
versions_count,
|
||||
delete_markers_count,
|
||||
total_size,
|
||||
has_live_object,
|
||||
},
|
||||
object_info,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_snapshot(
|
||||
meta: LocalUsageSnapshotMeta,
|
||||
objects: &HashMap<String, LocalObjectUsage>,
|
||||
now: SystemTime,
|
||||
) -> LocalUsageSnapshot {
|
||||
let mut snapshot = LocalUsageSnapshot::new(meta);
|
||||
|
||||
for usage in objects.values() {
|
||||
let bucket_entry = snapshot.buckets_usage.entry(usage.bucket.clone()).or_default();
|
||||
|
||||
if usage.has_live_object {
|
||||
bucket_entry.objects_count = bucket_entry.objects_count.saturating_add(1);
|
||||
}
|
||||
bucket_entry.versions_count = bucket_entry.versions_count.saturating_add(usage.versions_count);
|
||||
bucket_entry.delete_markers_count = bucket_entry.delete_markers_count.saturating_add(usage.delete_markers_count);
|
||||
bucket_entry.size = bucket_entry.size.saturating_add(usage.total_size);
|
||||
}
|
||||
|
||||
snapshot.last_update = Some(now);
|
||||
snapshot.recompute_totals();
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
path.iter()
|
||||
.map(|component| component.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn system_time_to_ns(time: SystemTime) -> i128 {
|
||||
match time.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => {
|
||||
let secs = duration.as_secs() as i128;
|
||||
let nanos = duration.subsec_nanos() as i128;
|
||||
secs * 1_000_000_000 + nanos
|
||||
}
|
||||
Err(err) => {
|
||||
let duration = err.duration();
|
||||
let secs = duration.as_secs() as i128;
|
||||
let nanos = duration.subsec_nanos() as i128;
|
||||
-(secs * 1_000_000_000 + nanos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_file_path(root: &Path, disk_id: &str) -> PathBuf {
|
||||
let mut path = data_usage_state_dir(root);
|
||||
path.push(format!("{}{}", snapshot_file_name(disk_id), STATE_FILE_EXTENSION));
|
||||
path
|
||||
}
|
||||
|
||||
async fn read_scan_state(path: &Path) -> Result<IncrementalScanState> {
|
||||
match fs::read(path).await {
|
||||
Ok(bytes) => from_slice(&bytes).map_err(|err| Error::Serialization(err.to_string())),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(IncrementalScanState::default()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_scan_state(path: &Path, state: &IncrementalScanState) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let data = to_vec(state).map_err(|err| Error::Serialization(err.to_string()))?;
|
||||
fs::write(path, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_filemeta::{ChecksumAlgo, ErasureAlgo, FileMetaShallowVersion, MetaDeleteMarker, MetaObject};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn build_file_meta_with_object(erasure_index: usize, size: i64) -> FileMeta {
|
||||
let mut file_meta = FileMeta::default();
|
||||
|
||||
let meta_object = MetaObject {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
data_dir: Some(Uuid::new_v4()),
|
||||
erasure_algorithm: ErasureAlgo::ReedSolomon,
|
||||
erasure_m: 2,
|
||||
erasure_n: 2,
|
||||
erasure_block_size: 4096,
|
||||
erasure_index,
|
||||
erasure_dist: vec![0_u8, 1, 2, 3],
|
||||
bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
|
||||
part_numbers: vec![1],
|
||||
part_etags: vec!["etag".to_string()],
|
||||
part_sizes: vec![size as usize],
|
||||
part_actual_sizes: vec![size],
|
||||
part_indices: Vec::new(),
|
||||
size,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
meta_sys: HashMap::new(),
|
||||
meta_user: HashMap::new(),
|
||||
};
|
||||
|
||||
let version = FileMetaVersion {
|
||||
version_type: VersionType::Object,
|
||||
object: Some(meta_object),
|
||||
delete_marker: None,
|
||||
write_version: 1,
|
||||
};
|
||||
|
||||
let shallow = FileMetaShallowVersion::try_from(version).expect("convert version");
|
||||
file_meta.versions.push(shallow);
|
||||
file_meta
|
||||
}
|
||||
|
||||
fn build_file_meta_with_delete_marker() -> FileMeta {
|
||||
let mut file_meta = FileMeta::default();
|
||||
|
||||
let delete_marker = MetaDeleteMarker {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
meta_sys: HashMap::new(),
|
||||
};
|
||||
|
||||
let version = FileMetaVersion {
|
||||
version_type: VersionType::Delete,
|
||||
object: None,
|
||||
delete_marker: Some(delete_marker),
|
||||
write_version: 2,
|
||||
};
|
||||
|
||||
let shallow = FileMetaShallowVersion::try_from(version).expect("convert delete marker");
|
||||
file_meta.versions.push(shallow);
|
||||
file_meta
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_primary_disk() {
|
||||
let file_meta = build_file_meta_with_object(0, 1024);
|
||||
let record = compute_object_usage("bucket", "foo/bar", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("record should exist");
|
||||
|
||||
assert!(record.usage.has_live_object);
|
||||
assert_eq!(record.usage.bucket, "bucket");
|
||||
assert_eq!(record.usage.object, "foo/bar");
|
||||
assert_eq!(record.usage.total_size, 1024);
|
||||
assert!(record.object_info.is_some(), "object info should be synthesized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_handles_non_primary_disk() {
|
||||
let file_meta = build_file_meta_with_object(1, 2048);
|
||||
let record = compute_object_usage("bucket", "obj", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("record should exist for non-primary shard");
|
||||
assert!(record.usage.has_live_object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_reports_delete_marker() {
|
||||
let file_meta = build_file_meta_with_delete_marker();
|
||||
let record = compute_object_usage("bucket", "obj", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("delete marker record");
|
||||
|
||||
assert!(!record.usage.has_live_object);
|
||||
assert_eq!(record.usage.delete_markers_count, 1);
|
||||
assert_eq!(record.usage.versions_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_snapshot_accumulates_usage() {
|
||||
let mut objects = HashMap::new();
|
||||
objects.insert(
|
||||
"bucket/a".to_string(),
|
||||
LocalObjectUsage {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "a".to_string(),
|
||||
last_modified_ns: None,
|
||||
versions_count: 2,
|
||||
delete_markers_count: 1,
|
||||
total_size: 512,
|
||||
has_live_object: true,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot = build_snapshot(LocalUsageSnapshotMeta::default(), &objects, SystemTime::now());
|
||||
let usage = snapshot.buckets_usage.get("bucket").expect("bucket entry should exist");
|
||||
assert_eq!(usage.objects_count, 1);
|
||||
assert_eq!(usage.versions_count, 2);
|
||||
assert_eq!(usage.delete_markers_count, 1);
|
||||
assert_eq!(usage.size, 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_handles_incremental_updates() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
|
||||
let bucket_dir = root.join("bench");
|
||||
let object1_dir = bucket_dir.join("obj1");
|
||||
fs::create_dir_all(&object1_dir).expect("create first object directory");
|
||||
|
||||
let file_meta = build_file_meta_with_object(0, 1024);
|
||||
let bytes = file_meta.marshal_msg().expect("serialize first object");
|
||||
fs::write(object1_dir.join("xl.meta"), bytes).expect("write first xl.meta");
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot1,
|
||||
state,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta.clone(), IncrementalScanState::default()).expect("initial scan succeeds");
|
||||
|
||||
let usage1 = snapshot1.buckets_usage.get("bench").expect("bucket stats recorded");
|
||||
assert_eq!(usage1.objects_count, 1);
|
||||
assert_eq!(usage1.size, 1024);
|
||||
assert_eq!(state.objects.len(), 1);
|
||||
|
||||
let object2_dir = bucket_dir.join("nested").join("obj2");
|
||||
fs::create_dir_all(&object2_dir).expect("create second object directory");
|
||||
let second_meta = build_file_meta_with_object(0, 2048);
|
||||
let bytes = second_meta.marshal_msg().expect("serialize second object");
|
||||
fs::write(object2_dir.join("xl.meta"), bytes).expect("write second xl.meta");
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot2,
|
||||
state: state_next,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta.clone(), state).expect("incremental scan succeeds");
|
||||
|
||||
let usage2 = snapshot2
|
||||
.buckets_usage
|
||||
.get("bench")
|
||||
.expect("bucket stats recorded after addition");
|
||||
assert_eq!(usage2.objects_count, 2);
|
||||
assert_eq!(usage2.size, 1024 + 2048);
|
||||
assert_eq!(state_next.objects.len(), 2);
|
||||
|
||||
fs::remove_dir_all(&object1_dir).expect("remove first object");
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot3,
|
||||
state: state_final,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta, state_next).expect("scan after deletion succeeds");
|
||||
|
||||
let usage3 = snapshot3
|
||||
.buckets_usage
|
||||
.get("bench")
|
||||
.expect("bucket stats recorded after deletion");
|
||||
assert_eq!(usage3.objects_count, 1);
|
||||
assert_eq!(usage3.size, 2048);
|
||||
assert_eq!(state_final.objects.len(), 1);
|
||||
assert!(
|
||||
state_final.objects.keys().all(|path| path.contains("nested")),
|
||||
"state should only keep surviving object"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_recovers_from_stale_state_entries() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
|
||||
let mut stale_state = IncrementalScanState::default();
|
||||
stale_state.objects.insert(
|
||||
"bench/stale".to_string(),
|
||||
LocalObjectUsage {
|
||||
bucket: "bench".to_string(),
|
||||
object: "stale".to_string(),
|
||||
last_modified_ns: Some(42),
|
||||
versions_count: 1,
|
||||
delete_markers_count: 0,
|
||||
total_size: 512,
|
||||
has_live_object: true,
|
||||
},
|
||||
);
|
||||
stale_state.last_scan_ns = Some(99);
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot, state, status, ..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta, stale_state).expect("scan succeeds");
|
||||
|
||||
assert!(state.objects.is_empty(), "stale entries should be cleared when files disappear");
|
||||
assert!(
|
||||
snapshot.buckets_usage.is_empty(),
|
||||
"no real xl.meta files means bucket usage should stay empty"
|
||||
);
|
||||
assert!(status.snapshot_exists, "snapshot status should indicate a refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_handles_large_volume() {
|
||||
const OBJECTS: usize = 256;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
let bucket_dir = root.join("bulk");
|
||||
|
||||
for idx in 0..OBJECTS {
|
||||
let object_dir = bucket_dir.join(format!("obj-{idx:03}"));
|
||||
fs::create_dir_all(&object_dir).expect("create object directory");
|
||||
let size = 1024 + idx as i64;
|
||||
let file_meta = build_file_meta_with_object(0, size);
|
||||
let bytes = file_meta.marshal_msg().expect("serialize file meta");
|
||||
fs::write(object_dir.join("xl.meta"), bytes).expect("write xl.meta");
|
||||
}
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult { snapshot, state, .. } =
|
||||
scan_disk_blocking(root.to_path_buf(), meta, IncrementalScanState::default()).expect("bulk scan succeeds");
|
||||
|
||||
let bucket_usage = snapshot
|
||||
.buckets_usage
|
||||
.get("bulk")
|
||||
.expect("bucket usage present for bulk scan");
|
||||
assert_eq!(bucket_usage.objects_count as usize, OBJECTS, "should count all objects once");
|
||||
assert!(
|
||||
bucket_usage.size >= (1024 * OBJECTS) as u64,
|
||||
"aggregated size should grow with object count"
|
||||
);
|
||||
assert_eq!(state.objects.len(), OBJECTS, "incremental state tracks every object");
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::scanner::node_scanner::{BucketStats, DiskStats, LocalScanStats};
|
||||
use crate::{Error, Result};
|
||||
use rustfs_common::data_usage::DataUsageInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// local stats manager
|
||||
pub struct LocalStatsManager {
|
||||
/// node id
|
||||
node_id: String,
|
||||
/// stats file path
|
||||
stats_file: PathBuf,
|
||||
/// backup file path
|
||||
backup_file: PathBuf,
|
||||
/// temp file path
|
||||
temp_file: PathBuf,
|
||||
/// local stats data
|
||||
stats: Arc<RwLock<LocalScanStats>>,
|
||||
/// save interval
|
||||
save_interval: Duration,
|
||||
/// last save time
|
||||
last_save: Arc<RwLock<SystemTime>>,
|
||||
/// stats counters
|
||||
counters: Arc<StatsCounters>,
|
||||
}
|
||||
|
||||
/// stats counters
|
||||
pub struct StatsCounters {
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: AtomicU64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: AtomicU64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: AtomicU64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: AtomicU64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: AtomicU64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for StatsCounters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_objects_scanned: AtomicU64::new(0),
|
||||
total_healthy_objects: AtomicU64::new(0),
|
||||
total_corrupted_objects: AtomicU64::new(0),
|
||||
total_bytes_scanned: AtomicU64::new(0),
|
||||
total_scan_errors: AtomicU64::new(0),
|
||||
total_heal_triggered: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scan result entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanResultEntry {
|
||||
/// object path
|
||||
pub object_path: String,
|
||||
/// bucket name
|
||||
pub bucket_name: String,
|
||||
/// object size
|
||||
pub object_size: u64,
|
||||
/// is healthy
|
||||
pub is_healthy: bool,
|
||||
/// error message (if any)
|
||||
pub error_message: Option<String>,
|
||||
/// scan time
|
||||
pub scan_time: SystemTime,
|
||||
/// disk id
|
||||
pub disk_id: String,
|
||||
}
|
||||
|
||||
/// batch scan result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BatchScanResult {
|
||||
/// disk id
|
||||
pub disk_id: String,
|
||||
/// scan result entries
|
||||
pub entries: Vec<ScanResultEntry>,
|
||||
/// scan start time
|
||||
pub scan_start: SystemTime,
|
||||
/// scan end time
|
||||
pub scan_end: SystemTime,
|
||||
/// scan duration
|
||||
pub scan_duration: Duration,
|
||||
}
|
||||
|
||||
impl LocalStatsManager {
|
||||
/// create new local stats manager
|
||||
pub fn new(node_id: &str, data_dir: &Path) -> Self {
|
||||
// ensure data directory exists
|
||||
if !data_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
error!("create stats data directory failed {:?}: {}", data_dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
let stats_file = data_dir.join(format!("scanner_stats_{node_id}.json"));
|
||||
let backup_file = data_dir.join(format!("scanner_stats_{node_id}.backup"));
|
||||
let temp_file = data_dir.join(format!("scanner_stats_{node_id}.tmp"));
|
||||
|
||||
Self {
|
||||
node_id: node_id.to_string(),
|
||||
stats_file,
|
||||
backup_file,
|
||||
temp_file,
|
||||
stats: Arc::new(RwLock::new(LocalScanStats::default())),
|
||||
save_interval: Duration::from_secs(60), // 60 seconds save once
|
||||
last_save: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
counters: Arc::new(StatsCounters::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// load local stats data
|
||||
pub async fn load_stats(&self) -> Result<()> {
|
||||
if !self.stats_file.exists() {
|
||||
info!("stats data file not exists, will create new stats data");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.load_stats_from_file(&self.stats_file).await {
|
||||
Ok(stats) => {
|
||||
*self.stats.write().await = stats;
|
||||
info!("success load local stats data");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("load main stats file failed: {}, try backup file", e);
|
||||
|
||||
match self.load_stats_from_file(&self.backup_file).await {
|
||||
Ok(stats) => {
|
||||
*self.stats.write().await = stats;
|
||||
warn!("restore stats data from backup file");
|
||||
Ok(())
|
||||
}
|
||||
Err(backup_e) => {
|
||||
warn!("backup file also cannot load: {}, will use default stats data", backup_e);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load stats data from file
|
||||
async fn load_stats_from_file(&self, file_path: &Path) -> Result<LocalScanStats> {
|
||||
let content = tokio::fs::read_to_string(file_path)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read stats file failed: {e}")))?;
|
||||
|
||||
let stats: LocalScanStats =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize stats data failed: {e}")))?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// save stats data to disk
|
||||
pub async fn save_stats(&self) -> Result<()> {
|
||||
let now = SystemTime::now();
|
||||
let last_save = *self.last_save.read().await;
|
||||
|
||||
// frequency control
|
||||
if now.duration_since(last_save).unwrap_or(Duration::ZERO) < self.save_interval {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stats = self.stats.read().await.clone();
|
||||
|
||||
// serialize
|
||||
let json_data = serde_json::to_string_pretty(&stats)
|
||||
.map_err(|e| Error::Serialization(format!("serialize stats data failed: {e}")))?;
|
||||
|
||||
// atomic write
|
||||
tokio::fs::write(&self.temp_file, json_data)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("write temp stats file failed: {e}")))?;
|
||||
|
||||
// backup existing file
|
||||
if self.stats_file.exists() {
|
||||
tokio::fs::copy(&self.stats_file, &self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("backup stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// atomic replace
|
||||
tokio::fs::rename(&self.temp_file, &self.stats_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("replace stats file failed: {e}")))?;
|
||||
|
||||
*self.last_save.write().await = now;
|
||||
|
||||
debug!("save local stats data to {:?}", self.stats_file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// force save stats data
|
||||
pub async fn force_save_stats(&self) -> Result<()> {
|
||||
*self.last_save.write().await = SystemTime::UNIX_EPOCH;
|
||||
self.save_stats().await
|
||||
}
|
||||
|
||||
/// update disk scan result
|
||||
pub async fn update_disk_scan_result(&self, result: &BatchScanResult) -> Result<()> {
|
||||
let mut stats = self.stats.write().await;
|
||||
|
||||
// update disk stats
|
||||
let disk_stat = stats.disks_stats.entry(result.disk_id.clone()).or_insert_with(|| DiskStats {
|
||||
disk_id: result.disk_id.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let healthy_count = result.entries.iter().filter(|e| e.is_healthy).count() as u64;
|
||||
let error_count = result.entries.iter().filter(|e| !e.is_healthy).count() as u64;
|
||||
|
||||
disk_stat.objects_scanned += result.entries.len() as u64;
|
||||
disk_stat.errors_count += error_count;
|
||||
disk_stat.last_scan_time = result.scan_end;
|
||||
disk_stat.scan_duration = result.scan_duration;
|
||||
disk_stat.scan_completed = true;
|
||||
|
||||
// update overall stats
|
||||
stats.objects_scanned += result.entries.len() as u64;
|
||||
stats.healthy_objects += healthy_count;
|
||||
stats.corrupted_objects += error_count;
|
||||
stats.last_update = SystemTime::now();
|
||||
|
||||
// update bucket stats
|
||||
for entry in &result.entries {
|
||||
let _bucket_stat = stats
|
||||
.buckets_stats
|
||||
.entry(entry.bucket_name.clone())
|
||||
.or_insert_with(BucketStats::default);
|
||||
|
||||
// TODO: update BucketStats
|
||||
}
|
||||
|
||||
// update atomic counters
|
||||
self.counters
|
||||
.total_objects_scanned
|
||||
.fetch_add(result.entries.len() as u64, Ordering::Relaxed);
|
||||
self.counters
|
||||
.total_healthy_objects
|
||||
.fetch_add(healthy_count, Ordering::Relaxed);
|
||||
self.counters
|
||||
.total_corrupted_objects
|
||||
.fetch_add(error_count, Ordering::Relaxed);
|
||||
|
||||
let total_bytes: u64 = result.entries.iter().map(|e| e.object_size).sum();
|
||||
self.counters.total_bytes_scanned.fetch_add(total_bytes, Ordering::Relaxed);
|
||||
|
||||
if error_count > 0 {
|
||||
self.counters.total_scan_errors.fetch_add(error_count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stats);
|
||||
|
||||
debug!(
|
||||
"update disk {} scan result: objects {}, healthy {}, error {}",
|
||||
result.disk_id,
|
||||
result.entries.len(),
|
||||
healthy_count,
|
||||
error_count
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// record single object scan result
|
||||
pub async fn record_object_scan(&self, entry: ScanResultEntry) -> Result<()> {
|
||||
let result = BatchScanResult {
|
||||
disk_id: entry.disk_id.clone(),
|
||||
entries: vec![entry],
|
||||
scan_start: SystemTime::now(),
|
||||
scan_end: SystemTime::now(),
|
||||
scan_duration: Duration::from_millis(0),
|
||||
};
|
||||
|
||||
self.update_disk_scan_result(&result).await
|
||||
}
|
||||
|
||||
/// get local stats data copy
|
||||
pub async fn get_stats(&self) -> LocalScanStats {
|
||||
self.stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// get real-time counters
|
||||
pub fn get_counters(&self) -> Arc<StatsCounters> {
|
||||
self.counters.clone()
|
||||
}
|
||||
|
||||
/// reset stats data
|
||||
pub async fn reset_stats(&self) -> Result<()> {
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
*stats = LocalScanStats::default();
|
||||
}
|
||||
|
||||
// reset counters
|
||||
self.counters.total_objects_scanned.store(0, Ordering::Relaxed);
|
||||
self.counters.total_healthy_objects.store(0, Ordering::Relaxed);
|
||||
self.counters.total_corrupted_objects.store(0, Ordering::Relaxed);
|
||||
self.counters.total_bytes_scanned.store(0, Ordering::Relaxed);
|
||||
self.counters.total_scan_errors.store(0, Ordering::Relaxed);
|
||||
self.counters.total_heal_triggered.store(0, Ordering::Relaxed);
|
||||
|
||||
info!("reset local stats data");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get stats summary
|
||||
pub async fn get_stats_summary(&self) -> StatsSummary {
|
||||
let stats = self.stats.read().await;
|
||||
|
||||
StatsSummary {
|
||||
node_id: self.node_id.clone(),
|
||||
total_objects_scanned: self.counters.total_objects_scanned.load(Ordering::Relaxed),
|
||||
total_healthy_objects: self.counters.total_healthy_objects.load(Ordering::Relaxed),
|
||||
total_corrupted_objects: self.counters.total_corrupted_objects.load(Ordering::Relaxed),
|
||||
total_bytes_scanned: self.counters.total_bytes_scanned.load(Ordering::Relaxed),
|
||||
total_scan_errors: self.counters.total_scan_errors.load(Ordering::Relaxed),
|
||||
total_heal_triggered: self.counters.total_heal_triggered.load(Ordering::Relaxed),
|
||||
total_disks: stats.disks_stats.len(),
|
||||
total_buckets: stats.buckets_stats.len(),
|
||||
last_update: stats.last_update,
|
||||
scan_progress: stats.scan_progress.clone(),
|
||||
data_usage: stats.data_usage.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// record heal triggered
|
||||
pub async fn record_heal_triggered(&self, object_path: &str, error_message: &str) {
|
||||
self.counters.total_heal_triggered.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
info!("record heal triggered: object={}, error={}", object_path, error_message);
|
||||
}
|
||||
|
||||
/// update data usage stats
|
||||
pub async fn update_data_usage(&self, data_usage: DataUsageInfo) {
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.data_usage = data_usage;
|
||||
stats.last_update = SystemTime::now();
|
||||
|
||||
debug!("update data usage stats");
|
||||
}
|
||||
|
||||
/// cleanup stats files
|
||||
pub async fn cleanup_stats_files(&self) -> Result<()> {
|
||||
// delete main file
|
||||
if self.stats_file.exists() {
|
||||
tokio::fs::remove_file(&self.stats_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete backup file
|
||||
if self.backup_file.exists() {
|
||||
tokio::fs::remove_file(&self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete backup stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete temp file
|
||||
if self.temp_file.exists() {
|
||||
tokio::fs::remove_file(&self.temp_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete temp stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
info!("cleanup all stats files");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// set save interval
|
||||
pub fn set_save_interval(&mut self, interval: Duration) {
|
||||
self.save_interval = interval;
|
||||
info!("set stats data save interval to {:?}", interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// stats summary
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatsSummary {
|
||||
/// node id
|
||||
pub node_id: String,
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: u64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: u64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: u64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: u64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: u64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: u64,
|
||||
/// total disks
|
||||
pub total_disks: usize,
|
||||
/// total buckets
|
||||
pub total_buckets: usize,
|
||||
/// last update time
|
||||
pub last_update: SystemTime,
|
||||
/// scan progress
|
||||
pub scan_progress: super::node_scanner::ScanProgress,
|
||||
/// data usage snapshot for the node
|
||||
pub data_usage: DataUsageInfo,
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
/// Scanner metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScannerMetrics {
|
||||
/// Total objects scanned since server start
|
||||
pub objects_scanned: u64,
|
||||
/// Total object versions scanned since server start
|
||||
pub versions_scanned: u64,
|
||||
/// Total directories scanned since server start
|
||||
pub directories_scanned: u64,
|
||||
/// Total bucket scans started since server start
|
||||
pub bucket_scans_started: u64,
|
||||
/// Total bucket scans finished since server start
|
||||
pub bucket_scans_finished: u64,
|
||||
/// Total objects with health issues found
|
||||
pub objects_with_issues: u64,
|
||||
/// Total heal tasks queued
|
||||
pub heal_tasks_queued: u64,
|
||||
/// Total heal tasks completed
|
||||
pub heal_tasks_completed: u64,
|
||||
/// Total heal tasks failed
|
||||
pub heal_tasks_failed: u64,
|
||||
/// Total healthy objects found
|
||||
pub healthy_objects: u64,
|
||||
/// Total corrupted objects found
|
||||
pub corrupted_objects: u64,
|
||||
/// Last scan activity time
|
||||
pub last_activity: Option<SystemTime>,
|
||||
/// Current scan cycle
|
||||
pub current_cycle: u64,
|
||||
/// Total scan cycles completed
|
||||
pub total_cycles: u64,
|
||||
/// Current scan duration
|
||||
pub current_scan_duration: Option<Duration>,
|
||||
/// Average scan duration
|
||||
pub avg_scan_duration: Duration,
|
||||
/// Objects scanned per second
|
||||
pub objects_per_second: f64,
|
||||
/// Buckets scanned per second
|
||||
pub buckets_per_second: f64,
|
||||
/// Storage metrics by bucket
|
||||
pub bucket_metrics: HashMap<String, BucketMetrics>,
|
||||
/// Disk metrics
|
||||
pub disk_metrics: HashMap<String, DiskMetrics>,
|
||||
}
|
||||
|
||||
/// Bucket-specific metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BucketMetrics {
|
||||
/// Bucket name
|
||||
pub bucket: String,
|
||||
/// Total objects in bucket
|
||||
pub total_objects: u64,
|
||||
/// Total size of objects in bucket (bytes)
|
||||
pub total_size: u64,
|
||||
/// Objects with health issues
|
||||
pub objects_with_issues: u64,
|
||||
/// Last scan time
|
||||
pub last_scan_time: Option<SystemTime>,
|
||||
/// Scan duration
|
||||
pub scan_duration: Option<Duration>,
|
||||
/// Heal tasks queued for this bucket
|
||||
pub heal_tasks_queued: u64,
|
||||
/// Heal tasks completed for this bucket
|
||||
pub heal_tasks_completed: u64,
|
||||
/// Heal tasks failed for this bucket
|
||||
pub heal_tasks_failed: u64,
|
||||
}
|
||||
|
||||
/// Disk-specific metrics
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DiskMetrics {
|
||||
/// Disk path
|
||||
pub disk_path: String,
|
||||
/// Total disk space (bytes)
|
||||
pub total_space: u64,
|
||||
/// Used disk space (bytes)
|
||||
pub used_space: u64,
|
||||
/// Free disk space (bytes)
|
||||
pub free_space: u64,
|
||||
/// Objects scanned on this disk
|
||||
pub objects_scanned: u64,
|
||||
/// Objects with issues on this disk
|
||||
pub objects_with_issues: u64,
|
||||
/// Last scan time
|
||||
pub last_scan_time: Option<SystemTime>,
|
||||
/// Whether disk is online
|
||||
pub is_online: bool,
|
||||
/// Whether disk is being scanned
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
/// Thread-safe metrics collector
|
||||
pub struct MetricsCollector {
|
||||
/// Atomic counters for real-time metrics
|
||||
objects_scanned: AtomicU64,
|
||||
versions_scanned: AtomicU64,
|
||||
directories_scanned: AtomicU64,
|
||||
bucket_scans_started: AtomicU64,
|
||||
bucket_scans_finished: AtomicU64,
|
||||
objects_with_issues: AtomicU64,
|
||||
heal_tasks_queued: AtomicU64,
|
||||
heal_tasks_completed: AtomicU64,
|
||||
heal_tasks_failed: AtomicU64,
|
||||
current_cycle: AtomicU64,
|
||||
total_cycles: AtomicU64,
|
||||
healthy_objects: AtomicU64,
|
||||
corrupted_objects: AtomicU64,
|
||||
}
|
||||
|
||||
impl MetricsCollector {
|
||||
/// Create a new metrics collector
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
objects_scanned: AtomicU64::new(0),
|
||||
versions_scanned: AtomicU64::new(0),
|
||||
directories_scanned: AtomicU64::new(0),
|
||||
bucket_scans_started: AtomicU64::new(0),
|
||||
bucket_scans_finished: AtomicU64::new(0),
|
||||
objects_with_issues: AtomicU64::new(0),
|
||||
heal_tasks_queued: AtomicU64::new(0),
|
||||
heal_tasks_completed: AtomicU64::new(0),
|
||||
heal_tasks_failed: AtomicU64::new(0),
|
||||
current_cycle: AtomicU64::new(0),
|
||||
total_cycles: AtomicU64::new(0),
|
||||
healthy_objects: AtomicU64::new(0),
|
||||
corrupted_objects: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment objects scanned count
|
||||
pub fn increment_objects_scanned(&self, count: u64) {
|
||||
self.objects_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment versions scanned count
|
||||
pub fn increment_versions_scanned(&self, count: u64) {
|
||||
self.versions_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment directories scanned count
|
||||
pub fn increment_directories_scanned(&self, count: u64) {
|
||||
self.directories_scanned.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment bucket scans started count
|
||||
pub fn increment_bucket_scans_started(&self, count: u64) {
|
||||
self.bucket_scans_started.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment bucket scans finished count
|
||||
pub fn increment_bucket_scans_finished(&self, count: u64) {
|
||||
self.bucket_scans_finished.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment objects with issues count
|
||||
pub fn increment_objects_with_issues(&self, count: u64) {
|
||||
self.objects_with_issues.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks queued count
|
||||
pub fn increment_heal_tasks_queued(&self, count: u64) {
|
||||
self.heal_tasks_queued.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks completed count
|
||||
pub fn increment_heal_tasks_completed(&self, count: u64) {
|
||||
self.heal_tasks_completed.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment heal tasks failed count
|
||||
pub fn increment_heal_tasks_failed(&self, count: u64) {
|
||||
self.heal_tasks_failed.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set current cycle
|
||||
pub fn set_current_cycle(&self, cycle: u64) {
|
||||
self.current_cycle.store(cycle, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment total cycles
|
||||
pub fn increment_total_cycles(&self) {
|
||||
self.total_cycles.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment healthy objects count
|
||||
pub fn increment_healthy_objects(&self) {
|
||||
self.healthy_objects.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment corrupted objects count
|
||||
pub fn increment_corrupted_objects(&self) {
|
||||
self.corrupted_objects.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get current metrics snapshot
|
||||
pub fn get_metrics(&self) -> ScannerMetrics {
|
||||
ScannerMetrics {
|
||||
objects_scanned: self.objects_scanned.load(Ordering::Relaxed),
|
||||
versions_scanned: self.versions_scanned.load(Ordering::Relaxed),
|
||||
directories_scanned: self.directories_scanned.load(Ordering::Relaxed),
|
||||
bucket_scans_started: self.bucket_scans_started.load(Ordering::Relaxed),
|
||||
bucket_scans_finished: self.bucket_scans_finished.load(Ordering::Relaxed),
|
||||
objects_with_issues: self.objects_with_issues.load(Ordering::Relaxed),
|
||||
heal_tasks_queued: self.heal_tasks_queued.load(Ordering::Relaxed),
|
||||
heal_tasks_completed: self.heal_tasks_completed.load(Ordering::Relaxed),
|
||||
heal_tasks_failed: self.heal_tasks_failed.load(Ordering::Relaxed),
|
||||
healthy_objects: self.healthy_objects.load(Ordering::Relaxed),
|
||||
corrupted_objects: self.corrupted_objects.load(Ordering::Relaxed),
|
||||
last_activity: Some(SystemTime::now()),
|
||||
current_cycle: self.current_cycle.load(Ordering::Relaxed),
|
||||
total_cycles: self.total_cycles.load(Ordering::Relaxed),
|
||||
current_scan_duration: None, // Will be set by scanner
|
||||
avg_scan_duration: Duration::ZERO, // Will be calculated
|
||||
objects_per_second: 0.0, // Will be calculated
|
||||
buckets_per_second: 0.0, // Will be calculated
|
||||
bucket_metrics: HashMap::new(), // Will be populated by scanner
|
||||
disk_metrics: HashMap::new(), // Will be populated by scanner
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all metrics
|
||||
pub fn reset(&self) {
|
||||
self.objects_scanned.store(0, Ordering::Relaxed);
|
||||
self.versions_scanned.store(0, Ordering::Relaxed);
|
||||
self.directories_scanned.store(0, Ordering::Relaxed);
|
||||
self.bucket_scans_started.store(0, Ordering::Relaxed);
|
||||
self.bucket_scans_finished.store(0, Ordering::Relaxed);
|
||||
self.objects_with_issues.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_queued.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_completed.store(0, Ordering::Relaxed);
|
||||
self.heal_tasks_failed.store(0, Ordering::Relaxed);
|
||||
self.current_cycle.store(0, Ordering::Relaxed);
|
||||
self.total_cycles.store(0, Ordering::Relaxed);
|
||||
self.healthy_objects.store(0, Ordering::Relaxed);
|
||||
self.corrupted_objects.store(0, Ordering::Relaxed);
|
||||
|
||||
info!("Scanner metrics reset");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetricsCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_metrics_collector_creation() {
|
||||
let collector = MetricsCollector::new();
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 0);
|
||||
assert_eq!(metrics.versions_scanned, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_increment() {
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
collector.increment_objects_scanned(10);
|
||||
collector.increment_versions_scanned(5);
|
||||
collector.increment_objects_with_issues(2);
|
||||
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 10);
|
||||
assert_eq!(metrics.versions_scanned, 5);
|
||||
assert_eq!(metrics.objects_with_issues, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_reset() {
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
collector.increment_objects_scanned(10);
|
||||
collector.reset();
|
||||
|
||||
let metrics = collector.get_metrics();
|
||||
assert_eq!(metrics.objects_scanned, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod data_scanner;
|
||||
pub mod histogram;
|
||||
pub mod io_monitor;
|
||||
pub mod io_throttler;
|
||||
pub mod lifecycle;
|
||||
pub mod local_scan;
|
||||
pub mod local_stats;
|
||||
pub mod metrics;
|
||||
pub mod node_scanner;
|
||||
pub mod stats_aggregator;
|
||||
|
||||
pub use checkpoint::{CheckpointData, CheckpointInfo, CheckpointManager};
|
||||
pub use data_scanner::{ScanMode, Scanner, ScannerConfig, ScannerState};
|
||||
pub use io_monitor::{AdvancedIOMonitor, IOMetrics, IOMonitorConfig};
|
||||
pub use io_throttler::{AdvancedIOThrottler, IOThrottlerConfig, MetricsSnapshot, ResourceAllocation, ThrottleDecision};
|
||||
pub use local_stats::{BatchScanResult, LocalStatsManager, ScanResultEntry, StatsSummary};
|
||||
pub use metrics::{BucketMetrics, DiskMetrics, MetricsCollector, ScannerMetrics};
|
||||
pub use node_scanner::{IOMonitor, IOThrottler, LoadLevel, LocalScanStats, NodeScanner, NodeScannerConfig};
|
||||
pub use stats_aggregator::{
|
||||
AggregatedStats, DecentralizedStatsAggregator, DecentralizedStatsAggregatorConfig, NodeClient, NodeInfo,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,771 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::scanner::{
|
||||
local_stats::StatsSummary,
|
||||
node_scanner::{BucketStats, LoadLevel, ScanProgress},
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use rustfs_common::data_usage::DataUsageInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// node client config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeClientConfig {
|
||||
/// connect timeout
|
||||
pub connect_timeout: Duration,
|
||||
/// request timeout
|
||||
pub request_timeout: Duration,
|
||||
/// retry times
|
||||
pub max_retries: u32,
|
||||
/// retry interval
|
||||
pub retry_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for NodeClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
request_timeout: Duration::from_secs(10),
|
||||
max_retries: 3,
|
||||
retry_interval: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// node info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
/// node id
|
||||
pub node_id: String,
|
||||
/// node address
|
||||
pub address: String,
|
||||
/// node port
|
||||
pub port: u16,
|
||||
/// is online
|
||||
pub is_online: bool,
|
||||
/// last heartbeat time
|
||||
pub last_heartbeat: SystemTime,
|
||||
/// node version
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// aggregated stats
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AggregatedStats {
|
||||
/// aggregation timestamp
|
||||
pub aggregation_timestamp: SystemTime,
|
||||
/// number of nodes participating in aggregation
|
||||
pub node_count: usize,
|
||||
/// number of online nodes
|
||||
pub online_node_count: usize,
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: u64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: u64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: u64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: u64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: u64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: u64,
|
||||
/// total disks
|
||||
pub total_disks: usize,
|
||||
/// total buckets
|
||||
pub total_buckets: usize,
|
||||
/// aggregated data usage
|
||||
pub aggregated_data_usage: DataUsageInfo,
|
||||
/// node summaries
|
||||
pub node_summaries: HashMap<String, StatsSummary>,
|
||||
/// aggregated bucket stats
|
||||
pub aggregated_bucket_stats: HashMap<String, BucketStats>,
|
||||
/// aggregated scan progress
|
||||
pub scan_progress_summary: ScanProgressSummary,
|
||||
/// load level distribution
|
||||
pub load_level_distribution: HashMap<LoadLevel, usize>,
|
||||
}
|
||||
|
||||
impl Default for AggregatedStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
aggregation_timestamp: SystemTime::now(),
|
||||
node_count: 0,
|
||||
online_node_count: 0,
|
||||
total_objects_scanned: 0,
|
||||
total_healthy_objects: 0,
|
||||
total_corrupted_objects: 0,
|
||||
total_bytes_scanned: 0,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 0,
|
||||
total_buckets: 0,
|
||||
aggregated_data_usage: DataUsageInfo::default(),
|
||||
node_summaries: HashMap::new(),
|
||||
aggregated_bucket_stats: HashMap::new(),
|
||||
scan_progress_summary: ScanProgressSummary::default(),
|
||||
load_level_distribution: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scan progress summary
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScanProgressSummary {
|
||||
/// average current cycle
|
||||
pub average_current_cycle: f64,
|
||||
/// total completed disks
|
||||
pub total_completed_disks: usize,
|
||||
/// total completed buckets
|
||||
pub total_completed_buckets: usize,
|
||||
/// latest scan start time
|
||||
pub earliest_scan_start: Option<SystemTime>,
|
||||
/// estimated completion time
|
||||
pub estimated_completion: Option<SystemTime>,
|
||||
/// node progress
|
||||
pub node_progress: HashMap<String, ScanProgress>,
|
||||
}
|
||||
|
||||
/// node client
|
||||
///
|
||||
/// responsible for communicating with other nodes, getting stats data
|
||||
pub struct NodeClient {
|
||||
/// node info
|
||||
node_info: NodeInfo,
|
||||
/// config
|
||||
config: NodeClientConfig,
|
||||
/// HTTP client
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl NodeClient {
|
||||
/// create new node client
|
||||
pub fn new(node_info: NodeInfo, config: NodeClientConfig) -> Self {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(config.request_timeout)
|
||||
.connect_timeout(config.connect_timeout)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
node_info,
|
||||
config,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// get node stats summary
|
||||
pub async fn get_stats_summary(&self) -> Result<StatsSummary> {
|
||||
let url = format!("http://{}:{}/internal/scanner/stats", self.node_info.address, self.node_info.port);
|
||||
|
||||
for attempt in 1..=self.config.max_retries {
|
||||
match self.try_get_stats_summary(&url).await {
|
||||
Ok(summary) => return Ok(summary),
|
||||
Err(e) => {
|
||||
warn!("try to get node {} stats failed: {}", self.node_info.node_id, e);
|
||||
|
||||
if attempt < self.config.max_retries {
|
||||
tokio::time::sleep(self.config.retry_interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::Other(format!("cannot get stats data from node {}", self.node_info.node_id)))
|
||||
}
|
||||
|
||||
/// try to get stats summary
|
||||
async fn try_get_stats_summary(&self, url: &str) -> Result<StatsSummary> {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::Other(format!("HTTP request failed: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::Other(format!("HTTP status error: {}", response.status())));
|
||||
}
|
||||
|
||||
let summary = response
|
||||
.json::<StatsSummary>()
|
||||
.await
|
||||
.map_err(|e| Error::Serialization(format!("deserialize stats data failed: {e}")))?;
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// check node health status
|
||||
pub async fn check_health(&self) -> bool {
|
||||
let url = format!("http://{}:{}/internal/health", self.node_info.address, self.node_info.port);
|
||||
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// get node info
|
||||
pub fn get_node_info(&self) -> &NodeInfo {
|
||||
&self.node_info
|
||||
}
|
||||
|
||||
/// update node online status
|
||||
pub fn update_online_status(&mut self, is_online: bool) {
|
||||
self.node_info.is_online = is_online;
|
||||
if is_online {
|
||||
self.node_info.last_heartbeat = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// decentralized stats aggregator config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecentralizedStatsAggregatorConfig {
|
||||
/// aggregation interval
|
||||
pub aggregation_interval: Duration,
|
||||
/// cache ttl
|
||||
pub cache_ttl: Duration,
|
||||
/// node timeout
|
||||
pub node_timeout: Duration,
|
||||
/// max concurrent aggregations
|
||||
pub max_concurrent_aggregations: usize,
|
||||
}
|
||||
|
||||
impl Default for DecentralizedStatsAggregatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
aggregation_interval: Duration::from_secs(30), // 30 seconds to aggregate
|
||||
cache_ttl: Duration::from_secs(3), // 3 seconds to cache
|
||||
node_timeout: Duration::from_secs(5), // 5 seconds to node timeout
|
||||
max_concurrent_aggregations: 10, // max 10 nodes to aggregate concurrently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// decentralized stats aggregator
|
||||
///
|
||||
/// real-time aggregate stats data from all nodes, provide global view
|
||||
pub struct DecentralizedStatsAggregator {
|
||||
/// config
|
||||
config: Arc<RwLock<DecentralizedStatsAggregatorConfig>>,
|
||||
/// node clients
|
||||
node_clients: Arc<RwLock<HashMap<String, Arc<NodeClient>>>>,
|
||||
/// cached aggregated stats
|
||||
cached_stats: Arc<RwLock<Option<AggregatedStats>>>,
|
||||
/// cache timestamp
|
||||
cache_timestamp: Arc<RwLock<SystemTime>>,
|
||||
/// local node stats summary
|
||||
local_stats_summary: Arc<RwLock<Option<StatsSummary>>>,
|
||||
}
|
||||
|
||||
impl DecentralizedStatsAggregator {
|
||||
/// create new decentralized stats aggregator
|
||||
pub fn new(config: DecentralizedStatsAggregatorConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
node_clients: Arc::new(RwLock::new(HashMap::new())),
|
||||
cached_stats: Arc::new(RwLock::new(None)),
|
||||
cache_timestamp: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
local_stats_summary: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// add node client
|
||||
pub async fn add_node(&self, node_info: NodeInfo) {
|
||||
let client_config = NodeClientConfig::default();
|
||||
let client = Arc::new(NodeClient::new(node_info.clone(), client_config));
|
||||
|
||||
self.node_clients.write().await.insert(node_info.node_id.clone(), client);
|
||||
|
||||
info!("add node to aggregator: {}", node_info.node_id);
|
||||
}
|
||||
|
||||
/// remove node client
|
||||
pub async fn remove_node(&self, node_id: &str) {
|
||||
self.node_clients.write().await.remove(node_id);
|
||||
info!("remove node from aggregator: {}", node_id);
|
||||
}
|
||||
|
||||
/// set local node stats summary
|
||||
pub async fn set_local_stats(&self, stats: StatsSummary) {
|
||||
*self.local_stats_summary.write().await = Some(stats);
|
||||
}
|
||||
|
||||
/// get aggregated stats data (with cache)
|
||||
pub async fn get_aggregated_stats(&self) -> Result<AggregatedStats> {
|
||||
let config = self.config.read().await;
|
||||
let cache_ttl = config.cache_ttl;
|
||||
drop(config);
|
||||
|
||||
// check cache validity
|
||||
let cache_timestamp = *self.cache_timestamp.read().await;
|
||||
let now = SystemTime::now();
|
||||
|
||||
debug!(
|
||||
"cache check: cache_timestamp={:?}, now={:?}, cache_ttl={:?}",
|
||||
cache_timestamp, now, cache_ttl
|
||||
);
|
||||
|
||||
// Check cache validity if timestamp is not initial value (UNIX_EPOCH)
|
||||
if cache_timestamp != SystemTime::UNIX_EPOCH {
|
||||
if let Ok(elapsed) = now.duration_since(cache_timestamp) {
|
||||
if elapsed < cache_ttl {
|
||||
if let Some(cached) = self.cached_stats.read().await.as_ref() {
|
||||
debug!("Returning cached aggregated stats, remaining TTL: {:?}", cache_ttl - elapsed);
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
} else {
|
||||
debug!("Cache expired: elapsed={:?} >= ttl={:?}", elapsed, cache_ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cache expired, re-aggregate
|
||||
info!("cache expired, start re-aggregating stats data");
|
||||
let aggregation_timestamp = now;
|
||||
let aggregated = self.aggregate_stats_from_all_nodes(aggregation_timestamp).await?;
|
||||
|
||||
// update cache
|
||||
*self.cached_stats.write().await = Some(aggregated.clone());
|
||||
// Use the time when aggregation completes as cache timestamp to avoid premature expiry during long runs
|
||||
*self.cache_timestamp.write().await = SystemTime::now();
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// force refresh aggregated stats (ignore cache)
|
||||
pub async fn force_refresh_aggregated_stats(&self) -> Result<AggregatedStats> {
|
||||
let now = SystemTime::now();
|
||||
let aggregated = self.aggregate_stats_from_all_nodes(now).await?;
|
||||
|
||||
// update cache
|
||||
*self.cached_stats.write().await = Some(aggregated.clone());
|
||||
// Cache timestamp should reflect completion time rather than aggregation start
|
||||
*self.cache_timestamp.write().await = SystemTime::now();
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// aggregate stats data from all nodes
|
||||
async fn aggregate_stats_from_all_nodes(&self, aggregation_timestamp: SystemTime) -> Result<AggregatedStats> {
|
||||
let node_clients = self.node_clients.read().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
// concurrent get stats data from all nodes
|
||||
let mut tasks = Vec::new();
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_aggregations));
|
||||
|
||||
// add local node stats
|
||||
let mut node_summaries = HashMap::new();
|
||||
if let Some(local_stats) = self.local_stats_summary.read().await.as_ref() {
|
||||
node_summaries.insert(local_stats.node_id.clone(), local_stats.clone());
|
||||
}
|
||||
|
||||
// get remote node stats
|
||||
for (node_id, client) in node_clients.iter() {
|
||||
let client = client.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let node_id = node_id.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let _permit = match semaphore.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
Err(e) => {
|
||||
warn!("Failed to acquire semaphore for node {}: {}", node_id, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get_stats_summary().await {
|
||||
Ok(summary) => {
|
||||
debug!("successfully get node {} stats data", node_id);
|
||||
Some((node_id, summary))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("get node {} stats data failed: {}", node_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// wait for all tasks to complete
|
||||
for task in tasks {
|
||||
if let Ok(Some((node_id, summary))) = task.await {
|
||||
node_summaries.insert(node_id, summary);
|
||||
}
|
||||
}
|
||||
|
||||
drop(node_clients);
|
||||
drop(config);
|
||||
|
||||
// aggregate stats data
|
||||
let aggregated = self.aggregate_node_summaries(node_summaries, aggregation_timestamp).await;
|
||||
|
||||
info!(
|
||||
"aggregate stats completed: {} nodes, {} online",
|
||||
aggregated.node_count, aggregated.online_node_count
|
||||
);
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// aggregate node summaries
|
||||
async fn aggregate_node_summaries(
|
||||
&self,
|
||||
node_summaries: HashMap<String, StatsSummary>,
|
||||
aggregation_timestamp: SystemTime,
|
||||
) -> AggregatedStats {
|
||||
let mut aggregated = AggregatedStats {
|
||||
aggregation_timestamp,
|
||||
node_count: node_summaries.len(),
|
||||
online_node_count: node_summaries.len(), // assume all nodes with data are online
|
||||
node_summaries: node_summaries.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// aggregate numeric stats
|
||||
for (node_id, summary) in &node_summaries {
|
||||
aggregated.total_objects_scanned += summary.total_objects_scanned;
|
||||
aggregated.total_healthy_objects += summary.total_healthy_objects;
|
||||
aggregated.total_corrupted_objects += summary.total_corrupted_objects;
|
||||
aggregated.total_bytes_scanned += summary.total_bytes_scanned;
|
||||
aggregated.total_scan_errors += summary.total_scan_errors;
|
||||
aggregated.total_heal_triggered += summary.total_heal_triggered;
|
||||
aggregated.total_disks += summary.total_disks;
|
||||
aggregated.total_buckets += summary.total_buckets;
|
||||
aggregated.aggregated_data_usage.merge(&summary.data_usage);
|
||||
|
||||
// aggregate scan progress
|
||||
aggregated
|
||||
.scan_progress_summary
|
||||
.node_progress
|
||||
.insert(node_id.clone(), summary.scan_progress.clone());
|
||||
|
||||
aggregated.scan_progress_summary.total_completed_disks += summary.scan_progress.completed_disks.len();
|
||||
aggregated.scan_progress_summary.total_completed_buckets += summary.scan_progress.completed_buckets.len();
|
||||
}
|
||||
|
||||
// calculate average scan cycle
|
||||
if !node_summaries.is_empty() {
|
||||
let total_cycles: u64 = node_summaries.values().map(|s| s.scan_progress.current_cycle).sum();
|
||||
aggregated.scan_progress_summary.average_current_cycle = total_cycles as f64 / node_summaries.len() as f64;
|
||||
}
|
||||
|
||||
// find earliest scan start time
|
||||
aggregated.scan_progress_summary.earliest_scan_start =
|
||||
node_summaries.values().map(|s| s.scan_progress.scan_start_time).min();
|
||||
|
||||
// TODO: aggregate bucket stats and data usage
|
||||
// here we need to implement it based on the specific BucketStats and DataUsageInfo structure
|
||||
|
||||
aggregated
|
||||
}
|
||||
|
||||
/// get nodes health status
|
||||
pub async fn get_nodes_health(&self) -> HashMap<String, bool> {
|
||||
let node_clients = self.node_clients.read().await;
|
||||
let mut health_status = HashMap::new();
|
||||
|
||||
// concurrent check all nodes health status
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for (node_id, client) in node_clients.iter() {
|
||||
let client = client.clone();
|
||||
let node_id = node_id.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let is_healthy = client.check_health().await;
|
||||
(node_id, is_healthy)
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// collect results
|
||||
for task in tasks {
|
||||
if let Ok((node_id, is_healthy)) = task.await {
|
||||
health_status.insert(node_id, is_healthy);
|
||||
}
|
||||
}
|
||||
|
||||
health_status
|
||||
}
|
||||
|
||||
/// get online nodes list
|
||||
pub async fn get_online_nodes(&self) -> Vec<String> {
|
||||
let health_status = self.get_nodes_health().await;
|
||||
|
||||
health_status
|
||||
.into_iter()
|
||||
.filter_map(|(node_id, is_healthy)| if is_healthy { Some(node_id) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// clear cache
|
||||
pub async fn clear_cache(&self) {
|
||||
*self.cached_stats.write().await = None;
|
||||
*self.cache_timestamp.write().await = SystemTime::UNIX_EPOCH;
|
||||
info!("clear aggregated stats cache");
|
||||
}
|
||||
|
||||
/// get cache status
|
||||
pub async fn get_cache_status(&self) -> CacheStatus {
|
||||
let cached_stats = self.cached_stats.read().await;
|
||||
let cache_timestamp = *self.cache_timestamp.read().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
let is_valid = if let Ok(elapsed) = SystemTime::now().duration_since(cache_timestamp) {
|
||||
elapsed < config.cache_ttl
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
CacheStatus {
|
||||
has_cached_data: cached_stats.is_some(),
|
||||
cache_timestamp,
|
||||
is_valid,
|
||||
ttl: config.cache_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// update config
|
||||
pub async fn update_config(&self, new_config: DecentralizedStatsAggregatorConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
info!("update aggregator config");
|
||||
}
|
||||
}
|
||||
|
||||
/// cache status
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheStatus {
|
||||
/// has cached data
|
||||
pub has_cached_data: bool,
|
||||
/// cache timestamp
|
||||
pub cache_timestamp: SystemTime,
|
||||
/// cache is valid
|
||||
pub is_valid: bool,
|
||||
/// cache ttl
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scanner::node_scanner::{BucketScanState, ScanProgress};
|
||||
use rustfs_common::data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregated_stats_merge_data_usage() {
|
||||
let aggregator = DecentralizedStatsAggregator::new(DecentralizedStatsAggregatorConfig::default());
|
||||
|
||||
let mut data_usage = DataUsageInfo::default();
|
||||
let bucket_usage = BucketUsageInfo {
|
||||
objects_count: 5,
|
||||
size: 1024,
|
||||
..Default::default()
|
||||
};
|
||||
data_usage.buckets_usage.insert("bucket".to_string(), bucket_usage);
|
||||
data_usage.objects_total_count = 5;
|
||||
data_usage.objects_total_size = 1024;
|
||||
|
||||
let summary = StatsSummary {
|
||||
node_id: "local-node".to_string(),
|
||||
total_objects_scanned: 10,
|
||||
total_healthy_objects: 9,
|
||||
total_corrupted_objects: 1,
|
||||
total_bytes_scanned: 2048,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 2,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: ScanProgress::default(),
|
||||
data_usage: data_usage.clone(),
|
||||
};
|
||||
|
||||
aggregator.set_local_stats(summary).await;
|
||||
|
||||
// Wait briefly to ensure async cache writes settle in high-concurrency environments
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let aggregated = aggregator.get_aggregated_stats().await.expect("aggregated stats");
|
||||
|
||||
assert_eq!(aggregated.node_count, 1);
|
||||
assert!(aggregated.node_summaries.contains_key("local-node"));
|
||||
assert_eq!(aggregated.aggregated_data_usage.objects_total_count, 5);
|
||||
assert_eq!(
|
||||
aggregated
|
||||
.aggregated_data_usage
|
||||
.buckets_usage
|
||||
.get("bucket")
|
||||
.expect("bucket usage present")
|
||||
.objects_count,
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregated_stats_merge_multiple_nodes() {
|
||||
let aggregator = DecentralizedStatsAggregator::new(DecentralizedStatsAggregatorConfig::default());
|
||||
|
||||
let mut local_usage = DataUsageInfo::default();
|
||||
let local_bucket = BucketUsageInfo {
|
||||
objects_count: 3,
|
||||
versions_count: 3,
|
||||
size: 150,
|
||||
..Default::default()
|
||||
};
|
||||
local_usage.buckets_usage.insert("local-bucket".to_string(), local_bucket);
|
||||
local_usage.calculate_totals();
|
||||
local_usage.buckets_count = local_usage.buckets_usage.len() as u64;
|
||||
local_usage.last_update = Some(SystemTime::now());
|
||||
|
||||
let local_progress = ScanProgress {
|
||||
current_cycle: 1,
|
||||
completed_disks: {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert("disk-local".to_string());
|
||||
set
|
||||
},
|
||||
completed_buckets: {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(
|
||||
"local-bucket".to_string(),
|
||||
BucketScanState {
|
||||
completed: true,
|
||||
last_object_key: Some("obj1".to_string()),
|
||||
objects_scanned: 3,
|
||||
scan_timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let local_summary = StatsSummary {
|
||||
node_id: "node-local".to_string(),
|
||||
total_objects_scanned: 30,
|
||||
total_healthy_objects: 30,
|
||||
total_corrupted_objects: 0,
|
||||
total_bytes_scanned: 1500,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 1,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: local_progress,
|
||||
data_usage: local_usage.clone(),
|
||||
};
|
||||
|
||||
let mut remote_usage = DataUsageInfo::default();
|
||||
let remote_bucket = BucketUsageInfo {
|
||||
objects_count: 5,
|
||||
versions_count: 5,
|
||||
size: 250,
|
||||
..Default::default()
|
||||
};
|
||||
remote_usage.buckets_usage.insert("remote-bucket".to_string(), remote_bucket);
|
||||
remote_usage.calculate_totals();
|
||||
remote_usage.buckets_count = remote_usage.buckets_usage.len() as u64;
|
||||
remote_usage.last_update = Some(SystemTime::now());
|
||||
|
||||
let remote_progress = ScanProgress {
|
||||
current_cycle: 2,
|
||||
completed_disks: {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert("disk-remote".to_string());
|
||||
set
|
||||
},
|
||||
completed_buckets: {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(
|
||||
"remote-bucket".to_string(),
|
||||
BucketScanState {
|
||||
completed: true,
|
||||
last_object_key: Some("remote-obj".to_string()),
|
||||
objects_scanned: 5,
|
||||
scan_timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let remote_summary = StatsSummary {
|
||||
node_id: "node-remote".to_string(),
|
||||
total_objects_scanned: 50,
|
||||
total_healthy_objects: 48,
|
||||
total_corrupted_objects: 2,
|
||||
total_bytes_scanned: 2048,
|
||||
total_scan_errors: 1,
|
||||
total_heal_triggered: 1,
|
||||
total_disks: 2,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: remote_progress,
|
||||
data_usage: remote_usage.clone(),
|
||||
};
|
||||
let node_summaries: HashMap<_, _> = [
|
||||
(local_summary.node_id.clone(), local_summary.clone()),
|
||||
(remote_summary.node_id.clone(), remote_summary.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let aggregated = aggregator.aggregate_node_summaries(node_summaries, SystemTime::now()).await;
|
||||
|
||||
assert_eq!(aggregated.node_count, 2);
|
||||
assert_eq!(aggregated.total_objects_scanned, 80);
|
||||
assert_eq!(aggregated.total_corrupted_objects, 2);
|
||||
assert_eq!(aggregated.total_disks, 3);
|
||||
assert!(aggregated.node_summaries.contains_key("node-local"));
|
||||
assert!(aggregated.node_summaries.contains_key("node-remote"));
|
||||
|
||||
assert_eq!(
|
||||
aggregated.aggregated_data_usage.objects_total_count,
|
||||
local_usage.objects_total_count + remote_usage.objects_total_count
|
||||
);
|
||||
assert_eq!(
|
||||
aggregated.aggregated_data_usage.objects_total_size,
|
||||
local_usage.objects_total_size + remote_usage.objects_total_size
|
||||
);
|
||||
|
||||
let mut expected_buckets: HashSet<&str> = HashSet::new();
|
||||
expected_buckets.insert("local-bucket");
|
||||
expected_buckets.insert("remote-bucket");
|
||||
let actual_buckets: HashSet<&str> = aggregated
|
||||
.aggregated_data_usage
|
||||
.buckets_usage
|
||||
.keys()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
assert_eq!(expected_buckets, actual_buckets);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use rustfs_ahm::scanner::data_scanner::Scanner;
|
||||
use rustfs_common::data_usage::DataUsageInfo;
|
||||
use rustfs_ecstore::GLOBAL_Endpoints;
|
||||
use rustfs_ecstore::bucket::metadata_sys::{BucketMetadataSys, GLOBAL_BucketMetadataSys};
|
||||
use rustfs_ecstore::endpoints::EndpointServerPools;
|
||||
use rustfs_ecstore::store::ECStore;
|
||||
use rustfs_ecstore::store_api::{ObjectIO, PutObjReader, StorageAPI};
|
||||
use std::sync::{Arc, Once};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Level;
|
||||
|
||||
/// Build a minimal single-node ECStore over a temp directory and populate objects.
|
||||
async fn create_store_with_objects(count: usize) -> (TempDir, std::sync::Arc<ECStore>) {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let root = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Create endpoints from the temp dir
|
||||
let (endpoint_pools, _setup) = EndpointServerPools::from_volumes("127.0.0.1:0", vec![root])
|
||||
.await
|
||||
.expect("endpoint pools");
|
||||
|
||||
// Seed globals required by metadata sys if not already set
|
||||
if GLOBAL_Endpoints.get().is_none() {
|
||||
let _ = GLOBAL_Endpoints.set(endpoint_pools.clone());
|
||||
}
|
||||
|
||||
let store = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.expect("create store");
|
||||
|
||||
if rustfs_ecstore::global::new_object_layer_fn().is_none() {
|
||||
rustfs_ecstore::global::set_object_layer(store.clone()).await;
|
||||
}
|
||||
|
||||
// Initialize metadata system before bucket operations
|
||||
if GLOBAL_BucketMetadataSys.get().is_none() {
|
||||
let mut sys = BucketMetadataSys::new(store.clone());
|
||||
sys.init(Vec::new()).await;
|
||||
let _ = GLOBAL_BucketMetadataSys.set(Arc::new(RwLock::new(sys)));
|
||||
}
|
||||
|
||||
store
|
||||
.make_bucket("fallback-bucket", &rustfs_ecstore::store_api::MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("make bucket");
|
||||
|
||||
for i in 0..count {
|
||||
let key = format!("obj-{i:04}");
|
||||
let data = format!("payload-{i}");
|
||||
let mut reader = PutObjReader::from_vec(data.into_bytes());
|
||||
store
|
||||
.put_object("fallback-bucket", &key, &mut reader, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
(temp_dir, store)
|
||||
}
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn init_tracing(filter_level: Level) {
|
||||
INIT.call_once(|| {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.with_max_level(filter_level)
|
||||
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339())
|
||||
.with_thread_names(true)
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_builds_full_counts_over_100_objects() {
|
||||
init_tracing(Level::ERROR);
|
||||
let (_tmp, store) = create_store_with_objects(1000).await;
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Directly call the fallback builder to ensure pagination works.
|
||||
let usage: DataUsageInfo = scanner.build_data_usage_from_ecstore(&store).await.expect("fallback usage");
|
||||
|
||||
let bucket = usage.buckets_usage.get("fallback-bucket").expect("bucket usage present");
|
||||
|
||||
assert!(
|
||||
usage.objects_total_count >= 1000,
|
||||
"total objects should be >=1000, got {}",
|
||||
usage.objects_total_count
|
||||
);
|
||||
assert!(
|
||||
bucket.objects_count >= 1000,
|
||||
"bucket objects should be >=1000, got {}",
|
||||
bucket.objects_count
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_ahm::scanner::{
|
||||
io_throttler::MetricsSnapshot,
|
||||
local_stats::StatsSummary,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig},
|
||||
stats_aggregator::{DecentralizedStatsAggregator, DecentralizedStatsAggregatorConfig, NodeInfo},
|
||||
};
|
||||
use scanner_optimization_tests::{PerformanceBenchmark, create_test_scanner};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
mod scanner_optimization_tests;
|
||||
#[tokio::test]
|
||||
async fn test_end_to_end_scanner_lifecycle() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
scanner.initialize_stats().await.expect("Failed to initialize stats");
|
||||
|
||||
let initial_progress = scanner.get_scan_progress().await;
|
||||
assert_eq!(initial_progress.current_cycle, 0);
|
||||
|
||||
scanner.force_save_checkpoint().await.expect("Failed to save checkpoint");
|
||||
|
||||
let checkpoint_info = scanner.get_checkpoint_info().await.unwrap();
|
||||
assert!(checkpoint_info.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_balancing_and_throttling_integration() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let throttler = scanner.get_io_throttler();
|
||||
|
||||
// Start IO monitoring
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// Simulate load variation scenarios
|
||||
let load_scenarios = vec![
|
||||
(LoadLevel::Low, 10, 100, 0, 5), // (load level, latency, QPS, error rate, connections)
|
||||
(LoadLevel::Medium, 30, 300, 10, 20),
|
||||
(LoadLevel::High, 80, 800, 50, 50),
|
||||
(LoadLevel::Critical, 200, 1200, 100, 100),
|
||||
];
|
||||
|
||||
for (expected_level, latency, qps, error_rate, connections) in load_scenarios {
|
||||
// Update business metrics
|
||||
scanner.update_business_metrics(latency, qps, error_rate, connections).await;
|
||||
|
||||
// Wait for monitoring system response
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
|
||||
// Get current load level
|
||||
let current_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// Get throttling decision
|
||||
let metrics_snapshot = MetricsSnapshot {
|
||||
iops: 100 + qps / 10,
|
||||
latency,
|
||||
cpu_usage: std::cmp::min(50 + (qps / 20) as u8, 100),
|
||||
memory_usage: 40,
|
||||
};
|
||||
|
||||
let decision = throttler.make_throttle_decision(current_level, Some(metrics_snapshot)).await;
|
||||
|
||||
println!(
|
||||
"Load scenario test: Expected={:?}, Actual={:?}, Should_pause={}, Delay={:?}",
|
||||
expected_level, current_level, decision.should_pause, decision.suggested_delay
|
||||
);
|
||||
|
||||
// Verify throttling effect under high load
|
||||
if matches!(current_level, LoadLevel::High | LoadLevel::Critical) {
|
||||
assert!(decision.suggested_delay > Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
if matches!(current_level, LoadLevel::Critical) {
|
||||
assert!(decision.should_pause);
|
||||
}
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_resume_functionality() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create first scanner instance
|
||||
let scanner1 = {
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
NodeScanner::new("checkpoint-test-node".to_string(), config)
|
||||
};
|
||||
|
||||
// Initialize and simulate some scan progress
|
||||
scanner1.initialize_stats().await.unwrap();
|
||||
|
||||
// Simulate scan progress
|
||||
scanner1
|
||||
.update_scan_progress_for_test(3, 1, Some("checkpoint-test-key".to_string()))
|
||||
.await;
|
||||
|
||||
// Save checkpoint
|
||||
scanner1.force_save_checkpoint().await.unwrap();
|
||||
|
||||
// Stop first scanner
|
||||
scanner1.stop().await.unwrap();
|
||||
|
||||
// Create second scanner instance (simulate restart)
|
||||
let scanner2 = {
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
NodeScanner::new("checkpoint-test-node".to_string(), config)
|
||||
};
|
||||
|
||||
// Try to recover from checkpoint
|
||||
scanner2.start_with_resume().await.unwrap();
|
||||
|
||||
// Verify recovered progress
|
||||
let recovered_progress = scanner2.get_scan_progress().await;
|
||||
assert_eq!(recovered_progress.current_cycle, 3);
|
||||
assert_eq!(recovered_progress.current_disk_index, 1);
|
||||
assert_eq!(recovered_progress.last_scan_key, Some("checkpoint-test-key".to_string()));
|
||||
|
||||
// Cleanup
|
||||
scanner2.cleanup_checkpoint().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_distributed_stats_aggregation() {
|
||||
// Create decentralized stats aggregator
|
||||
let config = DecentralizedStatsAggregatorConfig {
|
||||
cache_ttl: Duration::from_secs(10), // Increase cache TTL to ensure cache is valid during test
|
||||
node_timeout: Duration::from_millis(500), // Reduce timeout
|
||||
..Default::default()
|
||||
};
|
||||
let aggregator = DecentralizedStatsAggregator::new(config);
|
||||
|
||||
// Simulate multiple nodes (these nodes don't exist in test environment, will cause connection failures)
|
||||
let node_infos = vec![
|
||||
NodeInfo {
|
||||
node_id: "node-1".to_string(),
|
||||
address: "127.0.0.1".to_string(),
|
||||
port: 9001,
|
||||
is_online: true,
|
||||
last_heartbeat: std::time::SystemTime::now(),
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
NodeInfo {
|
||||
node_id: "node-2".to_string(),
|
||||
address: "127.0.0.1".to_string(),
|
||||
port: 9002,
|
||||
is_online: true,
|
||||
last_heartbeat: std::time::SystemTime::now(),
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Add nodes to aggregator
|
||||
for node_info in node_infos {
|
||||
aggregator.add_node(node_info).await;
|
||||
}
|
||||
|
||||
// Set local statistics (simulate local node)
|
||||
let local_stats = StatsSummary {
|
||||
node_id: "local-node".to_string(),
|
||||
total_objects_scanned: 1000,
|
||||
total_healthy_objects: 950,
|
||||
total_corrupted_objects: 50,
|
||||
total_bytes_scanned: 1024 * 1024 * 100, // 100MB
|
||||
total_scan_errors: 5,
|
||||
total_heal_triggered: 10,
|
||||
total_disks: 4,
|
||||
total_buckets: 5,
|
||||
last_update: std::time::SystemTime::now(),
|
||||
scan_progress: Default::default(),
|
||||
data_usage: rustfs_common::data_usage::DataUsageInfo::default(),
|
||||
};
|
||||
|
||||
aggregator.set_local_stats(local_stats).await;
|
||||
|
||||
// Get aggregated statistics (remote nodes will fail, but local node should succeed)
|
||||
let aggregated = aggregator.get_aggregated_stats().await.unwrap();
|
||||
|
||||
// Verify local node statistics are included
|
||||
assert!(aggregated.node_summaries.contains_key("local-node"));
|
||||
assert!(aggregated.total_objects_scanned >= 1000);
|
||||
|
||||
// Only local node data due to remote node connection failures
|
||||
assert_eq!(aggregated.node_summaries.len(), 1);
|
||||
|
||||
// Test caching mechanism
|
||||
let original_timestamp = aggregated.aggregation_timestamp;
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let cached_result = aggregator.get_aggregated_stats().await.unwrap();
|
||||
let cached_duration = start_time.elapsed();
|
||||
|
||||
// Verify cache is effective: timestamps should be the same
|
||||
assert_eq!(original_timestamp, cached_result.aggregation_timestamp);
|
||||
|
||||
// Cached calls should be fast (relaxed to 200ms for test environment)
|
||||
assert!(cached_duration < Duration::from_millis(200));
|
||||
|
||||
// Force refresh
|
||||
let _refreshed = aggregator.force_refresh_aggregated_stats().await.unwrap();
|
||||
|
||||
// Clear cache
|
||||
aggregator.clear_cache().await;
|
||||
|
||||
// Verify cache status
|
||||
let cache_status = aggregator.get_cache_status().await;
|
||||
assert!(!cache_status.has_cached_data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_performance_impact_measurement() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
// Start performance monitoring
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let _throttler = scanner.get_io_throttler();
|
||||
|
||||
io_monitor.start().await.unwrap();
|
||||
|
||||
// Baseline test: no scanner load - measure multiple times for stability
|
||||
const MEASUREMENT_COUNT: usize = 5;
|
||||
let mut baseline_measurements = Vec::new();
|
||||
for _ in 0..MEASUREMENT_COUNT {
|
||||
let duration = measure_workload(10_000, Duration::ZERO).await;
|
||||
baseline_measurements.push(duration);
|
||||
}
|
||||
// Use median to reduce impact of outliers
|
||||
baseline_measurements.sort();
|
||||
let median_idx = baseline_measurements.len() / 2;
|
||||
let baseline_duration = baseline_measurements[median_idx].max(Duration::from_millis(20));
|
||||
|
||||
// Simulate scanner activity
|
||||
scanner.update_business_metrics(50, 500, 0, 25).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Performance test: with scanner load - measure multiple times for stability
|
||||
let mut scanner_measurements = Vec::new();
|
||||
for _ in 0..MEASUREMENT_COUNT {
|
||||
let duration = measure_workload(10_000, Duration::ZERO).await;
|
||||
scanner_measurements.push(duration);
|
||||
}
|
||||
scanner_measurements.sort();
|
||||
let median_idx = scanner_measurements.len() / 2;
|
||||
let with_scanner_duration = scanner_measurements[median_idx].max(baseline_duration);
|
||||
|
||||
// Calculate performance impact
|
||||
let baseline_ns = baseline_duration.as_nanos().max(1) as f64;
|
||||
let overhead_duration = with_scanner_duration.saturating_sub(baseline_duration);
|
||||
let overhead_ns = overhead_duration.as_nanos() as f64;
|
||||
let overhead_ms = (overhead_ns / 1_000_000.0).round() as u64;
|
||||
let impact_percentage = (overhead_ns / baseline_ns) * 100.0;
|
||||
|
||||
let benchmark = PerformanceBenchmark {
|
||||
_scanner_overhead_ms: overhead_ms,
|
||||
business_impact_percentage: impact_percentage,
|
||||
_throttle_effectiveness: 95.0, // Simulated value
|
||||
};
|
||||
|
||||
println!("Performance impact measurement:");
|
||||
println!(" Baseline duration: {baseline_duration:?}");
|
||||
println!(" With scanner duration: {with_scanner_duration:?}");
|
||||
println!(" Overhead: {overhead_ms} ms");
|
||||
println!(" Impact percentage: {impact_percentage:.2}%");
|
||||
println!(" Meets optimization goals: {}", benchmark.meets_optimization_goals());
|
||||
|
||||
// Verify optimization target (business impact < 50%)
|
||||
// Note: In test environment, allow higher threshold due to system load variability
|
||||
// In production, the actual impact should be much lower (< 10%)
|
||||
assert!(impact_percentage < 50.0, "Performance impact too high: {impact_percentage:.2}%");
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_scanner_operations() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = Arc::new(create_test_scanner(&temp_dir).await);
|
||||
|
||||
scanner.initialize_stats().await.unwrap();
|
||||
|
||||
// Execute multiple scanner operations concurrently
|
||||
let tasks = vec![
|
||||
// Task 1: Periodically update business metrics
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for i in 0..10 {
|
||||
scanner.update_business_metrics(10 + i * 5, 100 + i * 10, i, 5 + i).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
// Task 2: Periodically save checkpoints
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for _i in 0..5 {
|
||||
if let Err(e) = scanner.force_save_checkpoint().await {
|
||||
eprintln!("Checkpoint save failed: {e}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
// Task 3: Periodically get statistics
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for _i in 0..8 {
|
||||
let _summary = scanner.get_stats_summary().await;
|
||||
let _progress = scanner.get_scan_progress().await;
|
||||
tokio::time::sleep(Duration::from_millis(75)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
];
|
||||
|
||||
// Wait for all tasks to complete
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
let final_stats = scanner.get_stats_summary().await;
|
||||
let _final_progress = scanner.get_scan_progress().await;
|
||||
|
||||
assert_eq!(final_stats.node_id, "integration-test-node");
|
||||
assert!(final_stats.last_update > std::time::SystemTime::UNIX_EPOCH);
|
||||
|
||||
// Cleanup
|
||||
scanner.cleanup_checkpoint().await.unwrap();
|
||||
}
|
||||
|
||||
// Helper function to simulate business workload
|
||||
async fn simulate_business_workload(operations: usize) {
|
||||
for _i in 0..operations {
|
||||
// Simulate some CPU-intensive operations
|
||||
let _result: u64 = (0..100).map(|x| x * x).sum();
|
||||
|
||||
// Small delay to simulate IO operations
|
||||
if _i % 100 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn measure_workload(operations: usize, extra_delay: Duration) -> Duration {
|
||||
let start = std::time::Instant::now();
|
||||
simulate_business_workload(operations).await;
|
||||
if !extra_delay.is_zero() {
|
||||
tokio::time::sleep(extra_delay).await;
|
||||
}
|
||||
start.elapsed()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_recovery_and_resilience() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
// Test recovery from stats initialization failure
|
||||
scanner.initialize_stats().await.unwrap();
|
||||
|
||||
// Test recovery from checkpoint corruption
|
||||
scanner.force_save_checkpoint().await.unwrap();
|
||||
|
||||
// Artificially corrupt checkpoint file (by writing invalid data)
|
||||
let checkpoint_file = temp_dir.path().join("scanner_checkpoint_integration-test-node.json");
|
||||
if checkpoint_file.exists() {
|
||||
tokio::fs::write(&checkpoint_file, "invalid json data").await.unwrap();
|
||||
}
|
||||
|
||||
// Verify system can gracefully handle corrupted checkpoint
|
||||
let checkpoint_info = scanner.get_checkpoint_info().await;
|
||||
// Should return error or null value, not crash
|
||||
assert!(checkpoint_info.is_err() || checkpoint_info.unwrap().is_none());
|
||||
|
||||
// Clean up corrupted checkpoint
|
||||
scanner.cleanup_checkpoint().await.unwrap();
|
||||
|
||||
// Verify ability to recreate valid checkpoint
|
||||
scanner.force_save_checkpoint().await.unwrap();
|
||||
let new_checkpoint_info = scanner.get_checkpoint_info().await.unwrap();
|
||||
assert!(new_checkpoint_info.is_some());
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use heed::byteorder::BigEndian;
|
||||
use heed::types::*;
|
||||
use heed::{BoxedError, BytesDecode, BytesEncode, Database, DatabaseFlags, Env, EnvOpenOptions};
|
||||
use rustfs_ahm::scanner::local_scan::{self, LocalObjectRecord, LocalScanOutcome};
|
||||
use rustfs_ecstore::{
|
||||
disk::endpoint::Endpoint,
|
||||
endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
|
||||
store::ECStore,
|
||||
store_api::{MakeBucketOptions, ObjectIO, ObjectInfo, ObjectOptions, PutObjReader, StorageAPI},
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Once, OnceLock},
|
||||
};
|
||||
//use heed_traits::Comparator;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
static _LIFECYCLE_EXPIRY_CURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_EXPIRY_NONCURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_TRANSITION_CURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_TRANSITION_NONCURRENT_DAYS: i32 = 1;
|
||||
static GLOBAL_LMDB_ENV: OnceLock<Env> = OnceLock::new();
|
||||
static GLOBAL_LMDB_DB: OnceLock<Database<I64<BigEndian>, LifecycleContentCodec>> = OnceLock::new();
|
||||
|
||||
fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
});
|
||||
}
|
||||
|
||||
/// Test helper: Create test environment with ECStore
|
||||
async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
init_tracing();
|
||||
|
||||
// Fast path: already initialized, just clone and return
|
||||
if let Some((paths, ecstore)) = GLOBAL_ENV.get() {
|
||||
return (paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// create temp dir as 4 disks with unique base dir
|
||||
let test_base_dir = format!("/tmp/rustfs_ahm_lifecyclecache_test_{}", uuid::Uuid::new_v4());
|
||||
let temp_dir = std::path::PathBuf::from(&test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
// create 4 disk dirs
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).await.unwrap();
|
||||
}
|
||||
|
||||
// create EndpointServerPools
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
// set correct index
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// format disks (only first time)
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
// create ECStore with dynamic port 0 (let OS assign) or fixed 9002 if free
|
||||
let port = 9002; // for simplicity
|
||||
let server_addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
rustfs_ecstore::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
//lmdb env
|
||||
// User home directory
|
||||
/*if let Ok(home_dir) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) {
|
||||
let mut path = PathBuf::from(home_dir);
|
||||
path.push(format!(".{DEFAULT_LOG_FILENAME}"));
|
||||
path.push(DEFAULT_LOG_DIR);
|
||||
if ensure_directory_writable(&path) {
|
||||
//return path;
|
||||
}
|
||||
}*/
|
||||
let test_lmdb_lifecycle_dir = "/tmp/lmdb_lifecycle".to_string();
|
||||
let temp_dir = std::path::PathBuf::from(&test_lmdb_lifecycle_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
let lmdb_env = unsafe { EnvOpenOptions::new().max_dbs(100).open(&test_lmdb_lifecycle_dir).unwrap() };
|
||||
let bucket_name = format!("test-lc-cache-{}", "00000");
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
let db = match lmdb_env
|
||||
.database_options()
|
||||
.name(&format!("bucket_{bucket_name}"))
|
||||
.types::<I64<BigEndian>, LifecycleContentCodec>()
|
||||
.flags(DatabaseFlags::DUP_SORT)
|
||||
//.dup_sort_comparator::<>()
|
||||
.create(&mut wtxn)
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(err) => {
|
||||
panic!("lmdb error: {err}");
|
||||
}
|
||||
};
|
||||
let _ = wtxn.commit();
|
||||
let _ = GLOBAL_LMDB_ENV.set(lmdb_env);
|
||||
let _ = GLOBAL_LMDB_DB.set(db);
|
||||
|
||||
// Store in global once lock
|
||||
let _ = GLOBAL_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
/// Test helper: Create a test bucket
|
||||
#[allow(dead_code)]
|
||||
async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(bucket_name, &Default::default())
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Create a test lock bucket
|
||||
async fn create_test_lock_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(
|
||||
bucket_name,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Upload test object
|
||||
async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) {
|
||||
let mut reader = PutObjReader::from_vec(data.to_vec());
|
||||
let object_info = (**ecstore)
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload test object");
|
||||
|
||||
println!("object_info1: {object_info:?}");
|
||||
|
||||
info!("Uploaded test object: {}/{} ({} bytes)", bucket, object, object_info.size);
|
||||
}
|
||||
|
||||
/// Test helper: Check if object exists
|
||||
async fn object_exists(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> bool {
|
||||
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
Ok(info) => !info.delete_marker,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ns_to_offset_datetime(ns: i128) -> Option<OffsetDateTime> {
|
||||
OffsetDateTime::from_unix_timestamp_nanos(ns).ok()
|
||||
}
|
||||
|
||||
fn convert_record_to_object_info(record: &LocalObjectRecord) -> ObjectInfo {
|
||||
let usage = &record.usage;
|
||||
|
||||
ObjectInfo {
|
||||
bucket: usage.bucket.clone(),
|
||||
name: usage.object.clone(),
|
||||
size: usage.total_size as i64,
|
||||
delete_marker: !usage.has_live_object && usage.delete_markers_count > 0,
|
||||
mod_time: usage.last_modified_ns.and_then(ns_to_offset_datetime),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn to_object_info(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
total_size: i64,
|
||||
delete_marker: bool,
|
||||
mod_time: OffsetDateTime,
|
||||
version_id: &str,
|
||||
) -> ObjectInfo {
|
||||
ObjectInfo {
|
||||
bucket: bucket.to_string(),
|
||||
name: object.to_string(),
|
||||
size: total_size,
|
||||
delete_marker,
|
||||
mod_time: Some(mod_time),
|
||||
version_id: Some(Uuid::parse_str(version_id).unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LifecycleType {
|
||||
ExpiryCurrent,
|
||||
ExpiryNoncurrent,
|
||||
TransitionCurrent,
|
||||
TransitionNoncurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LifecycleContent {
|
||||
ver_no: u8,
|
||||
ver_id: String,
|
||||
mod_time: OffsetDateTime,
|
||||
type_: LifecycleType,
|
||||
object_name: String,
|
||||
}
|
||||
|
||||
pub struct LifecycleContentCodec;
|
||||
|
||||
impl BytesEncode<'_> for LifecycleContentCodec {
|
||||
type EItem = LifecycleContent;
|
||||
|
||||
fn bytes_encode(lcc: &Self::EItem) -> Result<Cow<'_, [u8]>, BoxedError> {
|
||||
let (ver_no_byte, ver_id_bytes, mod_timestamp_bytes, type_byte, object_name_bytes) = match lcc {
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::ExpiryCurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
0,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::ExpiryNoncurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
1,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionCurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
2,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionNoncurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
3,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
};
|
||||
|
||||
let mut output = Vec::<u8>::new();
|
||||
output.push(*ver_no_byte);
|
||||
output.extend_from_slice(&ver_id_bytes);
|
||||
output.extend_from_slice(&mod_timestamp_bytes);
|
||||
output.push(type_byte);
|
||||
output.extend_from_slice(&object_name_bytes);
|
||||
Ok(Cow::Owned(output))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BytesDecode<'a> for LifecycleContentCodec {
|
||||
type DItem = LifecycleContent;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, BoxedError> {
|
||||
use std::mem::size_of;
|
||||
|
||||
let ver_no = match bytes.get(..size_of::<u8>()) {
|
||||
Some(bytes) => bytes.try_into().map(u8::from_be_bytes).unwrap(),
|
||||
None => return Err("invalid LifecycleContent: cannot extract ver_no".into()),
|
||||
};
|
||||
|
||||
let ver_id = match bytes.get(size_of::<u8>()..(36 + 1)) {
|
||||
Some(bytes) => unsafe { std::str::from_utf8_unchecked(bytes).to_string() },
|
||||
None => return Err("invalid LifecycleContent: cannot extract ver_id".into()),
|
||||
};
|
||||
|
||||
let mod_timestamp = match bytes.get((36 + 1)..(size_of::<i64>() + 36 + 1)) {
|
||||
Some(bytes) => bytes.try_into().map(i64::from_be_bytes).unwrap(),
|
||||
None => return Err("invalid LifecycleContent: cannot extract mod_time timestamp".into()),
|
||||
};
|
||||
|
||||
let type_ = match bytes.get(size_of::<i64>() + 36 + 1) {
|
||||
Some(&0) => LifecycleType::ExpiryCurrent,
|
||||
Some(&1) => LifecycleType::ExpiryNoncurrent,
|
||||
Some(&2) => LifecycleType::TransitionCurrent,
|
||||
Some(&3) => LifecycleType::TransitionNoncurrent,
|
||||
Some(_) => return Err("invalid LifecycleContent: invalid LifecycleType".into()),
|
||||
None => return Err("invalid LifecycleContent: cannot extract LifecycleType".into()),
|
||||
};
|
||||
|
||||
let object_name = match bytes.get((size_of::<i64>() + 36 + 1 + 1)..) {
|
||||
Some(bytes) => unsafe { std::str::from_utf8_unchecked(bytes).to_string() },
|
||||
None => return Err("invalid LifecycleContent: cannot extract object_name".into()),
|
||||
};
|
||||
|
||||
Ok(LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time: OffsetDateTime::from_unix_timestamp(mod_timestamp).unwrap(),
|
||||
type_,
|
||||
object_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod serial_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
//#[ignore]
|
||||
async fn test_lifecycle_chche_build() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let bucket_name = format!("test-lc-cache-{}", &suffix[..8]);
|
||||
let object_name = "test/object.txt"; // Match the lifecycle rule prefix "test/"
|
||||
let test_data = b"Hello, this is test data for lifecycle expiry!";
|
||||
|
||||
create_test_lock_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
upload_test_object(&ecstore, bucket_name.as_str(), object_name, test_data).await;
|
||||
|
||||
// Verify object exists initially
|
||||
assert!(object_exists(&ecstore, bucket_name.as_str(), object_name).await);
|
||||
println!("✅ Object exists before lifecycle processing");
|
||||
|
||||
let scan_outcome = match local_scan::scan_and_persist_local_usage(ecstore.clone()).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
warn!("Local usage scan failed: {}", err);
|
||||
LocalScanOutcome::default()
|
||||
}
|
||||
};
|
||||
let bucket_objects_map = &scan_outcome.bucket_objects;
|
||||
|
||||
let records = match bucket_objects_map.get(&bucket_name) {
|
||||
Some(records) => records,
|
||||
None => {
|
||||
debug!("No local snapshot entries found for bucket {}; skipping lifecycle/integrity", bucket_name);
|
||||
&vec![]
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(lmdb_env) = GLOBAL_LMDB_ENV.get() {
|
||||
if let Some(lmdb) = GLOBAL_LMDB_DB.get() {
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
|
||||
/*if let Ok((lc_config, _)) = rustfs_ecstore::bucket::metadata_sys::get_lifecycle_config(bucket_name.as_str()).await {
|
||||
if let Ok(object_info) = ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
let event = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::eval_action_from_lifecycle(
|
||||
&lc_config,
|
||||
None,
|
||||
None,
|
||||
&object_info,
|
||||
)
|
||||
.await;
|
||||
|
||||
rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
&object_info,
|
||||
&event,
|
||||
&rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
|
||||
)
|
||||
.await;
|
||||
|
||||
expired = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(2)).await;
|
||||
}
|
||||
}*/
|
||||
|
||||
for record in records {
|
||||
if !record.usage.has_live_object {
|
||||
continue;
|
||||
}
|
||||
|
||||
let object_info = convert_record_to_object_info(record);
|
||||
println!("object_info2: {object_info:?}");
|
||||
let mod_time = object_info.mod_time.unwrap_or(OffsetDateTime::now_utc());
|
||||
let expiry_time = rustfs_ecstore::bucket::lifecycle::lifecycle::expected_expiry_time(mod_time, 1);
|
||||
|
||||
let version_id = if let Some(version_id) = object_info.version_id {
|
||||
version_id.to_string()
|
||||
} else {
|
||||
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz".to_string()
|
||||
};
|
||||
|
||||
lmdb.put(
|
||||
&mut wtxn,
|
||||
&expiry_time.unix_timestamp(),
|
||||
&LifecycleContent {
|
||||
ver_no: 0,
|
||||
ver_id: version_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionNoncurrent,
|
||||
object_name: object_info.name,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
wtxn.commit().unwrap();
|
||||
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
let iter = lmdb.iter_mut(&mut wtxn).unwrap();
|
||||
//let _ = unsafe { iter.del_current().unwrap() };
|
||||
for row in iter {
|
||||
if let Ok(ref elm) = row {
|
||||
let LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_,
|
||||
object_name,
|
||||
} = &elm.1;
|
||||
println!("cache row:{ver_no} {ver_id} {mod_time} {type_:?} {object_name}");
|
||||
}
|
||||
println!("row:{row:?}");
|
||||
}
|
||||
//drop(iter);
|
||||
wtxn.commit().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
println!("Lifecycle cache test completed");
|
||||
}
|
||||
}
|
||||
@@ -1,817 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_ahm::heal::manager::HealConfig;
|
||||
use rustfs_ahm::scanner::{
|
||||
Scanner,
|
||||
data_scanner::ScanMode,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig},
|
||||
};
|
||||
use rustfs_ecstore::{
|
||||
StorageAPI,
|
||||
disk::endpoint::Endpoint,
|
||||
endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
|
||||
store::ECStore,
|
||||
store_api::{MakeBucketOptions, ObjectIO, PutObjReader},
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::{fs, net::SocketAddr, sync::Arc, sync::OnceLock, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// Global test environment cache to avoid repeated initialization
|
||||
static GLOBAL_TEST_ENV: OnceLock<(Vec<std::path::PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
|
||||
async fn prepare_test_env(test_dir: Option<&str>, port: Option<u16>) -> (Vec<std::path::PathBuf>, Arc<ECStore>) {
|
||||
// Check if global environment is already initialized
|
||||
if let Some((disk_paths, ecstore)) = GLOBAL_TEST_ENV.get() {
|
||||
return (disk_paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// create temp dir as 4 disks
|
||||
let test_base_dir = test_dir.unwrap_or("/tmp/rustfs_ahm_optimized_test");
|
||||
let temp_dir = std::path::PathBuf::from(test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).unwrap();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
// create 4 disk dirs
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).unwrap();
|
||||
}
|
||||
|
||||
// create EndpointServerPools
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
// set correct index
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// format disks
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
// create ECStore with dynamic port
|
||||
let port = port.unwrap_or(9000);
|
||||
let server_addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
rustfs_ecstore::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
// Store in global cache
|
||||
let _ = GLOBAL_TEST_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_basic_functionality() {
|
||||
const TEST_DIR_BASIC: &str = "/tmp/rustfs_ahm_optimized_test_basic";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_BASIC), Some(9101)).await;
|
||||
|
||||
// create some test data
|
||||
let bucket_name = "test-bucket";
|
||||
let object_name = "test-object";
|
||||
let test_data = b"Hello, Optimized RustFS!";
|
||||
|
||||
// create bucket and verify
|
||||
let bucket_opts = MakeBucketOptions::default();
|
||||
ecstore
|
||||
.make_bucket(bucket_name, &bucket_opts)
|
||||
.await
|
||||
.expect("make_bucket failed");
|
||||
|
||||
// check bucket really exists
|
||||
let buckets = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(buckets.iter().any(|b| b.name == bucket_name), "bucket not found after creation");
|
||||
|
||||
// write object
|
||||
let mut put_reader = PutObjReader::from_vec(test_data.to_vec());
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed");
|
||||
|
||||
// create optimized Scanner and test basic functionality
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Test 1: Normal scan - verify object is found
|
||||
println!("=== Test 1: Optimized Normal scan ===");
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Optimized normal scan should succeed");
|
||||
let _metrics = scanner.get_metrics().await;
|
||||
// Note: The optimized scanner may not immediately show scanned objects as it works differently
|
||||
println!("Optimized normal scan completed successfully");
|
||||
|
||||
// Test 2: Simulate disk corruption - delete object data from disk1
|
||||
println!("=== Test 2: Optimized corruption handling ===");
|
||||
let disk1_bucket_path = disk_paths[0].join(bucket_name);
|
||||
let disk1_object_path = disk1_bucket_path.join(object_name);
|
||||
|
||||
// Try to delete the object file from disk1 (simulate corruption)
|
||||
// Note: This might fail if ECStore is actively using the file
|
||||
match fs::remove_dir_all(&disk1_object_path) {
|
||||
Ok(_) => {
|
||||
println!("Successfully deleted object from disk1: {disk1_object_path:?}");
|
||||
|
||||
// Verify deletion by checking if the directory still exists
|
||||
if disk1_object_path.exists() {
|
||||
println!("WARNING: Directory still exists after deletion: {disk1_object_path:?}");
|
||||
} else {
|
||||
println!("Confirmed: Directory was successfully deleted");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Could not delete object from disk1 (file may be in use): {disk1_object_path:?} - {e}");
|
||||
// This is expected behavior - ECStore might be holding file handles
|
||||
}
|
||||
}
|
||||
|
||||
// Scan again - should still complete (even with missing data)
|
||||
let scan_result_after_corruption = scanner.scan_cycle().await;
|
||||
println!("Optimized scan after corruption result: {scan_result_after_corruption:?}");
|
||||
|
||||
// Scanner should handle missing data gracefully
|
||||
assert!(
|
||||
scan_result_after_corruption.is_ok(),
|
||||
"Optimized scanner should handle missing data gracefully"
|
||||
);
|
||||
|
||||
// Test 3: Test metrics collection
|
||||
println!("=== Test 3: Optimized metrics collection ===");
|
||||
let final_metrics = scanner.get_metrics().await;
|
||||
println!("Optimized final metrics: {final_metrics:?}");
|
||||
|
||||
// Verify metrics are available (even if different from legacy scanner)
|
||||
assert!(final_metrics.last_activity.is_some(), "Should have scan activity");
|
||||
|
||||
// clean up temp dir
|
||||
let temp_dir = std::path::PathBuf::from(TEST_DIR_BASIC);
|
||||
if let Err(e) = fs::remove_dir_all(&temp_dir) {
|
||||
eprintln!("Warning: Failed to clean up temp directory {temp_dir:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_usage_stats() {
|
||||
const TEST_DIR_USAGE_STATS: &str = "/tmp/rustfs_ahm_optimized_test_usage_stats";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_USAGE_STATS), Some(9102)).await;
|
||||
|
||||
// prepare test bucket and object
|
||||
let bucket = "test-bucket-optimized";
|
||||
ecstore.make_bucket(bucket, &Default::default()).await.unwrap();
|
||||
let mut pr = PutObjReader::from_vec(b"hello optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket, "obj1", &mut pr, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// enable statistics
|
||||
scanner.set_config_enable_data_usage_stats(true).await;
|
||||
|
||||
// first scan and get statistics
|
||||
scanner.scan_cycle().await.unwrap();
|
||||
let du_initial = scanner.get_data_usage_info().await.unwrap();
|
||||
// Note: Optimized scanner may work differently, so we're less strict about counts
|
||||
println!("Initial data usage: {du_initial:?}");
|
||||
|
||||
// write 3 more objects and get statistics again
|
||||
for size in [1024, 2048, 4096] {
|
||||
let name = format!("obj_{size}");
|
||||
let mut pr = PutObjReader::from_vec(vec![b'x'; size]);
|
||||
ecstore.put_object(bucket, &name, &mut pr, &Default::default()).await.unwrap();
|
||||
}
|
||||
|
||||
scanner.scan_cycle().await.unwrap();
|
||||
let du_after = scanner.get_data_usage_info().await.unwrap();
|
||||
println!("Data usage after adding objects: {du_after:?}");
|
||||
|
||||
// The optimized scanner should at least not crash and return valid data
|
||||
// buckets_count is u64, so it's always >= 0
|
||||
assert!(du_after.buckets_count == du_after.buckets_count);
|
||||
|
||||
// clean up temp dir
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_USAGE_STATS));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_volume_healing_functionality() {
|
||||
const TEST_DIR_VOLUME_HEAL: &str = "/tmp/rustfs_ahm_optimized_test_volume_heal";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_VOLUME_HEAL), Some(9103)).await;
|
||||
|
||||
// Create test buckets
|
||||
let bucket1 = "test-bucket-1-opt";
|
||||
let bucket2 = "test-bucket-2-opt";
|
||||
|
||||
ecstore.make_bucket(bucket1, &Default::default()).await.unwrap();
|
||||
ecstore.make_bucket(bucket2, &Default::default()).await.unwrap();
|
||||
|
||||
// Add some test objects
|
||||
let mut pr1 = PutObjReader::from_vec(b"test data 1 optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket1, "obj1", &mut pr1, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pr2 = PutObjReader::from_vec(b"test data 2 optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket2, "obj2", &mut pr2, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate missing bucket on one disk by removing bucket directory
|
||||
let disk1_bucket1_path = disk_paths[0].join(bucket1);
|
||||
if disk1_bucket1_path.exists() {
|
||||
println!("Removing bucket directory to simulate missing volume: {disk1_bucket1_path:?}");
|
||||
match fs::remove_dir_all(&disk1_bucket1_path) {
|
||||
Ok(_) => println!("Successfully removed bucket directory from disk 0"),
|
||||
Err(e) => println!("Failed to remove bucket directory: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Create optimized scanner
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Enable healing in config
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
|
||||
println!("=== Testing optimized volume healing functionality ===");
|
||||
|
||||
// Run scan cycle which should detect missing volume
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Optimized scan cycle should succeed");
|
||||
|
||||
// Get metrics to verify scan completed
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Optimized volume healing detection test completed successfully");
|
||||
println!("Optimized scan metrics: {metrics:?}");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_VOLUME_HEAL));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_performance_characteristics() {
|
||||
const TEST_DIR_PERF: &str = "/tmp/rustfs_ahm_optimized_test_perf";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_PERF), Some(9104)).await;
|
||||
|
||||
// Create test bucket with multiple objects
|
||||
let bucket_name = "performance-test-bucket";
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create several test objects
|
||||
for i in 0..10 {
|
||||
let object_name = format!("perf-object-{i}");
|
||||
let test_data = vec![b'A' + (i % 26) as u8; 1024 * (i + 1)]; // Variable size objects
|
||||
let mut put_reader = PutObjReader::from_vec(test_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
ecstore
|
||||
.put_object(bucket_name, &object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Failed to create object {object_name}"));
|
||||
}
|
||||
|
||||
// Create optimized scanner
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Test performance characteristics
|
||||
println!("=== Testing optimized scanner performance ===");
|
||||
|
||||
// Measure scan time
|
||||
let start_time = std::time::Instant::now();
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
let scan_duration = start_time.elapsed();
|
||||
|
||||
println!("Optimized scan completed in: {scan_duration:?}");
|
||||
assert!(scan_result.is_ok(), "Performance scan should succeed");
|
||||
|
||||
// Verify the scan was reasonably fast (should be faster than old concurrent scanner)
|
||||
// Note: This is a rough check - in practice, optimized scanner should be much faster
|
||||
assert!(
|
||||
scan_duration < Duration::from_secs(30),
|
||||
"Optimized scan should complete within 30 seconds"
|
||||
);
|
||||
|
||||
// Test memory usage is reasonable (indirect test through successful completion)
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Performance test metrics: {metrics:?}");
|
||||
|
||||
// Test that multiple scans don't degrade performance significantly
|
||||
let start_time2 = std::time::Instant::now();
|
||||
let _scan_result2 = scanner.scan_cycle().await;
|
||||
let scan_duration2 = start_time2.elapsed();
|
||||
|
||||
println!("Second optimized scan completed in: {scan_duration2:?}");
|
||||
|
||||
// Second scan should be similar or faster due to caching
|
||||
let performance_ratio = scan_duration2.as_millis() as f64 / scan_duration.as_millis() as f64;
|
||||
println!("Performance ratio (second/first): {performance_ratio:.2}");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_PERF));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_load_balancing_and_throttling() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a node scanner with optimized configuration
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
enable_smart_scheduling: true,
|
||||
scan_interval: Duration::from_millis(100), // Fast for testing
|
||||
disk_scan_delay: Duration::from_millis(50),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let node_scanner = NodeScanner::new("test-optimized-node".to_string(), config);
|
||||
|
||||
// Initialize the scanner
|
||||
node_scanner.initialize_stats().await.unwrap();
|
||||
|
||||
let io_monitor = node_scanner.get_io_monitor();
|
||||
let throttler = node_scanner.get_io_throttler();
|
||||
|
||||
// Start IO monitoring
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// Test load balancing scenarios
|
||||
let load_scenarios = vec![
|
||||
(LoadLevel::Low, 10, 100, 0, 5), // (load level, latency, qps, error rate, connections)
|
||||
(LoadLevel::Medium, 30, 300, 10, 20),
|
||||
(LoadLevel::High, 80, 800, 50, 50),
|
||||
(LoadLevel::Critical, 200, 1200, 100, 100),
|
||||
];
|
||||
|
||||
for (expected_level, latency, qps, error_rate, connections) in load_scenarios {
|
||||
println!("Testing load scenario: {expected_level:?}");
|
||||
|
||||
// Update business metrics to simulate load
|
||||
node_scanner
|
||||
.update_business_metrics(latency, qps, error_rate, connections)
|
||||
.await;
|
||||
|
||||
// Wait for monitoring system to respond
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Get current load level
|
||||
let current_level = io_monitor.get_business_load_level().await;
|
||||
println!("Detected load level: {current_level:?}");
|
||||
|
||||
// Get throttling decision
|
||||
let _current_metrics = io_monitor.get_current_metrics().await;
|
||||
let metrics_snapshot = rustfs_ahm::scanner::io_throttler::MetricsSnapshot {
|
||||
iops: 100 + qps / 10,
|
||||
latency,
|
||||
cpu_usage: std::cmp::min(50 + (qps / 20) as u8, 100),
|
||||
memory_usage: 40,
|
||||
};
|
||||
|
||||
let decision = throttler.make_throttle_decision(current_level, Some(metrics_snapshot)).await;
|
||||
|
||||
println!(
|
||||
"Throttle decision: should_pause={}, delay={:?}",
|
||||
decision.should_pause, decision.suggested_delay
|
||||
);
|
||||
|
||||
// Verify throttling behavior
|
||||
match current_level {
|
||||
LoadLevel::Critical => {
|
||||
assert!(decision.should_pause, "Critical load should trigger pause");
|
||||
}
|
||||
LoadLevel::High => {
|
||||
assert!(
|
||||
decision.suggested_delay > Duration::from_millis(1000),
|
||||
"High load should suggest significant delay"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Lower loads should have reasonable delays
|
||||
assert!(
|
||||
decision.suggested_delay < Duration::from_secs(5),
|
||||
"Lower loads should not have excessive delays"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
|
||||
println!("Optimized load balancing and throttling test completed successfully");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_detect_missing_data_parts() {
|
||||
const TEST_DIR_MISSING_PARTS: &str = "/tmp/rustfs_ahm_optimized_test_missing_parts";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_MISSING_PARTS), Some(9105)).await;
|
||||
|
||||
// Create test bucket
|
||||
let bucket_name = "test-bucket-parts-opt";
|
||||
let object_name = "large-object-20mb-opt";
|
||||
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create a 20MB object to ensure it has multiple parts
|
||||
let large_data = vec![b'A'; 20 * 1024 * 1024]; // 20MB of 'A' characters
|
||||
let mut put_reader = PutObjReader::from_vec(large_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
println!("=== Creating 20MB object ===");
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed for large object");
|
||||
|
||||
// Verify object was created and get its info
|
||||
let obj_info = ecstore
|
||||
.get_object_info(bucket_name, object_name, &object_opts)
|
||||
.await
|
||||
.expect("get_object_info failed");
|
||||
|
||||
println!(
|
||||
"Object info: size={}, parts={}, inlined={}",
|
||||
obj_info.size,
|
||||
obj_info.parts.len(),
|
||||
obj_info.inlined
|
||||
);
|
||||
assert!(!obj_info.inlined, "20MB object should not be inlined");
|
||||
println!("Object has {} parts", obj_info.parts.len());
|
||||
|
||||
// Create HealManager and optimized Scanner
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_config = HealConfig {
|
||||
enable_auto_heal: true,
|
||||
heal_interval: Duration::from_millis(100),
|
||||
max_concurrent_heals: 4,
|
||||
task_timeout: Duration::from_secs(300),
|
||||
queue_size: 1000,
|
||||
};
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
|
||||
// Enable healing to detect missing parts
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
println!("=== Initial scan (all parts present) ===");
|
||||
let initial_scan = scanner.scan_cycle().await;
|
||||
assert!(initial_scan.is_ok(), "Initial scan should succeed");
|
||||
|
||||
let initial_metrics = scanner.get_metrics().await;
|
||||
println!("Initial scan metrics: objects_scanned={}", initial_metrics.objects_scanned);
|
||||
|
||||
// Simulate data part loss by deleting part files from some disks
|
||||
println!("=== Simulating data part loss ===");
|
||||
let mut deleted_parts = 0;
|
||||
let mut deleted_part_paths = Vec::new();
|
||||
|
||||
for (disk_idx, disk_path) in disk_paths.iter().enumerate() {
|
||||
if disk_idx > 0 {
|
||||
// Only delete from first disk
|
||||
break;
|
||||
}
|
||||
let bucket_path = disk_path.join(bucket_name);
|
||||
let object_path = bucket_path.join(object_name);
|
||||
|
||||
if !object_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the data directory (UUID)
|
||||
if let Ok(entries) = fs::read_dir(&object_path) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
// This is likely the data_dir, look for part files inside
|
||||
let part_file_path = entry_path.join("part.1");
|
||||
if part_file_path.exists() {
|
||||
match fs::remove_file(&part_file_path) {
|
||||
Ok(_) => {
|
||||
println!("Deleted part file: {part_file_path:?}");
|
||||
deleted_part_paths.push(part_file_path);
|
||||
deleted_parts += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to delete part file {part_file_path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Deleted {deleted_parts} part files to simulate data loss");
|
||||
|
||||
// Scan again to detect missing parts
|
||||
println!("=== Scan after data deletion (should detect missing data) ===");
|
||||
let scan_after_deletion = scanner.scan_cycle().await;
|
||||
|
||||
// Wait a bit for the heal manager to process
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Check heal statistics
|
||||
let heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Heal statistics:");
|
||||
println!(" - total_tasks: {}", heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", heal_stats.failed_tasks);
|
||||
|
||||
// Get scanner metrics
|
||||
let final_metrics = scanner.get_metrics().await;
|
||||
println!("Scanner metrics after deletion scan:");
|
||||
println!(" - objects_scanned: {}", final_metrics.objects_scanned);
|
||||
|
||||
// The optimized scanner should handle missing data gracefully
|
||||
match scan_after_deletion {
|
||||
Ok(_) => {
|
||||
println!("Optimized scanner completed successfully despite missing data");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Optimized scanner detected errors (acceptable): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("=== Test completed ===");
|
||||
println!("Optimized scanner successfully handled missing data scenario");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_MISSING_PARTS));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_detect_missing_xl_meta() {
|
||||
const TEST_DIR_MISSING_META: &str = "/tmp/rustfs_ahm_optimized_test_missing_meta";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_MISSING_META), Some(9106)).await;
|
||||
|
||||
// Create test bucket
|
||||
let bucket_name = "test-bucket-meta-opt";
|
||||
let object_name = "test-object-meta-opt";
|
||||
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create a test object
|
||||
let test_data = vec![b'B'; 5 * 1024 * 1024]; // 5MB of 'B' characters
|
||||
let mut put_reader = PutObjReader::from_vec(test_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
println!("=== Creating test object ===");
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed");
|
||||
|
||||
// Create HealManager and optimized Scanner
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_config = HealConfig {
|
||||
enable_auto_heal: true,
|
||||
heal_interval: Duration::from_millis(100),
|
||||
max_concurrent_heals: 4,
|
||||
task_timeout: Duration::from_secs(300),
|
||||
queue_size: 1000,
|
||||
};
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
|
||||
// Enable healing to detect missing metadata
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
println!("=== Initial scan (all metadata present) ===");
|
||||
let initial_scan = scanner.scan_cycle().await;
|
||||
assert!(initial_scan.is_ok(), "Initial scan should succeed");
|
||||
|
||||
// Simulate xl.meta file loss by deleting xl.meta files from some disks
|
||||
println!("=== Simulating xl.meta file loss ===");
|
||||
let mut deleted_meta_files = 0;
|
||||
let mut deleted_meta_paths = Vec::new();
|
||||
|
||||
for (disk_idx, disk_path) in disk_paths.iter().enumerate() {
|
||||
if disk_idx >= 2 {
|
||||
// Only delete from first two disks to ensure some copies remain
|
||||
break;
|
||||
}
|
||||
let bucket_path = disk_path.join(bucket_name);
|
||||
let object_path = bucket_path.join(object_name);
|
||||
|
||||
if !object_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete xl.meta file
|
||||
let xl_meta_path = object_path.join("xl.meta");
|
||||
if xl_meta_path.exists() {
|
||||
match fs::remove_file(&xl_meta_path) {
|
||||
Ok(_) => {
|
||||
println!("Deleted xl.meta file: {xl_meta_path:?}");
|
||||
deleted_meta_paths.push(xl_meta_path);
|
||||
deleted_meta_files += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to delete xl.meta file {xl_meta_path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Deleted {deleted_meta_files} xl.meta files to simulate metadata loss");
|
||||
|
||||
// Scan again to detect missing metadata
|
||||
println!("=== Scan after xl.meta deletion ===");
|
||||
let scan_after_deletion = scanner.scan_cycle().await;
|
||||
|
||||
// Wait for heal manager to process
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Check heal statistics
|
||||
let final_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Final heal statistics:");
|
||||
println!(" - total_tasks: {}", final_heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", final_heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", final_heal_stats.failed_tasks);
|
||||
let _ = final_heal_stats; // Use the variable to avoid unused warning
|
||||
|
||||
// The optimized scanner should handle missing metadata gracefully
|
||||
match scan_after_deletion {
|
||||
Ok(_) => {
|
||||
println!("Optimized scanner completed successfully despite missing metadata");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Optimized scanner detected errors (acceptable): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("=== Test completed ===");
|
||||
println!("Optimized scanner successfully handled missing xl.meta scenario");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_MISSING_META));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_healthy_objects_not_marked_corrupted() {
|
||||
const TEST_DIR_HEALTHY: &str = "/tmp/rustfs_ahm_optimized_test_healthy_objects";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_HEALTHY), Some(9107)).await;
|
||||
|
||||
// Create heal manager for this test
|
||||
let heal_config = HealConfig::default();
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::manager::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Create optimized scanner with healing enabled
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
// Create test bucket and multiple healthy objects
|
||||
let bucket_name = "healthy-test-bucket-opt";
|
||||
let bucket_opts = MakeBucketOptions::default();
|
||||
ecstore.make_bucket(bucket_name, &bucket_opts).await.unwrap();
|
||||
|
||||
// Create multiple test objects with different sizes
|
||||
let test_objects = vec![
|
||||
("small-object-opt", b"Small test data optimized".to_vec()),
|
||||
("medium-object-opt", vec![42u8; 1024]), // 1KB
|
||||
("large-object-opt", vec![123u8; 10240]), // 10KB
|
||||
];
|
||||
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
// Write all test objects
|
||||
for (object_name, test_data) in &test_objects {
|
||||
let mut put_reader = PutObjReader::from_vec(test_data.clone());
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("Failed to put test object");
|
||||
println!("Created test object: {object_name} (size: {} bytes)", test_data.len());
|
||||
}
|
||||
|
||||
// Wait a moment for objects to be fully written
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Get initial heal statistics
|
||||
let initial_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Initial heal statistics:");
|
||||
println!(" - total_tasks: {}", initial_heal_stats.total_tasks);
|
||||
|
||||
// Perform initial scan on healthy objects
|
||||
println!("=== Scanning healthy objects ===");
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Scan of healthy objects should succeed");
|
||||
|
||||
// Wait for any potential heal tasks to be processed
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Get scanner metrics after scanning
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Optimized scanner metrics after scanning healthy objects:");
|
||||
println!(" - objects_scanned: {}", metrics.objects_scanned);
|
||||
println!(" - healthy_objects: {}", metrics.healthy_objects);
|
||||
println!(" - corrupted_objects: {}", metrics.corrupted_objects);
|
||||
|
||||
// Get heal statistics after scanning
|
||||
let post_scan_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Heal statistics after scanning healthy objects:");
|
||||
println!(" - total_tasks: {}", post_scan_heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", post_scan_heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", post_scan_heal_stats.failed_tasks);
|
||||
|
||||
// Critical assertion: healthy objects should not trigger unnecessary heal tasks
|
||||
let heal_tasks_created = post_scan_heal_stats.total_tasks - initial_heal_stats.total_tasks;
|
||||
if heal_tasks_created > 0 {
|
||||
println!("WARNING: {heal_tasks_created} heal tasks were created for healthy objects");
|
||||
// For optimized scanner, we're more lenient as it may work differently
|
||||
println!("Note: Optimized scanner may have different behavior than legacy scanner");
|
||||
} else {
|
||||
println!("✓ No heal tasks created for healthy objects - optimized scanner working correctly");
|
||||
}
|
||||
|
||||
// Perform a second scan to ensure consistency
|
||||
println!("=== Second scan to verify consistency ===");
|
||||
let second_scan_result = scanner.scan_cycle().await;
|
||||
assert!(second_scan_result.is_ok(), "Second scan should also succeed");
|
||||
|
||||
let second_metrics = scanner.get_metrics().await;
|
||||
let _final_heal_stats = heal_manager.get_statistics().await;
|
||||
|
||||
println!("Second scan metrics:");
|
||||
println!(" - objects_scanned: {}", second_metrics.objects_scanned);
|
||||
|
||||
println!("=== Test completed successfully ===");
|
||||
println!("✓ Optimized scanner handled healthy objects correctly");
|
||||
println!("✓ No false positive corruption detection");
|
||||
println!("✓ Objects remain accessible after scanning");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_HEALTHY));
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_ahm::scanner::{
|
||||
checkpoint::{CheckpointData, CheckpointManager},
|
||||
io_monitor::{AdvancedIOMonitor, IOMonitorConfig},
|
||||
io_throttler::{AdvancedIOThrottler, IOThrottlerConfig},
|
||||
local_stats::LocalStatsManager,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig, ScanProgress},
|
||||
stats_aggregator::{DecentralizedStatsAggregator, DecentralizedStatsAggregatorConfig},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_manager_save_and_load() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-node-1";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
// create checkpoint
|
||||
let progress = ScanProgress {
|
||||
current_cycle: 5,
|
||||
current_disk_index: 2,
|
||||
last_scan_key: Some("test-object-key".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// save checkpoint
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
// load checkpoint
|
||||
let loaded_progress = checkpoint_manager
|
||||
.load_checkpoint()
|
||||
.await
|
||||
.expect("Failed to load checkpoint")
|
||||
.expect("No checkpoint found");
|
||||
|
||||
// verify data
|
||||
assert_eq!(loaded_progress.current_cycle, 5);
|
||||
assert_eq!(loaded_progress.current_disk_index, 2);
|
||||
assert_eq!(loaded_progress.last_scan_key, Some("test-object-key".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_data_integrity() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-node-integrity";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
let progress = ScanProgress::default();
|
||||
|
||||
// create checkpoint data
|
||||
let checkpoint_data = CheckpointData::new(progress.clone(), node_id.to_string());
|
||||
|
||||
// verify integrity
|
||||
assert!(checkpoint_data.verify_integrity());
|
||||
|
||||
// save and load
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
let loaded = checkpoint_manager.load_checkpoint().await.expect("Failed to load checkpoint");
|
||||
|
||||
assert!(loaded.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_stats_manager() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-stats-node";
|
||||
let stats_manager = LocalStatsManager::new(node_id, temp_dir.path());
|
||||
|
||||
// load stats
|
||||
stats_manager.load_stats().await.expect("Failed to load stats");
|
||||
|
||||
// get stats summary
|
||||
let summary = stats_manager.get_stats_summary().await;
|
||||
assert_eq!(summary.node_id, node_id);
|
||||
assert_eq!(summary.total_objects_scanned, 0);
|
||||
|
||||
// record heal triggered
|
||||
stats_manager
|
||||
.record_heal_triggered("test-object", "corruption detected")
|
||||
.await;
|
||||
|
||||
let counters = stats_manager.get_counters();
|
||||
assert_eq!(counters.total_heal_triggered.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_io_monitor_load_level_calculation() {
|
||||
let config = IOMonitorConfig {
|
||||
enable_system_monitoring: false, // use mock data
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let io_monitor = AdvancedIOMonitor::new(config);
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// update business metrics to affect load calculation
|
||||
io_monitor.update_business_metrics(50, 100, 0, 10).await;
|
||||
|
||||
// wait for a monitoring cycle
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// load level should be in a reasonable range
|
||||
assert!(matches!(
|
||||
load_level,
|
||||
LoadLevel::Low | LoadLevel::Medium | LoadLevel::High | LoadLevel::Critical
|
||||
));
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_io_throttler_load_adjustment() {
|
||||
let config = IOThrottlerConfig::default();
|
||||
let throttler = AdvancedIOThrottler::new(config);
|
||||
|
||||
// test adjust for load level
|
||||
let low_delay = throttler.adjust_for_load_level(LoadLevel::Low).await;
|
||||
let medium_delay = throttler.adjust_for_load_level(LoadLevel::Medium).await;
|
||||
let high_delay = throttler.adjust_for_load_level(LoadLevel::High).await;
|
||||
let critical_delay = throttler.adjust_for_load_level(LoadLevel::Critical).await;
|
||||
|
||||
// verify delay increment
|
||||
assert!(low_delay < medium_delay);
|
||||
assert!(medium_delay < high_delay);
|
||||
assert!(high_delay < critical_delay);
|
||||
|
||||
// verify pause logic
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::Low).await);
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::Medium).await);
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::High).await);
|
||||
assert!(throttler.should_pause_scanning(LoadLevel::Critical).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_throttler_business_pressure_simulation() {
|
||||
let throttler = AdvancedIOThrottler::default();
|
||||
|
||||
// run short time pressure test
|
||||
let simulation_duration = Duration::from_millis(500);
|
||||
let result = throttler.simulate_business_pressure(simulation_duration).await;
|
||||
|
||||
// verify simulation result
|
||||
assert!(!result.simulation_records.is_empty());
|
||||
assert!(result.total_duration >= simulation_duration);
|
||||
assert!(result.final_stats.total_decisions > 0);
|
||||
|
||||
// verify all load levels are tested
|
||||
let load_levels: std::collections::HashSet<_> = result.simulation_records.iter().map(|r| r.load_level).collect();
|
||||
|
||||
assert!(load_levels.contains(&LoadLevel::Low));
|
||||
assert!(load_levels.contains(&LoadLevel::Critical));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_node_scanner_creation_and_config() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-scanner-node".to_string();
|
||||
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_secs(30),
|
||||
disk_scan_delay: Duration::from_secs(5),
|
||||
enable_smart_scheduling: true,
|
||||
enable_checkpoint: true,
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = NodeScanner::new(node_id.clone(), config);
|
||||
|
||||
// verify node id
|
||||
assert_eq!(scanner.node_id(), &node_id);
|
||||
|
||||
// initialize stats
|
||||
scanner.initialize_stats().await.expect("Failed to initialize stats");
|
||||
|
||||
// get stats summary
|
||||
let summary = scanner.get_stats_summary().await;
|
||||
assert_eq!(summary.node_id, node_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decentralized_stats_aggregator() {
|
||||
let config = DecentralizedStatsAggregatorConfig {
|
||||
cache_ttl: Duration::from_millis(100), // short cache ttl for testing
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let aggregator = DecentralizedStatsAggregator::new(config);
|
||||
|
||||
// test cache mechanism
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// first get stats (should trigger aggregation)
|
||||
let stats1 = aggregator
|
||||
.get_aggregated_stats()
|
||||
.await
|
||||
.expect("Failed to get aggregated stats");
|
||||
|
||||
let first_call_duration = _start_time.elapsed();
|
||||
|
||||
// second get stats (should use cache)
|
||||
let cache_start = std::time::Instant::now();
|
||||
let stats2 = aggregator.get_aggregated_stats().await.expect("Failed to get cached stats");
|
||||
|
||||
let cache_call_duration = cache_start.elapsed();
|
||||
|
||||
// cache call should be faster
|
||||
assert!(cache_call_duration < first_call_duration);
|
||||
|
||||
// data should be same
|
||||
assert_eq!(stats1.aggregation_timestamp, stats2.aggregation_timestamp);
|
||||
|
||||
// wait for cache expiration
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// third get should refresh data
|
||||
let stats3 = aggregator
|
||||
.get_aggregated_stats()
|
||||
.await
|
||||
.expect("Failed to get refreshed stats");
|
||||
|
||||
// timestamp should be different
|
||||
assert!(stats3.aggregation_timestamp > stats1.aggregation_timestamp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scanner_performance_impact() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "performance-test-node".to_string();
|
||||
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_millis(100), // fast scan for testing
|
||||
disk_scan_delay: Duration::from_millis(10),
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = NodeScanner::new(node_id, config);
|
||||
|
||||
// simulate business workload
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// update business metrics for high load
|
||||
scanner.update_business_metrics(1500, 3000, 500, 800).await;
|
||||
|
||||
// get io monitor and throttler
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let throttler = scanner.get_io_throttler();
|
||||
|
||||
// start io monitor
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// wait for monitor system to stabilize and trigger throttling - increase wait time
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// simulate some io operations to trigger throttling mechanism
|
||||
for _ in 0..10 {
|
||||
let _current_metrics = io_monitor.get_current_metrics().await;
|
||||
let metrics_snapshot = rustfs_ahm::scanner::io_throttler::MetricsSnapshot {
|
||||
iops: 1000,
|
||||
latency: 100,
|
||||
cpu_usage: 80,
|
||||
memory_usage: 70,
|
||||
};
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
let _decision = throttler.make_throttle_decision(load_level, Some(metrics_snapshot)).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
// check if load level is correctly responded
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// in high load, scanner should automatically adjust
|
||||
let throttle_stats = throttler.get_throttle_stats().await;
|
||||
|
||||
println!("Performance test results:");
|
||||
println!(" Load level: {load_level:?}");
|
||||
println!(" Throttle decisions: {}", throttle_stats.total_decisions);
|
||||
println!(" Average delay: {:?}", throttle_stats.average_delay);
|
||||
|
||||
// verify performance impact control - if load is high enough, there should be throttling delay
|
||||
if load_level != LoadLevel::Low {
|
||||
assert!(throttle_stats.average_delay > Duration::from_millis(0));
|
||||
} else {
|
||||
// in low load, there should be no throttling delay
|
||||
assert!(throttle_stats.average_delay >= Duration::from_millis(0));
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_recovery_resilience() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "resilience-test-node";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
// verify checkpoint manager
|
||||
let result = checkpoint_manager.load_checkpoint().await.unwrap();
|
||||
assert!(result.is_none());
|
||||
|
||||
// create and save checkpoint
|
||||
let progress = ScanProgress {
|
||||
current_cycle: 10,
|
||||
current_disk_index: 3,
|
||||
last_scan_key: Some("recovery-test-key".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
// verify recovery
|
||||
let recovered = checkpoint_manager
|
||||
.load_checkpoint()
|
||||
.await
|
||||
.expect("Failed to load checkpoint")
|
||||
.expect("No checkpoint recovered");
|
||||
|
||||
assert_eq!(recovered.current_cycle, 10);
|
||||
assert_eq!(recovered.current_disk_index, 3);
|
||||
|
||||
// cleanup checkpoint
|
||||
checkpoint_manager
|
||||
.cleanup_checkpoint()
|
||||
.await
|
||||
.expect("Failed to cleanup checkpoint");
|
||||
|
||||
// verify cleanup
|
||||
let after_cleanup = checkpoint_manager.load_checkpoint().await.unwrap();
|
||||
assert!(after_cleanup.is_none());
|
||||
}
|
||||
|
||||
pub async fn create_test_scanner(temp_dir: &TempDir) -> NodeScanner {
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_millis(50),
|
||||
disk_scan_delay: Duration::from_millis(10),
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
NodeScanner::new("integration-test-node".to_string(), config)
|
||||
}
|
||||
|
||||
pub struct PerformanceBenchmark {
|
||||
pub _scanner_overhead_ms: u64,
|
||||
pub business_impact_percentage: f64,
|
||||
pub _throttle_effectiveness: f64,
|
||||
}
|
||||
|
||||
impl PerformanceBenchmark {
|
||||
pub fn meets_optimization_goals(&self) -> bool {
|
||||
self.business_impact_percentage < 10.0
|
||||
}
|
||||
}
|
||||
@@ -204,10 +204,10 @@ impl TargetFactory for MQTTTargetFactory {
|
||||
if !std::path::Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
if let Some(qos_str) = config.lookup(MQTT_QOS) {
|
||||
if qos_str == "0" {
|
||||
warn!("Using queue_dir with QoS 0 may result in event loss");
|
||||
}
|
||||
if let Some(qos_str) = config.lookup(MQTT_QOS)
|
||||
&& qos_str == "0"
|
||||
{
|
||||
warn!("Using queue_dir with QoS 0 may result in event loss");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use futures::stream::FuturesUnordered;
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, audit::AUDIT_ROUTE_PREFIX};
|
||||
use rustfs_ecstore::config::{Config, KVS};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::{Target, TargetError, target::ChannelTargetType};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -138,12 +139,11 @@ impl AuditRegistry {
|
||||
format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}")
|
||||
.to_uppercase();
|
||||
for (key, value) in &all_env {
|
||||
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false) {
|
||||
if let Some(id) = key.strip_prefix(&enable_prefix) {
|
||||
if !id.is_empty() {
|
||||
instance_ids_from_env.insert(id.to_lowercase());
|
||||
}
|
||||
}
|
||||
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false)
|
||||
&& let Some(id) = key.strip_prefix(&enable_prefix)
|
||||
&& !id.is_empty()
|
||||
{
|
||||
instance_ids_from_env.insert(id.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +292,10 @@ impl AuditRegistry {
|
||||
for section in sections {
|
||||
let mut section_map: std::collections::HashMap<String, KVS> = std::collections::HashMap::new();
|
||||
// Add default item
|
||||
if let Some(default_kvs) = section_defaults.get(§ion) {
|
||||
if !default_kvs.is_empty() {
|
||||
section_map.insert(DEFAULT_DELIMITER.to_string(), default_kvs.clone());
|
||||
}
|
||||
if let Some(default_kvs) = section_defaults.get(§ion)
|
||||
&& !default_kvs.is_empty()
|
||||
{
|
||||
section_map.insert(DEFAULT_DELIMITER.to_string(), default_kvs.clone());
|
||||
}
|
||||
|
||||
// Add successful instance item
|
||||
@@ -393,4 +393,80 @@ impl AuditRegistry {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a unique key for a target based on its type and ID
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_type` - The type of the target (e.g., "webhook", "mqtt").
|
||||
/// * `target_id` - The identifier for the target instance.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `String` - The unique key for the target.
|
||||
pub fn create_key(&self, target_type: &str, target_id: &str) -> String {
|
||||
let key = TargetID::new(target_id.to_string(), target_type.to_string());
|
||||
info!(target_type = %target_type, "Create key for {}", key);
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
/// Enables a target (placeholder, assumes target exists)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_type` - The type of the target (e.g., "webhook", "mqtt").
|
||||
/// * `target_id` - The identifier for the target instance.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure.
|
||||
pub fn enable_target(&self, target_type: &str, target_id: &str) -> AuditResult<()> {
|
||||
let key = self.create_key(target_type, target_id);
|
||||
if self.get_target(&key).is_some() {
|
||||
info!("Target {}-{} enabled", target_type, target_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuditError::Configuration(
|
||||
format!("Target not found: {}-{}", target_type, target_id),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables a target (placeholder, assumes target exists)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_type` - The type of the target (e.g., "webhook", "mqtt").
|
||||
/// * `target_id` - The identifier for the target instance.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure.
|
||||
pub fn disable_target(&self, target_type: &str, target_id: &str) -> AuditResult<()> {
|
||||
let key = self.create_key(target_type, target_id);
|
||||
if self.get_target(&key).is_some() {
|
||||
info!("Target {}-{} disabled", target_type, target_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuditError::Configuration(
|
||||
format!("Target not found: {}-{}", target_type, target_id),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Upserts a target into the registry
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_type` - The type of the target (e.g., "webhook", "mqtt").
|
||||
/// * `target_id` - The identifier for the target instance.
|
||||
/// * `target` - The target instance to be upserted.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure.
|
||||
pub fn upsert_target(
|
||||
&mut self,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
target: Box<dyn Target<AuditEntry> + Send + Sync>,
|
||||
) -> AuditResult<()> {
|
||||
let key = self.create_key(target_type, target_id);
|
||||
self.targets.insert(key, target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+26
-25
@@ -274,9 +274,9 @@ impl AuditSystem {
|
||||
drop(state);
|
||||
|
||||
let registry = self.registry.lock().await;
|
||||
let target_ids = registry.list_targets();
|
||||
let target_keys = registry.list_targets();
|
||||
|
||||
if target_ids.is_empty() {
|
||||
if target_keys.is_empty() {
|
||||
warn!("No audit targets configured for dispatch");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -284,22 +284,22 @@ impl AuditSystem {
|
||||
// Dispatch to all targets concurrently
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for target_id in target_ids {
|
||||
if let Some(target) = registry.get_target(&target_id) {
|
||||
for target_key in target_keys {
|
||||
if let Some(target) = registry.get_target(&target_key) {
|
||||
let entry_clone = Arc::clone(&entry);
|
||||
let target_id_clone = target_id.clone();
|
||||
let target_key_clone = target_key.clone();
|
||||
|
||||
// Create EntityTarget for the audit log entry
|
||||
let entity_target = EntityTarget {
|
||||
object_name: entry.api.name.clone().unwrap_or_default(),
|
||||
bucket_name: entry.api.bucket.clone().unwrap_or_default(),
|
||||
event_name: rustfs_targets::EventName::ObjectCreatedPut, // Default, should be derived from entry
|
||||
event_name: entry.event, // Default, should be derived from entry
|
||||
data: (*entry_clone).clone(),
|
||||
};
|
||||
|
||||
let task = async move {
|
||||
let result = target.save(Arc::new(entity_target)).await;
|
||||
(target_id_clone, result)
|
||||
(target_key_clone, result)
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
@@ -312,14 +312,14 @@ impl AuditSystem {
|
||||
let mut errors = Vec::new();
|
||||
let mut success_count = 0;
|
||||
|
||||
for (target_id, result) in results {
|
||||
for (target_key, result) in results {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
success_count += 1;
|
||||
observability::record_target_success();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target_id = %target_id, error = %e, "Failed to dispatch audit log to target");
|
||||
error!(target_id = %target_key, error = %e, "Failed to dispatch audit log to target");
|
||||
errors.push(e);
|
||||
observability::record_target_failure();
|
||||
}
|
||||
@@ -360,18 +360,18 @@ impl AuditSystem {
|
||||
drop(state);
|
||||
|
||||
let registry = self.registry.lock().await;
|
||||
let target_ids = registry.list_targets();
|
||||
let target_keys = registry.list_targets();
|
||||
|
||||
if target_ids.is_empty() {
|
||||
if target_keys.is_empty() {
|
||||
warn!("No audit targets configured for batch dispatch");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for target_id in target_ids {
|
||||
if let Some(target) = registry.get_target(&target_id) {
|
||||
for target_key in target_keys {
|
||||
if let Some(target) = registry.get_target(&target_key) {
|
||||
let entries_clone: Vec<_> = entries.iter().map(Arc::clone).collect();
|
||||
let target_id_clone = target_id.clone();
|
||||
let target_key_clone = target_key.clone();
|
||||
|
||||
let task = async move {
|
||||
let mut success_count = 0;
|
||||
@@ -380,7 +380,7 @@ impl AuditSystem {
|
||||
let entity_target = EntityTarget {
|
||||
object_name: entry.api.name.clone().unwrap_or_default(),
|
||||
bucket_name: entry.api.bucket.clone().unwrap_or_default(),
|
||||
event_name: rustfs_targets::EventName::ObjectCreatedPut,
|
||||
event_name: entry.event,
|
||||
data: (*entry).clone(),
|
||||
};
|
||||
match target.save(Arc::new(entity_target)).await {
|
||||
@@ -388,7 +388,7 @@ impl AuditSystem {
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
(target_id_clone, success_count, errors)
|
||||
(target_key_clone, success_count, errors)
|
||||
};
|
||||
tasks.push(task);
|
||||
}
|
||||
@@ -418,6 +418,7 @@ impl AuditSystem {
|
||||
}
|
||||
|
||||
/// Starts the audit stream processing for a target with batching and retry logic
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `store` - The store from which to read audit entries
|
||||
/// * `target` - The target to which audit entries will be sent
|
||||
@@ -501,7 +502,7 @@ impl AuditSystem {
|
||||
/// Enables a specific target
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_id` - The ID of the target to enable
|
||||
/// * `target_id` - The ID of the target to enable, TargetID to string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure
|
||||
@@ -520,7 +521,7 @@ impl AuditSystem {
|
||||
/// Disables a specific target
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_id` - The ID of the target to disable
|
||||
/// * `target_id` - The ID of the target to disable, TargetID to string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure
|
||||
@@ -539,7 +540,7 @@ impl AuditSystem {
|
||||
/// Removes a target from the system
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_id` - The ID of the target to remove
|
||||
/// * `target_id` - The ID of the target to remove, TargetID to string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `AuditResult<()>` - Result indicating success or failure
|
||||
@@ -559,7 +560,7 @@ impl AuditSystem {
|
||||
/// Updates or inserts a target
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_id` - The ID of the target to upsert
|
||||
/// * `target_id` - The ID of the target to upsert, TargetID to string
|
||||
/// * `target` - The target instance to insert or update
|
||||
///
|
||||
/// # Returns
|
||||
@@ -573,10 +574,10 @@ impl AuditSystem {
|
||||
}
|
||||
|
||||
// Remove existing target if present
|
||||
if let Some(old_target) = registry.remove_target(&target_id) {
|
||||
if let Err(e) = old_target.close().await {
|
||||
error!(target_id = %target_id, error = %e, "Failed to close old target during upsert");
|
||||
}
|
||||
if let Some(old_target) = registry.remove_target(&target_id)
|
||||
&& let Err(e) = old_target.close().await
|
||||
{
|
||||
error!(target_id = %target_id, error = %e, "Failed to close old target during upsert");
|
||||
}
|
||||
|
||||
registry.add_target(target_id.clone(), target);
|
||||
@@ -596,7 +597,7 @@ impl AuditSystem {
|
||||
/// Gets information about a specific target
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `target_id` - The ID of the target to retrieve
|
||||
/// * `target_id` - The ID of the target to retrieve, TargetID to string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Option<String>` - Target ID if found
|
||||
|
||||
@@ -436,20 +436,12 @@ impl DataUsageEntry {
|
||||
self.obj_sizes.add(summary.total_size as u64);
|
||||
self.obj_versions.add(summary.versions as u64);
|
||||
|
||||
let replication_stats = if self.replication_stats.is_none() {
|
||||
self.replication_stats = Some(ReplicationAllStats::default());
|
||||
self.replication_stats.as_mut().unwrap()
|
||||
} else {
|
||||
self.replication_stats.as_mut().unwrap()
|
||||
};
|
||||
let replication_stats = self.replication_stats.get_or_insert_with(ReplicationAllStats::default);
|
||||
replication_stats.replica_size += summary.replica_size as u64;
|
||||
replication_stats.replica_count += summary.replica_count as u64;
|
||||
|
||||
for (arn, st) in &summary.repl_target_stats {
|
||||
let tgt_stat = replication_stats
|
||||
.targets
|
||||
.entry(arn.to_string())
|
||||
.or_insert(ReplicationStats::default());
|
||||
let tgt_stat = replication_stats.targets.entry(arn.to_string()).or_default();
|
||||
tgt_stat.pending_size += st.pending_size as u64;
|
||||
tgt_stat.failed_size += st.failed_size as u64;
|
||||
tgt_stat.replicated_size += st.replicated_size as u64;
|
||||
@@ -605,13 +597,12 @@ impl DataUsageCache {
|
||||
|
||||
pub fn search_parent(&self, hash: &DataUsageHash) -> Option<DataUsageHash> {
|
||||
let want = hash.key();
|
||||
if let Some(last_index) = want.rfind('/') {
|
||||
if let Some(v) = self.find(&want[0..last_index]) {
|
||||
if v.children.contains(&want) {
|
||||
let found = hash_path(&want[0..last_index]);
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
if let Some(last_index) = want.rfind('/')
|
||||
&& let Some(v) = self.find(&want[0..last_index])
|
||||
&& v.children.contains(&want)
|
||||
{
|
||||
let found = hash_path(&want[0..last_index]);
|
||||
return Some(found);
|
||||
}
|
||||
|
||||
for (k, v) in self.cache.iter() {
|
||||
@@ -1150,10 +1141,10 @@ impl DataUsageInfo {
|
||||
self.buckets_count = self.buckets_usage.len() as u64;
|
||||
|
||||
// Update last update time
|
||||
if let Some(other_update) = other.last_update {
|
||||
if self.last_update.is_none() || other_update > self.last_update.unwrap() {
|
||||
self.last_update = Some(other_update);
|
||||
}
|
||||
if let Some(other_update) = other.last_update
|
||||
&& (self.last_update.is_none() || other_update > self.last_update.unwrap())
|
||||
{
|
||||
self.last_update = Some(other_update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#![allow(non_upper_case_globals)] // FIXME
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -26,6 +27,30 @@ pub static GLOBAL_RUSTFS_ADDR: LazyLock<RwLock<String>> = LazyLock::new(|| RwLoc
|
||||
pub static GLOBAL_CONN_MAP: LazyLock<RwLock<HashMap<String, Channel>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||
/// Global initialization time of the RustFS node.
|
||||
pub static GLOBAL_INIT_TIME: LazyLock<RwLock<Option<DateTime<Utc>>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
/// Set the global local node name.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - A string slice representing the local node name.
|
||||
pub async fn set_global_local_node_name(name: &str) {
|
||||
*GLOBAL_LOCAL_NODE_NAME.write().await = name.to_string();
|
||||
}
|
||||
|
||||
/// Set the global RustFS initialization time to the current UTC time.
|
||||
pub async fn set_global_init_time_now() {
|
||||
let now = Utc::now();
|
||||
*GLOBAL_INIT_TIME.write().await = Some(now);
|
||||
}
|
||||
|
||||
/// Get the global RustFS initialization time.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Option<DateTime<Utc>>` - The initialization time if set.
|
||||
pub async fn get_global_init_time() -> Option<DateTime<Utc>> {
|
||||
*GLOBAL_INIT_TIME.read().await
|
||||
}
|
||||
|
||||
/// Set the global RustFS address used for gRPC connections.
|
||||
///
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Display for HealItemType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum DriveState {
|
||||
Ok,
|
||||
Offline,
|
||||
@@ -59,7 +59,7 @@ pub enum DriveState {
|
||||
PermissionDenied,
|
||||
Faulty,
|
||||
RootMount,
|
||||
Unknown,
|
||||
Unknown(String),
|
||||
Unformatted, // only returned by disk
|
||||
}
|
||||
|
||||
@@ -73,12 +73,28 @@ impl DriveState {
|
||||
DriveState::PermissionDenied => "permission-denied",
|
||||
DriveState::Faulty => "faulty",
|
||||
DriveState::RootMount => "root-mount",
|
||||
DriveState::Unknown => "unknown",
|
||||
DriveState::Unknown(reason) => reason,
|
||||
DriveState::Unformatted => "unformatted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for DriveState {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
DriveState::Unknown(reason) => DriveState::Unknown(reason.clone()),
|
||||
DriveState::Ok => DriveState::Ok,
|
||||
DriveState::Offline => DriveState::Offline,
|
||||
DriveState::Corrupt => DriveState::Corrupt,
|
||||
DriveState::Missing => DriveState::Missing,
|
||||
DriveState::PermissionDenied => DriveState::PermissionDenied,
|
||||
DriveState::Faulty => DriveState::Faulty,
|
||||
DriveState::RootMount => DriveState::RootMount,
|
||||
DriveState::Unformatted => DriveState::Unformatted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DriveState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_str())
|
||||
@@ -212,6 +228,8 @@ pub struct HealChannelRequest {
|
||||
pub bucket: String,
|
||||
/// Object prefix (optional)
|
||||
pub object_prefix: Option<String>,
|
||||
/// Object version ID (optional)
|
||||
pub object_version_id: Option<String>,
|
||||
/// Force start heal
|
||||
pub force_start: bool,
|
||||
/// Priority
|
||||
@@ -346,6 +364,7 @@ pub fn create_heal_request(
|
||||
id: Uuid::new_v4().to_string(),
|
||||
bucket,
|
||||
object_prefix,
|
||||
object_version_id: None,
|
||||
force_start,
|
||||
priority: priority.unwrap_or_default(),
|
||||
pool_index: None,
|
||||
@@ -374,6 +393,7 @@ pub fn create_heal_request_with_options(
|
||||
id: Uuid::new_v4().to_string(),
|
||||
bucket,
|
||||
object_prefix,
|
||||
object_version_id: None,
|
||||
force_start,
|
||||
priority: priority.unwrap_or_default(),
|
||||
pool_index,
|
||||
@@ -403,10 +423,10 @@ fn lc_get_prefix(rule: &LifecycleRule) -> String {
|
||||
} else if let Some(filter) = &rule.filter {
|
||||
if let Some(p) = &filter.prefix {
|
||||
return p.to_string();
|
||||
} else if let Some(and) = &filter.and {
|
||||
if let Some(p) = &and.prefix {
|
||||
return p.to_string();
|
||||
}
|
||||
} else if let Some(and) = &filter.and
|
||||
&& let Some(p) = &and.prefix
|
||||
{
|
||||
return p.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,21 +495,19 @@ pub fn rep_has_active_rules(config: &ReplicationConfiguration, prefix: &str, rec
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !prefix.is_empty() {
|
||||
if let Some(filter) = &rule.filter {
|
||||
if let Some(r_prefix) = &filter.prefix {
|
||||
if !r_prefix.is_empty() {
|
||||
// incoming prefix must be in rule prefix
|
||||
if !recursive && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
// If recursive, we can skip this rule if it doesn't match the tested prefix or level below prefix
|
||||
// does not match
|
||||
if recursive && !r_prefix.starts_with(prefix) && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !prefix.is_empty()
|
||||
&& let Some(filter) = &rule.filter
|
||||
&& let Some(r_prefix) = &filter.prefix
|
||||
&& !r_prefix.is_empty()
|
||||
{
|
||||
// incoming prefix must be in rule prefix
|
||||
if !recursive && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
// If recursive, we can skip this rule if it doesn't match the tested prefix or level below prefix
|
||||
// does not match
|
||||
if recursive && !r_prefix.starts_with(prefix) && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -503,6 +521,7 @@ pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPri
|
||||
bucket: "".to_string(),
|
||||
object_prefix: None,
|
||||
disk: Some(set_disk_id),
|
||||
object_version_id: None,
|
||||
force_start: false,
|
||||
priority: priority.unwrap_or_default(),
|
||||
pool_index: None,
|
||||
|
||||
@@ -18,6 +18,7 @@ use rustfs_madmin::metrics::ScannerMetrics as M_ScannerMetrics;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Display,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc, OnceLock,
|
||||
@@ -95,6 +96,11 @@ pub enum Metric {
|
||||
ApplyNonCurrent,
|
||||
HealAbandonedVersion,
|
||||
|
||||
// Quota metrics:
|
||||
QuotaCheck,
|
||||
QuotaViolation,
|
||||
QuotaSync,
|
||||
|
||||
// START Trace metrics:
|
||||
StartTrace,
|
||||
ScanObject, // Scan object. All operations included.
|
||||
@@ -115,7 +121,7 @@ pub enum Metric {
|
||||
|
||||
impl Metric {
|
||||
/// Convert to string representation for metrics
|
||||
pub fn as_str(self) -> &'static str {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadMetadata => "read_metadata",
|
||||
Self::CheckMissing => "check_missing",
|
||||
@@ -130,6 +136,9 @@ impl Metric {
|
||||
Self::CleanAbandoned => "clean_abandoned",
|
||||
Self::ApplyNonCurrent => "apply_non_current",
|
||||
Self::HealAbandonedVersion => "heal_abandoned_version",
|
||||
Self::QuotaCheck => "quota_check",
|
||||
Self::QuotaViolation => "quota_violation",
|
||||
Self::QuotaSync => "quota_sync",
|
||||
Self::StartTrace => "start_trace",
|
||||
Self::ScanObject => "scan_object",
|
||||
Self::HealAbandonedObject => "heal_abandoned_object",
|
||||
@@ -162,15 +171,18 @@ impl Metric {
|
||||
10 => Some(Self::CleanAbandoned),
|
||||
11 => Some(Self::ApplyNonCurrent),
|
||||
12 => Some(Self::HealAbandonedVersion),
|
||||
13 => Some(Self::StartTrace),
|
||||
14 => Some(Self::ScanObject),
|
||||
15 => Some(Self::HealAbandonedObject),
|
||||
16 => Some(Self::LastRealtime),
|
||||
17 => Some(Self::ScanFolder),
|
||||
18 => Some(Self::ScanCycle),
|
||||
19 => Some(Self::ScanBucketDrive),
|
||||
20 => Some(Self::CompactFolder),
|
||||
21 => Some(Self::Last),
|
||||
13 => Some(Self::QuotaCheck),
|
||||
14 => Some(Self::QuotaViolation),
|
||||
15 => Some(Self::QuotaSync),
|
||||
16 => Some(Self::StartTrace),
|
||||
17 => Some(Self::ScanObject),
|
||||
18 => Some(Self::HealAbandonedObject),
|
||||
19 => Some(Self::LastRealtime),
|
||||
20 => Some(Self::ScanFolder),
|
||||
21 => Some(Self::ScanCycle),
|
||||
22 => Some(Self::ScanBucketDrive),
|
||||
23 => Some(Self::CompactFolder),
|
||||
24 => Some(Self::Last),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -460,27 +472,32 @@ impl Metrics {
|
||||
metrics.current_started = cycle.started;
|
||||
}
|
||||
|
||||
// Replace default start time with global init time if it's the placeholder
|
||||
if let Some(init_time) = crate::get_global_init_time().await {
|
||||
metrics.current_started = init_time;
|
||||
}
|
||||
|
||||
metrics.collected_at = Utc::now();
|
||||
metrics.active_paths = self.get_current_paths().await;
|
||||
|
||||
// Lifetime operations
|
||||
for i in 0..Metric::Last as usize {
|
||||
let count = self.operations[i].load(Ordering::Relaxed);
|
||||
if count > 0 {
|
||||
if let Some(metric) = Metric::from_index(i) {
|
||||
metrics.life_time_ops.insert(metric.as_str().to_string(), count);
|
||||
}
|
||||
if count > 0
|
||||
&& let Some(metric) = Metric::from_index(i)
|
||||
{
|
||||
metrics.life_time_ops.insert(metric.as_str().to_string(), count);
|
||||
}
|
||||
}
|
||||
|
||||
// Last minute statistics for realtime metrics
|
||||
for i in 0..Metric::LastRealtime as usize {
|
||||
let last_min = self.latency[i].total().await;
|
||||
if last_min.n > 0 {
|
||||
if let Some(_metric) = Metric::from_index(i) {
|
||||
// Convert to madmin TimedAction format if needed
|
||||
// This would require implementing the conversion
|
||||
}
|
||||
if last_min.n > 0
|
||||
&& let Some(_metric) = Metric::from_index(i)
|
||||
{
|
||||
// Convert to madmin TimedAction format if needed
|
||||
// This would require implementing the conversion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,8 +506,8 @@ impl Metrics {
|
||||
}
|
||||
|
||||
// Type aliases for compatibility with existing code
|
||||
pub type UpdateCurrentPathFn = Arc<dyn Fn(&str) -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send + Sync>;
|
||||
pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send + Sync>;
|
||||
pub type UpdateCurrentPathFn = Arc<dyn Fn(&str) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
|
||||
pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
|
||||
|
||||
/// Create a current path updater for tracking scan progress
|
||||
pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||
@@ -506,7 +523,7 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
|
||||
|
||||
let update_fn = {
|
||||
let tracker = Arc::clone(&tracker);
|
||||
Arc::new(move |path: &str| -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Arc::new(move |path: &str| -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let tracker = Arc::clone(&tracker);
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
@@ -517,7 +534,7 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
|
||||
|
||||
let done_fn = {
|
||||
let disk_name = disk_name.clone();
|
||||
Arc::new(move || -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Arc::new(move || -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let disk_name = disk_name.clone();
|
||||
Box::pin(async move {
|
||||
global_metrics().current_paths.write().await.remove(&disk_name);
|
||||
|
||||
@@ -62,8 +62,8 @@ pub const DEFAULT_CONSOLE_ENABLE: bool = true;
|
||||
/// Default OBS configuration endpoint
|
||||
/// Environment variable: DEFAULT_OBS_ENDPOINT
|
||||
/// Command line argument: --obs-endpoint
|
||||
/// Example: DEFAULT_OBS_ENDPOINT="http://localost:4317"
|
||||
/// Example: --obs-endpoint http://localost:4317
|
||||
/// Example: DEFAULT_OBS_ENDPOINT="http://localost:4318"
|
||||
/// Example: --obs-endpoint http://localost:4318
|
||||
pub const DEFAULT_OBS_ENDPOINT: &str = "";
|
||||
|
||||
/// Default TLS key for rustfs
|
||||
@@ -98,6 +98,12 @@ pub const RUSTFS_HTTP_PREFIX: &str = "http://";
|
||||
/// Default value: https://
|
||||
pub const RUSTFS_HTTPS_PREFIX: &str = "https://";
|
||||
|
||||
/// Environment variable for rustfs address
|
||||
/// This is the environment variable for rustfs address.
|
||||
/// It is used to bind the server to a specific address.
|
||||
/// Example: RUSTFS_ADDRESS=":9000"
|
||||
pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS";
|
||||
|
||||
/// Default port for rustfs
|
||||
/// This is the default port for rustfs.
|
||||
/// This is used to bind the server to a specific port.
|
||||
@@ -168,13 +174,7 @@ pub const DEFAULT_OBS_LOG_STDOUT_ENABLED: bool = false;
|
||||
pub const KI_B: usize = 1024;
|
||||
/// Constant representing 1 Mebibyte (1024 * 1024 bytes)
|
||||
/// Default value: 1048576
|
||||
pub const MI_B: usize = 1024 * 1024;
|
||||
|
||||
/// Environment variable for gRPC authentication token
|
||||
/// Used to set the authentication token for gRPC communication
|
||||
/// Example: RUSTFS_GRPC_AUTH_TOKEN=your_token_here
|
||||
/// Default value: No default value. RUSTFS_SECRET_KEY value is recommended.
|
||||
pub const ENV_GRPC_AUTH_TOKEN: &str = "RUSTFS_GRPC_AUTH_TOKEN";
|
||||
pub const MI_B: usize = 1024 * KI_B;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -20,6 +20,10 @@ pub(crate) mod env;
|
||||
pub(crate) mod heal;
|
||||
pub(crate) mod object;
|
||||
pub(crate) mod profiler;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod proxy;
|
||||
pub(crate) mod quota;
|
||||
pub(crate) mod runtime;
|
||||
pub(crate) mod scanner;
|
||||
pub(crate) mod targets;
|
||||
pub(crate) mod tls;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Protocol server configuration constants
|
||||
|
||||
/// Default FTP server bind address (non-encrypted)
|
||||
pub const DEFAULT_FTP_ADDRESS: &str = "0.0.0.0:8021";
|
||||
|
||||
/// Default FTPS server bind address (FTP over TLS)
|
||||
pub const DEFAULT_FTPS_ADDRESS: &str = "0.0.0.0:8022";
|
||||
|
||||
/// Default FTP passive ports range (optional)
|
||||
pub const DEFAULT_FTP_PASSIVE_PORTS: Option<&str> = None;
|
||||
|
||||
/// Default FTPS passive ports range (optional)
|
||||
pub const DEFAULT_FTPS_PASSIVE_PORTS: Option<&str> = None;
|
||||
|
||||
/// Default FTP external IP (auto-detected)
|
||||
pub const DEFAULT_FTP_EXTERNAL_IP: Option<&str> = None;
|
||||
|
||||
/// Default FTPS external IP (auto-detected)
|
||||
pub const DEFAULT_FTPS_EXTERNAL_IP: Option<&str> = None;
|
||||
|
||||
/// Environment variable names
|
||||
pub const ENV_FTP_ENABLE: &str = "RUSTFS_FTP_ENABLE";
|
||||
pub const ENV_FTP_ADDRESS: &str = "RUSTFS_FTP_ADDRESS";
|
||||
pub const ENV_FTP_PASSIVE_PORTS: &str = "RUSTFS_FTP_PASSIVE_PORTS";
|
||||
pub const ENV_FTP_EXTERNAL_IP: &str = "RUSTFS_FTP_EXTERNAL_IP";
|
||||
|
||||
pub const ENV_FTPS_ENABLE: &str = "RUSTFS_FTPS_ENABLE";
|
||||
pub const ENV_FTPS_ADDRESS: &str = "RUSTFS_FTPS_ADDRESS";
|
||||
pub const ENV_FTPS_TLS_ENABLED: &str = "RUSTFS_FTPS_TLS_ENABLED";
|
||||
pub const ENV_FTPS_CERTS_DIR: &str = "RUSTFS_FTPS_CERTS_DIR";
|
||||
pub const ENV_FTPS_CA_FILE: &str = "RUSTFS_FTPS_CA_FILE";
|
||||
pub const ENV_FTPS_PASSIVE_PORTS: &str = "RUSTFS_FTPS_PASSIVE_PORTS";
|
||||
pub const ENV_FTPS_EXTERNAL_IP: &str = "RUSTFS_FTPS_EXTERNAL_IP";
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::DEFAULT_LOG_LEVEL;
|
||||
|
||||
// ==================== Base Proxy Configuration ====================
|
||||
/// Environment variable to enable the trusted proxy middleware.
|
||||
pub const ENV_TRUSTED_PROXY_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_ENABLED";
|
||||
/// Trusted proxy middleware is enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_ENABLED: bool = true;
|
||||
|
||||
/// Environment variable for the proxy validation mode.
|
||||
pub const ENV_TRUSTED_PROXY_VALIDATION_MODE: &str = "RUSTFS_TRUSTED_PROXY_VALIDATION_MODE";
|
||||
/// Default validation mode is "hop_by_hop".
|
||||
pub const DEFAULT_TRUSTED_PROXY_VALIDATION_MODE: &str = "hop_by_hop";
|
||||
|
||||
/// Environment variable to enable RFC 7239 "Forwarded" header support.
|
||||
pub const ENV_TRUSTED_PROXY_ENABLE_RFC7239: &str = "RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239";
|
||||
/// RFC 7239 support is enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239: bool = true;
|
||||
|
||||
/// Environment variable for the maximum allowed proxy hops.
|
||||
pub const ENV_TRUSTED_PROXY_MAX_HOPS: &str = "RUSTFS_TRUSTED_PROXY_MAX_HOPS";
|
||||
/// Default maximum hops is 10.
|
||||
pub const DEFAULT_TRUSTED_PROXY_MAX_HOPS: usize = 10;
|
||||
|
||||
/// Environment variable to enable proxy chain continuity checks.
|
||||
pub const ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK: &str = "RUSTFS_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK";
|
||||
/// Continuity checks are enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK: bool = true;
|
||||
|
||||
/// Environment variable to enable logging of failed proxy validations.
|
||||
pub const ENV_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS: &str = "RUSTFS_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS";
|
||||
/// Logging of failed validations is enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS: bool = true;
|
||||
|
||||
// ==================== Trusted Proxy Networks ====================
|
||||
/// Environment variable for the list of trusted proxy networks (comma-separated IP/CIDR).
|
||||
pub const ENV_TRUSTED_PROXY_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_NETWORKS";
|
||||
/// Default trusted networks include localhost and common private ranges.
|
||||
pub const DEFAULT_TRUSTED_PROXY_PROXIES: &str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8";
|
||||
|
||||
/// Environment variable for additional trusted proxy networks (production specific).
|
||||
pub const ENV_TRUSTED_PROXY_EXTRA_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_EXTRA_NETWORKS";
|
||||
/// No extra trusted networks by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_EXTRA_PROXIES: &str = "";
|
||||
|
||||
/// Environment variable for individual trusted proxy IPs.
|
||||
pub const ENV_TRUSTED_PROXY_IPS: &str = "RUSTFS_TRUSTED_PROXY_IPS";
|
||||
/// No individual trusted IPs by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_IPS: &str = "";
|
||||
|
||||
/// Environment variable for private network ranges used in internal validation.
|
||||
pub const ENV_TRUSTED_PROXY_PRIVATE_NETWORKS: &str = "RUSTFS_TRUSTED_PROXY_PRIVATE_NETWORKS";
|
||||
/// Default private networks include common RFC 1918 and RFC 4193 ranges.
|
||||
pub const DEFAULT_TRUSTED_PROXY_PRIVATE_NETWORKS: &str = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8";
|
||||
|
||||
// ==================== Cache Configuration ====================
|
||||
/// Environment variable for the proxy validation cache capacity.
|
||||
pub const ENV_TRUSTED_PROXY_CACHE_CAPACITY: &str = "RUSTFS_TRUSTED_PROXY_CACHE_CAPACITY";
|
||||
/// Default cache capacity is 10,000 entries.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CACHE_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Environment variable for the cache entry time-to-live (TTL) in seconds.
|
||||
pub const ENV_TRUSTED_PROXY_CACHE_TTL_SECONDS: &str = "RUSTFS_TRUSTED_PROXY_CACHE_TTL_SECONDS";
|
||||
/// Default cache TTL is 300 seconds (5 minutes).
|
||||
pub const DEFAULT_TRUSTED_PROXY_CACHE_TTL_SECONDS: u64 = 300;
|
||||
|
||||
/// Environment variable for the cache cleanup interval in seconds.
|
||||
pub const ENV_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL: &str = "RUSTFS_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL";
|
||||
/// Default cleanup interval is 60 seconds.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL: u64 = 60;
|
||||
|
||||
// ==================== Monitoring Configuration ====================
|
||||
/// Environment variable to enable Prometheus metrics.
|
||||
pub const ENV_TRUSTED_PROXY_METRICS_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_METRICS_ENABLED";
|
||||
/// Metrics are enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_METRICS_ENABLED: bool = true;
|
||||
|
||||
/// Environment variable for the application log level.
|
||||
pub const ENV_TRUSTED_PROXIES_LOG_LEVEL: &str = "RUSTFS_TRUSTED_PROXY_LOG_LEVEL";
|
||||
/// Default log level is "info".
|
||||
pub const DEFAULT_TRUSTED_PROXIES_LOG_LEVEL: &str = DEFAULT_LOG_LEVEL;
|
||||
|
||||
/// Environment variable to enable structured JSON logging.
|
||||
pub const ENV_TRUSTED_PROXY_STRUCTURED_LOGGING: &str = "RUSTFS_TRUSTED_PROXY_STRUCTURED_LOGGING";
|
||||
/// Structured logging is disabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_STRUCTURED_LOGGING: bool = false;
|
||||
|
||||
/// Environment variable to enable distributed tracing.
|
||||
pub const ENV_TRUSTED_PROXY_TRACING_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_TRACING_ENABLED";
|
||||
/// Tracing is enabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_TRACING_ENABLED: bool = true;
|
||||
|
||||
// ==================== Cloud Integration ====================
|
||||
/// Environment variable to enable automatic cloud metadata discovery.
|
||||
pub const ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_ENABLED";
|
||||
/// Cloud metadata discovery is disabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED: bool = false;
|
||||
|
||||
/// Environment variable for the cloud metadata request timeout in seconds.
|
||||
pub const ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT";
|
||||
/// Default cloud metadata timeout is 5 seconds.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT: u64 = 5;
|
||||
|
||||
/// Environment variable to enable Cloudflare IP range integration.
|
||||
pub const ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED";
|
||||
/// Cloudflare integration is disabled by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED: bool = false;
|
||||
|
||||
/// Environment variable to force a specific cloud provider (overrides auto-detection).
|
||||
pub const ENV_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE";
|
||||
/// No forced provider by default.
|
||||
pub const DEFAULT_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE: &str = "";
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub const QUOTA_CONFIG_FILE: &str = "quota.json";
|
||||
pub const QUOTA_TYPE_HARD: &str = "HARD";
|
||||
|
||||
pub const QUOTA_EXCEEDED_ERROR_CODE: &str = "XRustfsQuotaExceeded";
|
||||
pub const QUOTA_INVALID_CONFIG_ERROR_CODE: &str = "InvalidArgument";
|
||||
pub const QUOTA_NOT_FOUND_ERROR_CODE: &str = "NoSuchBucket";
|
||||
pub const QUOTA_INTERNAL_ERROR_CODE: &str = "InternalError";
|
||||
|
||||
pub const QUOTA_API_PATH: &str = "/rustfs/admin/v3/quota/{bucket}";
|
||||
|
||||
pub const QUOTA_INVALID_TYPE_ERROR_MSG: &str = "Only HARD quota type is supported";
|
||||
pub const QUOTA_METADATA_SYSTEM_ERROR_MSG: &str = "Bucket metadata system not initialized";
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/// Environment variable name that specifies the data scanner start delay in seconds.
|
||||
/// - Purpose: Define the delay between data scanner operations.
|
||||
/// - Unit: seconds (u64).
|
||||
/// - Valid values: any positive integer.
|
||||
/// - Semantics: This delay controls how frequently the data scanner checks for and processes data; shorter delays lead to more responsive scanning but may increase system load.
|
||||
/// - Example: `export RUSTFS_DATA_SCANNER_START_DELAY_SECS=10`
|
||||
/// - Note: Choose an appropriate delay that balances scanning responsiveness with overall system performance.
|
||||
pub const ENV_DATA_SCANNER_START_DELAY_SECS: &str = "RUSTFS_DATA_SCANNER_START_DELAY_SECS";
|
||||
|
||||
/// Default data scanner start delay in seconds if not specified in the environment variable.
|
||||
/// - Value: 10 seconds.
|
||||
/// - Rationale: This default interval provides a reasonable balance between scanning responsiveness and system load for most deployments.
|
||||
/// - Adjustments: Users may modify this value via the `RUSTFS_DATA_SCANNER_START_DELAY_SECS` environment variable based on their specific scanning requirements and system performance.
|
||||
pub const DEFAULT_DATA_SCANNER_START_DELAY_SECS: u64 = 60;
|
||||
@@ -31,8 +31,16 @@ pub use constants::object::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::profiler::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::protocols::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::proxy::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::quota::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::runtime::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::scanner::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::targets::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::tls::*;
|
||||
|
||||
@@ -27,11 +27,11 @@ pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
/// Example: --secret-key rustfsadmin
|
||||
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
|
||||
|
||||
/// Environment variable for gRPC authentication token
|
||||
/// Used to set the authentication token for gRPC communication
|
||||
/// Example: RUSTFS_GRPC_AUTH_TOKEN=your_token_here
|
||||
/// Environment variable for RPC authentication token
|
||||
/// Used to set the authentication token for RPC communication
|
||||
/// Example: RUSTFS_RPC_SECRET=your_token_here
|
||||
/// Default value: No default value. RUSTFS_SECRET_KEY value is recommended.
|
||||
pub const ENV_GRPC_AUTH_TOKEN: &str = "RUSTFS_GRPC_AUTH_TOKEN";
|
||||
pub const ENV_RPC_SECRET: &str = "RUSTFS_RPC_SECRET";
|
||||
|
||||
/// IAM Policy Types
|
||||
/// Used to differentiate between embedded and inherited policies
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::{DEFAULT_SECRET_KEY, ENV_GRPC_AUTH_TOKEN, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
|
||||
use rand::{Rng, RngCore};
|
||||
use crate::{DEFAULT_SECRET_KEY, ENV_RPC_SECRET, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
|
||||
use rand::{Rng, RngExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::io::Error;
|
||||
use std::sync::OnceLock;
|
||||
use time::OffsetDateTime;
|
||||
@@ -25,8 +26,39 @@ use time::OffsetDateTime;
|
||||
/// Global active credentials
|
||||
static GLOBAL_ACTIVE_CRED: OnceLock<Credentials> = OnceLock::new();
|
||||
|
||||
/// Global gRPC authentication token
|
||||
static GLOBAL_GRPC_AUTH_TOKEN: OnceLock<String> = OnceLock::new();
|
||||
/// Global RPC authentication token
|
||||
pub static GLOBAL_RUSTFS_RPC_SECRET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Error type for credentials operations
|
||||
#[derive(Debug)]
|
||||
pub enum CredentialsError {
|
||||
/// Credentials already initialized
|
||||
AlreadyInitialized,
|
||||
/// Failed to generate access key
|
||||
AccessKeyGenerationFailed(Error),
|
||||
/// Failed to generate secret key
|
||||
SecretKeyGenerationFailed(Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for CredentialsError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CredentialsError::AlreadyInitialized => write!(f, "Credentials already initialized"),
|
||||
CredentialsError::AccessKeyGenerationFailed(e) => write!(f, "Failed to generate access key: {}", e),
|
||||
CredentialsError::SecretKeyGenerationFailed(e) => write!(f, "Failed to generate secret key: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CredentialsError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
CredentialsError::AccessKeyGenerationFailed(e) => Some(e),
|
||||
CredentialsError::SecretKeyGenerationFailed(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the global action credentials
|
||||
///
|
||||
@@ -35,15 +67,17 @@ static GLOBAL_GRPC_AUTH_TOKEN: OnceLock<String> = OnceLock::new();
|
||||
/// * `sk` - Optional secret key
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<(), Box<Credentials>>` - Ok if successful, Err with existing credentials if already initialized
|
||||
///
|
||||
/// # Panics
|
||||
/// This function panics if automatic credential generation fails when `ak` or `sk`
|
||||
/// are `None`, for example if the random number generator fails while calling
|
||||
/// `gen_access_key` or `gen_secret_key`.
|
||||
pub fn init_global_action_credentials(ak: Option<String>, sk: Option<String>) -> Result<(), Box<Credentials>> {
|
||||
let ak = ak.unwrap_or_else(|| gen_access_key(20).expect("Failed to generate access key"));
|
||||
let sk = sk.unwrap_or_else(|| gen_secret_key(32).expect("Failed to generate secret key"));
|
||||
/// * `Result<(), CredentialsError>` - Ok if successful, Err if already initialized or generation failed
|
||||
pub fn init_global_action_credentials(ak: Option<String>, sk: Option<String>) -> Result<(), CredentialsError> {
|
||||
let ak = match ak {
|
||||
Some(k) => k,
|
||||
None => gen_access_key(20).map_err(CredentialsError::AccessKeyGenerationFailed)?,
|
||||
};
|
||||
|
||||
let sk = match sk {
|
||||
Some(k) => k,
|
||||
None => gen_secret_key(32).map_err(CredentialsError::SecretKeyGenerationFailed)?,
|
||||
};
|
||||
|
||||
let cred = Credentials {
|
||||
access_key: ak,
|
||||
@@ -51,12 +85,7 @@ pub fn init_global_action_credentials(ak: Option<String>, sk: Option<String>) ->
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
GLOBAL_ACTIVE_CRED.set(cred).map_err(|e| {
|
||||
Box::new(Credentials {
|
||||
access_key: e.access_key.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
GLOBAL_ACTIVE_CRED.set(cred).map_err(|_| CredentialsError::AlreadyInitialized)
|
||||
}
|
||||
|
||||
/// Get the global action credentials
|
||||
@@ -181,20 +210,50 @@ pub fn gen_secret_key(length: usize) -> std::io::Result<String> {
|
||||
Ok(key_str)
|
||||
}
|
||||
|
||||
/// Get the gRPC authentication token from environment variable
|
||||
/// Get the RPC authentication token from environment variable
|
||||
///
|
||||
/// # Returns
|
||||
/// * `String` - The gRPC authentication token
|
||||
/// * `String` - The RPC authentication token
|
||||
///
|
||||
pub fn get_grpc_token() -> String {
|
||||
GLOBAL_GRPC_AUTH_TOKEN
|
||||
pub fn get_rpc_token() -> String {
|
||||
GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get_or_init(|| {
|
||||
env::var(ENV_GRPC_AUTH_TOKEN)
|
||||
env::var(ENV_RPC_SECRET)
|
||||
.unwrap_or_else(|_| get_global_secret_key_opt().unwrap_or_else(|| DEFAULT_SECRET_KEY.to_string()))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A wrapper struct for masking sensitive strings in Debug implementations.
|
||||
/// It avoids allocating a new String just for formatting.
|
||||
pub struct Masked<'a>(pub Option<&'a str>);
|
||||
|
||||
impl<'a> fmt::Debug for Masked<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.0 {
|
||||
None => Ok(()),
|
||||
Some(s) => {
|
||||
let len = s.len();
|
||||
if len == 0 {
|
||||
Ok(())
|
||||
} else if len == 1 {
|
||||
write!(f, "***")
|
||||
} else if len == 2 {
|
||||
write!(f, "{}***|{}", &s[0..1], len)
|
||||
} else {
|
||||
write!(f, "{}***{}|{}", &s[0..1], &s[len - 1..], len)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> fmt::Display for Masked<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials structure
|
||||
///
|
||||
/// Fields:
|
||||
@@ -209,7 +268,7 @@ pub fn get_grpc_token() -> String {
|
||||
/// - name: Optional name string
|
||||
/// - description: Optional description string
|
||||
///
|
||||
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Credentials {
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
@@ -223,6 +282,23 @@ pub struct Credentials {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Credentials {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Credentials")
|
||||
.field("access_key", &self.access_key)
|
||||
.field("secret_key", &Masked(Some(&self.secret_key)))
|
||||
.field("session_token", &self.session_token)
|
||||
.field("expiration", &self.expiration)
|
||||
.field("status", &self.status)
|
||||
.field("parent_user", &self.parent_user)
|
||||
.field("groups", &self.groups)
|
||||
.field("claims", &self.claims)
|
||||
.field("name", &self.name)
|
||||
.field("description", &self.description)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if self.expiration.is_none() {
|
||||
@@ -383,4 +459,28 @@ mod tests {
|
||||
assert_eq!(sk.len(), 32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_debug() {
|
||||
// Test None
|
||||
assert_eq!(format!("{:?}", Masked(None)), "");
|
||||
|
||||
// Test empty string
|
||||
assert_eq!(format!("{:?}", Masked(Some(""))), "");
|
||||
|
||||
// Test length 1
|
||||
assert_eq!(format!("{:?}", Masked(Some("a"))), "***");
|
||||
|
||||
// Test length 2
|
||||
assert_eq!(format!("{:?}", Masked(Some("ab"))), "a***|2");
|
||||
|
||||
// Test length 3
|
||||
assert_eq!(format!("{:?}", Masked(Some("abc"))), "a***c|3");
|
||||
|
||||
// Test length 4
|
||||
assert_eq!(format!("{:?}", Masked(Some("abcd"))), "a***d|4");
|
||||
|
||||
// Test longer string
|
||||
assert_eq!(format!("{:?}", Masked(Some("secretpassword"))), "s***d|14");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ fn encrypt<T: aes_gcm::aead::Aead>(
|
||||
use crate::error::Error;
|
||||
use aes_gcm::AeadCore;
|
||||
use aes_gcm::aead::array::Array;
|
||||
use rand::RngCore;
|
||||
use rand::Rng;
|
||||
|
||||
let mut nonce: Array<u8, <T as AeadCore>::NonceSize> = Array::default();
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
|
||||
@@ -40,11 +40,18 @@ impl ID {
|
||||
pub(crate) fn get_key(&self, password: &[u8], salt: &[u8]) -> Result<[u8; 32], crate::Error> {
|
||||
let mut key = [0u8; 32];
|
||||
match self {
|
||||
ID::Pbkdf2AESGCM => pbkdf2_hmac::<Sha256>(password, salt, 8192, &mut key),
|
||||
_ => {
|
||||
let params = Params::new(64 * 1024, 1, 4, Some(32))?;
|
||||
let argon_2id = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
argon_2id.hash_password_into(password, salt, &mut key)?;
|
||||
ID::Pbkdf2AESGCM => {
|
||||
pbkdf2_hmac::<Sha256>(password, salt, 8192, &mut key);
|
||||
}
|
||||
ID::Argon2idAESGCM | ID::Argon2idChaCHa20Poly1305 => {
|
||||
const ARGON2_MEMORY: u32 = 64 * 1024;
|
||||
const ARGON2_ITERATIONS: u32 = 1;
|
||||
const ARGON2_PARALLELISM: u32 = 4;
|
||||
const ARGON2_OUTPUT_LEN: usize = 32;
|
||||
|
||||
let params = Params::new(ARGON2_MEMORY, ARGON2_ITERATIONS, ARGON2_PARALLELISM, Some(ARGON2_OUTPUT_LEN))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
argon2.hash_password_into(password, salt, &mut key)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ fn test_encrypt_decrypt_binary_data() -> Result<(), crate::Error> {
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_unicode_data() -> Result<(), crate::Error> {
|
||||
let unicode_strings = [
|
||||
"Hello, 世界! 🌍",
|
||||
"Hello, 世界!🌍",
|
||||
"Тест на русском языке",
|
||||
"العربية اختبار",
|
||||
"🚀🔐💻🌟⭐",
|
||||
|
||||
@@ -20,6 +20,12 @@ pub enum Error {
|
||||
#[error("invalid encryption algorithm ID: {0}")]
|
||||
ErrInvalidAlgID(u8),
|
||||
|
||||
#[error("invalid input: {0}")]
|
||||
ErrInvalidInput(String),
|
||||
|
||||
#[error("invalid key length")]
|
||||
ErrInvalidKeyLength,
|
||||
|
||||
#[cfg(any(test, feature = "crypto"))]
|
||||
#[error("{0}")]
|
||||
ErrInvalidLength(#[from] sha2::digest::InvalidLength),
|
||||
@@ -38,4 +44,13 @@ pub enum Error {
|
||||
|
||||
#[error("jwt err: {0}")]
|
||||
ErrJwt(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
ErrIo(#[from] std::io::Error),
|
||||
|
||||
#[error("invalid signature")]
|
||||
ErrInvalidSignature,
|
||||
|
||||
#[error("invalid token")]
|
||||
ErrInvalidToken,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ rust-version.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ftps = []
|
||||
|
||||
[dependencies]
|
||||
rustfs-ecstore.workspace = true
|
||||
rustfs-common.workspace = true
|
||||
@@ -35,7 +39,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tonic = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
url.workspace = true
|
||||
tokio-stream = { workspace = true }
|
||||
rustfs-madmin.workspace = true
|
||||
rustfs-filemeta.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -51,3 +55,9 @@ base64 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
md5 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
suppaftp = { workspace = true, features = ["tokio", "rustls-aws-lc-rs"] }
|
||||
rcgen.workspace = true
|
||||
anyhow.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-pemfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression test for Issue #1423
|
||||
//! Verifies that Bucket Policies are honored for Authenticated Users.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
async fn create_user(
|
||||
env: &RustFSTestEnvironment,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let create_user_body = serde_json::json!({
|
||||
"secretKey": password,
|
||||
"status": "enabled"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let create_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
|
||||
crate::common::awscurl_put(&create_user_url, &create_user_body, &env.access_key, &env.secret_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
||||
let credentials = Credentials::new(access_key, secret_key, None, None, "test-user");
|
||||
let config = Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(&env.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
|
||||
Client::from_conf(config)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting test_bucket_policy_authenticated_user...");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
let bucket_name = "bucket-policy-auth-test";
|
||||
let object_key = "test-object.txt";
|
||||
let user_access = "testuser";
|
||||
let user_secret = "testpassword";
|
||||
|
||||
// 1. Create Bucket (Admin)
|
||||
admin_client.create_bucket().bucket(bucket_name).send().await?;
|
||||
|
||||
// 2. Create User (Admin API)
|
||||
create_user(&env, user_access, user_secret).await?;
|
||||
|
||||
// 3. Create User Client
|
||||
let user_client = create_user_client(&env, user_access, user_secret);
|
||||
|
||||
// 4. Verify Access Denied initially (No Policy)
|
||||
let result = user_client.list_objects_v2().bucket(bucket_name).send().await;
|
||||
if result.is_ok() {
|
||||
return Err("Should be Access Denied initially".into());
|
||||
}
|
||||
|
||||
// 5. Apply Bucket Policy Allowed User
|
||||
let policy_json = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowTestUser",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": [user_access]
|
||||
},
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": [
|
||||
format!("arn:aws:s3:::{}", bucket_name),
|
||||
format!("arn:aws:s3:::{}/*", bucket_name)
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
admin_client
|
||||
.put_bucket_policy()
|
||||
.bucket(bucket_name)
|
||||
.policy(&policy_json)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// 6. Verify Access Allowed (With Bucket Policy)
|
||||
info!("Verifying PutObject...");
|
||||
user_client
|
||||
.put_object()
|
||||
.bucket(bucket_name)
|
||||
.key(object_key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello world"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("PutObject failed: {}", e))?;
|
||||
|
||||
info!("Verifying ListObjects...");
|
||||
let list_res = user_client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket_name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ListObjects failed: {}", e))?;
|
||||
assert_eq!(list_res.contents().len(), 1);
|
||||
|
||||
info!("Verifying GetObject...");
|
||||
user_client
|
||||
.get_object()
|
||||
.bucket(bucket_name)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GetObject failed: {}", e))?;
|
||||
|
||||
info!("Verifying DeleteObject...");
|
||||
user_client
|
||||
.delete_object()
|
||||
.bucket(bucket_name)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("DeleteObject failed: {}", e))?;
|
||||
|
||||
info!("Test Passed!");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! E2E tests for PutObject and MultipartUpload with checksums (Content-MD5, x-amz-checksum-*).
|
||||
//! Verifies that uploads with Content-MD5 and x-amz-checksum-sha256 succeed and content is correct.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use base64::Engine;
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
|
||||
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
||||
env.create_s3_client()
|
||||
}
|
||||
|
||||
async fn create_bucket(client: &Client, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match client.create_bucket().bucket(bucket).send().await {
|
||||
Ok(_) => {
|
||||
info!("Bucket {} created successfully", bucket);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("BucketAlreadyOwnedByYou") || e.to_string().contains("BucketAlreadyExists") {
|
||||
info!("Bucket {} already exists", bucket);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Box::new(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn content_md5_base64(body: &[u8]) -> String {
|
||||
let digest = md5::compute(body);
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
}
|
||||
|
||||
fn checksum_sha256_base64(body: &[u8]) -> String {
|
||||
let digest = Sha256::digest(body);
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
}
|
||||
|
||||
/// PutObject with Content-MD5: upload succeeds and GetObject returns same content.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_object_with_content_md5() {
|
||||
init_logging();
|
||||
info!("TEST: PutObject with Content-MD5");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-checksum-md5";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let key = "obj-with-md5.txt";
|
||||
let content = b"Hello world with Content-MD5 checksum";
|
||||
let content_md5 = content_md5_base64(content);
|
||||
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.content_md5(&content_md5)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "PutObject with Content-MD5 failed: {:?}", result.err());
|
||||
|
||||
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
||||
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
||||
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
||||
assert_eq!(body_bytes.as_ref(), content, "GetObject body must match uploaded content");
|
||||
info!("PASSED: PutObject with Content-MD5 and GetObject content match");
|
||||
}
|
||||
|
||||
/// PutObject with x-amz-checksum-sha256: upload succeeds and GetObject returns same content.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_object_with_checksum_sha256() {
|
||||
init_logging();
|
||||
info!("TEST: PutObject with x-amz-checksum-sha256");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-checksum-sha256";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let key = "obj-with-sha256.txt";
|
||||
let content = b"Hello world with x-amz-checksum-sha256";
|
||||
let checksum = checksum_sha256_base64(content);
|
||||
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.checksum_sha256(&checksum)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "PutObject with checksum_sha256 failed: {:?}", result.err());
|
||||
|
||||
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
||||
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
||||
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
||||
assert_eq!(body_bytes.as_ref(), content, "GetObject body must match uploaded content");
|
||||
info!("PASSED: PutObject with checksum_sha256 and GetObject content match");
|
||||
}
|
||||
|
||||
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
||||
/// Uses part size >= 5MB (server minimum) for two parts.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_multipart_upload_with_checksum() {
|
||||
init_logging();
|
||||
info!("TEST: MultipartUpload with checksum (checksum_sha256 on parts)");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-multipart-checksum";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let key = "multipart-with-checksum.bin";
|
||||
const PART_SIZE: usize = 6 * 1024 * 1024; // 6 MB per part (>= 5MB minimum)
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 256) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 1) % 256) as u8).collect();
|
||||
let full_content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
|
||||
let create_result = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Sha256)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
|
||||
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
||||
|
||||
let checksum1 = checksum_sha256_base64(&part1);
|
||||
let upload1 = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(part1.clone()))
|
||||
.checksum_sha256(&checksum1)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to upload part 1");
|
||||
|
||||
let etag1 = upload1.e_tag().expect("No etag part 1").to_string();
|
||||
let checksum_sha256_1 = upload1.checksum_sha256().map(|s| s.to_string());
|
||||
|
||||
let checksum2 = checksum_sha256_base64(&part2);
|
||||
let upload2 = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(part2.clone()))
|
||||
.checksum_sha256(&checksum2)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to upload part 2");
|
||||
|
||||
let etag2 = upload2.e_tag().expect("No etag part 2").to_string();
|
||||
let checksum_sha256_2 = upload2.checksum_sha256().map(|s| s.to_string());
|
||||
|
||||
let mut part1_builder = CompletedPart::builder().part_number(1).e_tag(etag1);
|
||||
if let Some(ref cs) = checksum_sha256_1 {
|
||||
part1_builder = part1_builder.checksum_sha256(cs);
|
||||
}
|
||||
let mut part2_builder = CompletedPart::builder().part_number(2).e_tag(etag2);
|
||||
if let Some(ref cs) = checksum_sha256_2 {
|
||||
part2_builder = part2_builder.checksum_sha256(cs);
|
||||
}
|
||||
|
||||
let completed_parts = vec![part1_builder.build(), part2_builder.build()];
|
||||
let completed_upload = CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build();
|
||||
|
||||
let complete_result = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(completed_upload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(complete_result.is_ok(), "CompleteMultipartUpload failed: {:?}", complete_result.err());
|
||||
|
||||
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
||||
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
||||
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
||||
assert_eq!(
|
||||
body_bytes.as_ref(),
|
||||
full_content.as_slice(),
|
||||
"GetObject body must match concatenated parts"
|
||||
);
|
||||
info!("PASSED: MultipartUpload with checksum and GetObject content match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::RustFSTestClusterEnvironment;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use bytes::Bytes;
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Barrier;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const BUCKET: &str = "conditional-put-race-bucket";
|
||||
|
||||
async fn cleanup_object(client: &Client, key: &str) {
|
||||
if let Err(e) = client.delete_object().bucket(BUCKET).key(key).send().await {
|
||||
warn!("Failed to delete object '{}' from bucket '{}' during cleanup: {:?}", key, BUCKET, e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn conditional_put(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
client_id: usize,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.body(Bytes::copy_from_slice(data).into())
|
||||
.if_none_match("*")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
info!(" Client {} SUCCEEDED - ETag: {}", client_id, resp.e_tag().unwrap_or("none"));
|
||||
Ok(true)
|
||||
}
|
||||
Err(SdkError::ServiceError(e)) => {
|
||||
let code = e.err().meta().code().unwrap_or("");
|
||||
let message = e.err().meta().message().unwrap_or("");
|
||||
if code == "PreconditionFailed" {
|
||||
warn!(" Client {} got 412 PreconditionFailed", client_id);
|
||||
Ok(false)
|
||||
} else {
|
||||
warn!(" Client {} got ServiceError: code={}, message={}", client_id, code, message);
|
||||
Err(e.into_err().into())
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" Client {} got non-ServiceError: {:?}", client_id, e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_race_iteration(
|
||||
clients: &[Client],
|
||||
test_key: &str,
|
||||
iteration: usize,
|
||||
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
cleanup_object(&clients[0], test_key).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let head_result = clients[0].head_object().bucket(BUCKET).key(test_key).send().await;
|
||||
|
||||
if head_result.is_ok() {
|
||||
warn!("Warning: Object still exists after cleanup, skipping iteration {}", iteration);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
info!("\n=== Iteration {} ===", iteration);
|
||||
info!("Launching {} concurrent conditional PUTs to different nodes...", clients.len());
|
||||
|
||||
let barrier = Arc::new(Barrier::new(clients.len()));
|
||||
let test_key = test_key.to_string();
|
||||
|
||||
let mut handles = vec![];
|
||||
for (i, client) in clients.iter().enumerate() {
|
||||
let client = client.clone();
|
||||
let barrier = barrier.clone();
|
||||
let key = test_key.clone();
|
||||
let data = format!("data from client {}", i).into_bytes();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
conditional_put(&client, &key, &data, i).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let mut success_count = 0;
|
||||
let mut had_error = false;
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok(true)) => success_count += 1,
|
||||
Ok(Ok(false)) => {}
|
||||
Ok(Err(e)) => {
|
||||
had_error = true;
|
||||
info!(" Error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
had_error = true;
|
||||
info!(" Task error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Result: {} out of {} succeeded", success_count, clients.len());
|
||||
|
||||
if success_count > 1 {
|
||||
info!(">>> RACE CONDITION DETECTED!");
|
||||
} else if success_count == 1 {
|
||||
info!(">>> Correct behavior: exactly 1 writer succeeded.");
|
||||
} else if had_error {
|
||||
return Err("all conditional PUTs failed (e.g. cluster/bucket not ready)".into());
|
||||
} else {
|
||||
info!(">>> Unexpected: no writers succeeded.");
|
||||
}
|
||||
|
||||
Ok(success_count)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
info!("Starting conditional PUT race test with auto cluster");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
|
||||
let iterations = 5;
|
||||
let mut races_detected = 0;
|
||||
let mut correct_count = 0;
|
||||
let mut error_count = 0;
|
||||
|
||||
for i in 1..=iterations {
|
||||
let test_key = format!("race-test-{}-{}", std::process::id(), i);
|
||||
|
||||
match run_race_iteration(&clients, &test_key, i).await {
|
||||
Ok(success_count) => {
|
||||
if success_count > 1 {
|
||||
races_detected += 1;
|
||||
} else if success_count == 1 {
|
||||
correct_count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error_count += 1;
|
||||
warn!("Iteration {} failed (not a race; e.g. cluster/network): {}", i, e)
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_object(&clients[0], &test_key).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
info!("\n====================================");
|
||||
info!("SUMMARY");
|
||||
info!("====================================");
|
||||
info!("Total iterations: {}", iterations);
|
||||
info!("Correct (1 winner): {}", correct_count);
|
||||
info!("Race conditions: {}", races_detected);
|
||||
info!("Errors (skipped): {}", error_count);
|
||||
|
||||
assert_eq!(races_detected, 0, "Race conditions detected: {}/{}", races_detected, iterations);
|
||||
assert_eq!(
|
||||
error_count, 0,
|
||||
"{} iteration(s) failed due to errors (e.g. cluster not ready)",
|
||||
error_count
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
info!("Starting basic conditional PUT test with auto cluster");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
let test_key = "basic-conditional-put";
|
||||
cleanup_object(&client, test_key).await;
|
||||
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(test_key)
|
||||
.body(Bytes::from("first write").into())
|
||||
.if_none_match("*")
|
||||
.send()
|
||||
.await;
|
||||
assert!(result.is_ok(), "First PUT with If-None-Match:* should succeed");
|
||||
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(test_key)
|
||||
.body(Bytes::from("second write").into())
|
||||
.if_none_match("*")
|
||||
.send()
|
||||
.await;
|
||||
assert!(result.is_err(), "Second PUT with If-None-Match:* should fail");
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(SdkError::ServiceError(_))),
|
||||
"Expected ServiceError but got different error type"
|
||||
);
|
||||
|
||||
if let Err(SdkError::ServiceError(e)) = result {
|
||||
let code = e.err().meta().code().unwrap_or("");
|
||||
assert_eq!(code, "PreconditionFailed");
|
||||
}
|
||||
|
||||
cleanup_object(&client, test_key).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -34,8 +34,8 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Common constants for all E2E tests
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "minioadmin";
|
||||
pub const DEFAULT_SECRET_KEY: &str = "minioadmin";
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
|
||||
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
||||
pub fn workspace_root() -> PathBuf {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -81,6 +81,14 @@ fn build_rustfs_binary() {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
||||
|
||||
// Read features from environment variable for e2e tests
|
||||
if let Ok(features) = std::env::var("RUSTFS_BUILD_FEATURES")
|
||||
&& !features.is_empty()
|
||||
{
|
||||
cmd.arg("--features").arg(&features);
|
||||
info!("Building with features: {}", features);
|
||||
}
|
||||
|
||||
if !cfg!(debug_assertions) {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
@@ -165,7 +173,7 @@ impl RustFSTestEnvironment {
|
||||
}
|
||||
|
||||
/// Find an available port for the test
|
||||
async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::net::TcpListener;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -176,13 +184,15 @@ impl RustFSTestEnvironment {
|
||||
/// Kill any existing RustFS processes
|
||||
pub async fn cleanup_existing_processes(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Cleaning up any existing RustFS processes");
|
||||
let output = Command::new("pkill").args(["-f", "rustfs"]).output();
|
||||
let binary_path = rustfs_binary_path();
|
||||
let binary_name = binary_path.to_string_lossy();
|
||||
let output = Command::new("pkill").args(["-f", &binary_name]).output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
info!("Killed existing RustFS processes");
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
if let Ok(output) = output
|
||||
&& output.status.success()
|
||||
{
|
||||
info!("Killed existing RustFS processes: {}", binary_name);
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -363,3 +373,285 @@ pub async fn awscurl_put(
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
execute_awscurl(url, "PUT", Some(body), access_key, secret_key).await
|
||||
}
|
||||
|
||||
/// Helper function for DELETE requests
|
||||
pub async fn awscurl_delete(
|
||||
url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
execute_awscurl(url, "DELETE", None, access_key, secret_key).await
|
||||
}
|
||||
|
||||
/// Represents a single RustFS server instance in a test cluster.
|
||||
///
|
||||
/// Each `ClusterNode` tracks the node's network address, base URL for
|
||||
/// S3-compatible requests, on-disk data directory, and the underlying
|
||||
/// child process handle when the node is running.
|
||||
pub struct ClusterNode {
|
||||
pub address: String,
|
||||
pub url: String,
|
||||
pub data_dir: String,
|
||||
pub process: Option<Child>,
|
||||
}
|
||||
|
||||
/// Test environment for managing a multi-node RustFS cluster.
|
||||
///
|
||||
/// `RustFSTestClusterEnvironment` is responsible for starting and stopping
|
||||
/// a group of `ClusterNode`s, managing their temporary storage directory,
|
||||
/// and providing the shared access and secret keys used by tests to
|
||||
/// interact with the cluster.
|
||||
pub struct RustFSTestClusterEnvironment {
|
||||
pub nodes: Vec<ClusterNode>,
|
||||
pub temp_dir: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
}
|
||||
|
||||
impl RustFSTestClusterEnvironment {
|
||||
/// Create a new RustFS test cluster environment with the specified number of nodes.
|
||||
///
|
||||
/// Generates a unique temporary root directory for the cluster, allocates an available TCP port
|
||||
/// for each node, creates an independent data directory for every node, and initializes basic
|
||||
/// cluster node configurations (node processes are not started at this stage).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_count` - The number of nodes to create in the cluster, must be a positive integer
|
||||
/// (an empty cluster will cause errors in subsequent startup operations).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Self)` - A new instance of `RustFSTestClusterEnvironment` with initialized node
|
||||
/// configurations and temporary directory info on success.
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if any step fails, such as temporary
|
||||
/// directory creation failure or available port lookup failure.
|
||||
pub async fn new(node_count: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if node_count == 0 {
|
||||
return Err("Node count must be greater than zero".into());
|
||||
}
|
||||
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
|
||||
fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
let mut nodes = Vec::with_capacity(node_count);
|
||||
for i in 0..node_count {
|
||||
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||
let address = format!("127.0.0.1:{}", port);
|
||||
let url = format!("http://{}", address);
|
||||
let data_dir = format!("{}/node{}", temp_dir, i);
|
||||
fs::create_dir_all(&data_dir).await?;
|
||||
|
||||
nodes.push(ClusterNode {
|
||||
address,
|
||||
url,
|
||||
data_dir,
|
||||
process: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
nodes,
|
||||
temp_dir,
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the volumes argument string for RustFS binary (internal helper method).
|
||||
///
|
||||
/// Concatenates the address and data directory of all cluster nodes into a single string
|
||||
/// used as the `RUSTFS_VOLUMES` environment variable for RustFS node processes.
|
||||
fn build_volumes_arg(&self) -> String {
|
||||
self.nodes
|
||||
.iter()
|
||||
.map(|n| format!("http://{}{}", n.address, n.data_dir))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Start all node processes in the RustFS cluster and wait for the cluster service to be ready.
|
||||
///
|
||||
/// Spawns a RustFS binary process for each node with necessary environment variable configurations,
|
||||
/// first waits for each node's TCP port to be reachable, then verifies the cluster's S3-compatible
|
||||
/// service availability via the S3 API.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - All nodes start successfully and the cluster S3 service is ready for requests.
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if process spawning fails, TCP port readiness
|
||||
/// times out, or cluster service readiness times out.
|
||||
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = rustfs_binary_path();
|
||||
let volumes_arg = self.build_volumes_arg();
|
||||
|
||||
for (i, node) in self.nodes.iter_mut().enumerate() {
|
||||
info!("Starting cluster node {} on {}", i, node.address);
|
||||
|
||||
let process = Command::new(&binary_path)
|
||||
.env("RUSTFS_VOLUMES", &volumes_arg)
|
||||
.env("RUSTFS_ADDRESS", &node.address)
|
||||
.env("RUSTFS_ACCESS_KEY", &self.access_key)
|
||||
.env("RUSTFS_SECRET_KEY", &self.secret_key)
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.current_dir(&node.data_dir)
|
||||
.spawn()?;
|
||||
|
||||
node.process = Some(process);
|
||||
}
|
||||
|
||||
for (i, node) in self.nodes.iter().enumerate() {
|
||||
self.wait_for_node_ready(&node.address, i).await?;
|
||||
}
|
||||
|
||||
self.wait_for_service_ready().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for a single cluster node's TCP port to become reachable (internal helper method).
|
||||
///
|
||||
/// Attempts to establish a TCP connection to the node's address, retries up to 60 times
|
||||
/// with a 1-second interval between attempts. Fails if the port is unreachable after all retries.
|
||||
async fn wait_for_node_ready(&self, address: &str, idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
for attempt in 0..60 {
|
||||
if TcpStream::connect(address).await.is_ok() {
|
||||
info!("Node {} ({}) TCP ready after {} attempts", idx, address, attempt + 1);
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Err(format!("Node {} failed to become ready", idx).into())
|
||||
}
|
||||
|
||||
/// Wait for the entire cluster's S3-compatible service to be ready (internal helper method).
|
||||
///
|
||||
/// Verifies service availability by calling the S3 `list_buckets` API, retries up to 120 times
|
||||
/// with a 1-second interval between attempts. Fails if the API call remains unsuccessful after all retries.
|
||||
async fn wait_for_service_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = self.create_s3_client(0)?;
|
||||
|
||||
for attempt in 0..120 {
|
||||
match client.list_buckets().send().await {
|
||||
Ok(_) => {
|
||||
info!("Cluster service ready after {} attempts", attempt + 1);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Cluster service failed to become ready".into())
|
||||
}
|
||||
|
||||
/// Create an S3 client configured to communicate with a specific cluster node.
|
||||
///
|
||||
/// Configures the S3 client with the cluster's authentication credentials, a fixed `us-east-1` region,
|
||||
/// the target node's endpoint URL, and enforces path-style access (required for RustFS S3 compatibility).
|
||||
/// Performs a validity check on the node index before creating the client to avoid out-of-bounds errors.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_idx` - The zero-based index of the target cluster node. Must be in the range `[0, total_nodes - 1]`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Client)` - A fully configured AWS S3 `Client` instance for the specified node on success.
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if the node index is invalid, or if the S3 client configuration fails.
|
||||
pub fn create_s3_client(&self, node_idx: usize) -> Result<Client, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if node_idx >= self.nodes.len() {
|
||||
return Err("node_idx is invalid".into());
|
||||
}
|
||||
let credentials = Credentials::new(&self.access_key, &self.secret_key, None, None, "cluster-test");
|
||||
let config = Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(&self.nodes[node_idx].url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
Ok(Client::from_conf(config))
|
||||
}
|
||||
|
||||
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
|
||||
///
|
||||
/// Iterates over all cluster node indices, calls `create_s3_client` for each index, and aggregates
|
||||
/// the resulting clients into a pre-allocated vector. Terminates immediately and returns an error
|
||||
/// if any single node's S3 client creation fails (fails fast behavior).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<Client>)` - A vector of configured S3 `Client` instances (one per cluster node) on full success.
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error with a descriptive message if any client creation fails,
|
||||
/// including the underlying error from `create_s3_client`.
|
||||
pub fn create_all_clients(&self) -> Result<Vec<Client>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
(0..self.nodes.len()).map(|i| self.create_s3_client(i)).try_fold(
|
||||
Vec::with_capacity(self.nodes.len()),
|
||||
|mut clients, result| match result {
|
||||
Ok(client) => {
|
||||
clients.push(client);
|
||||
Ok(clients)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to create S3 client for node: {}", e).into()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a test S3 bucket in the RustFS cluster.
|
||||
///
|
||||
/// Uses the S3 client of the first cluster node to call the S3 `create_bucket` API and
|
||||
/// create a bucket with the specified name (follows S3 bucket naming conventions).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bucket_name` - The name of the bucket to create, must comply with S3 bucket naming
|
||||
/// rules (lowercase, no spaces, valid characters only).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - The test bucket is created successfully via the S3 API.
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if the S3 `create_bucket` API call fails,
|
||||
/// such as invalid bucket name, insufficient permissions, or an unready cluster.
|
||||
pub async fn create_test_bucket(&self, bucket_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = self.create_s3_client(0)?;
|
||||
client.create_bucket().bucket(bucket_name).send().await?;
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop all running node processes in the RustFS cluster.
|
||||
///
|
||||
/// Iterates over all cluster nodes, attempts to kill the spawned RustFS process (if running),
|
||||
/// and waits for the process to exit. Logs an error if process termination or waiting fails,
|
||||
/// but does not panic (fails gracefully).
|
||||
///
|
||||
/// This method is automatically called by the `Drop` trait when the cluster environment
|
||||
/// is destroyed, and can also be called manually to stop the cluster early.
|
||||
pub fn stop(&mut self) {
|
||||
for (i, node) in self.nodes.iter_mut().enumerate() {
|
||||
if let Some(mut process) = node.process.take() {
|
||||
info!("Stopping cluster node {}", i);
|
||||
if let Err(e) = process.kill() {
|
||||
error!("Failed to kill cluster node {}: {}", i, e);
|
||||
}
|
||||
if let Err(e) = process.wait() {
|
||||
error!("Failed to wait for cluster node {} to exit: {}", i, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RustFSTestClusterEnvironment {
|
||||
/// Clean up the RustFS test cluster environment when the instance is dropped.
|
||||
///
|
||||
/// Automatically calls the `stop` method to terminate all running node processes, then
|
||||
/// attempts to delete the cluster's temporary root directory and all its contents.
|
||||
/// Logs a warning if directory deletion fails (does not affect program exit).
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
if let Err(e) = std::fs::remove_dir_all(&self.temp_dir) {
|
||||
warn!("Failed to clean up cluster temp directory {}: {}", self.temp_dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Integration tests for object compression functionality
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::sleep;
|
||||
use tracing::info;
|
||||
|
||||
const COMPRESSION_TEST_BUCKET: &str = "compression-test-bucket";
|
||||
const MIN_COMPRESSIBLE_SIZE: usize = 4096;
|
||||
|
||||
fn generate_compressible_data(size: usize) -> Vec<u8> {
|
||||
let pattern = b"Hello, this is compressible test data! ";
|
||||
let mut data = Vec::with_capacity(size);
|
||||
let repetitions = size / pattern.len() + 1;
|
||||
for _ in 0..repetitions {
|
||||
data.extend_from_slice(pattern);
|
||||
}
|
||||
data.truncate(size);
|
||||
data
|
||||
}
|
||||
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||
let mut part_files = Vec::new();
|
||||
|
||||
fn scan_dir(dir: &PathBuf, target: &str, results: &mut Vec<PathBuf>) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, target, results);
|
||||
} else if path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("part."))
|
||||
.unwrap_or(false)
|
||||
&& path.to_string_lossy().contains(target)
|
||||
{
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_dir(&bucket_path, object_key, &mut part_files);
|
||||
part_files
|
||||
}
|
||||
|
||||
async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let process = Command::new(&binary_path)
|
||||
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
||||
.args([
|
||||
"--address",
|
||||
&env.address,
|
||||
"--access-key",
|
||||
&env.access_key,
|
||||
"--secret-key",
|
||||
&env.secret_key,
|
||||
&env.temp_dir,
|
||||
])
|
||||
.spawn()?;
|
||||
|
||||
env.process = Some(process);
|
||||
|
||||
info!("Waiting for RustFS server with compression enabled on {}", env.address);
|
||||
for i in 0..30 {
|
||||
if TcpStream::connect(&env.address).await.is_ok() {
|
||||
info!("RustFS server is ready after {} attempts", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
if i == 29 {
|
||||
return Err("RustFS server failed to become ready".into());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting compression roundtrip test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(COMPRESSION_TEST_BUCKET).await?;
|
||||
|
||||
// Upload compressible object larger than MIN_COMPRESSIBLE_SIZE
|
||||
let original_size = MIN_COMPRESSIBLE_SIZE + 1024;
|
||||
let original_data = generate_compressible_data(original_size);
|
||||
let object_key = "test-compressible.txt";
|
||||
|
||||
info!("Uploading {} bytes", original_size);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(COMPRESSION_TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.body(ByteStream::from(original_data.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// HEAD to verify size
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(COMPRESSION_TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let content_length = head_response.content_length().unwrap_or(0);
|
||||
assert_eq!(content_length as usize, original_size, "Content-Length should be original size");
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, COMPRESSION_TEST_BUCKET, object_key);
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
|
||||
assert!(
|
||||
total_physical_size < original_size as u64,
|
||||
"Physical size {} should be less than original size {} (compression applied)",
|
||||
total_physical_size,
|
||||
original_size
|
||||
);
|
||||
info!(
|
||||
"Physical storage size: {} bytes (compressed from {} bytes)",
|
||||
total_physical_size, original_size
|
||||
);
|
||||
|
||||
// GET and verify data
|
||||
let get_response = client
|
||||
.get_object()
|
||||
.bucket(COMPRESSION_TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let downloaded_data = get_response.body.collect().await?.into_bytes();
|
||||
|
||||
assert_eq!(downloaded_data.len(), original_size);
|
||||
assert_eq!(&downloaded_data[..], &original_data[..], "Data mismatch");
|
||||
|
||||
info!("Compression roundtrip test passed");
|
||||
env.delete_test_bucket(COMPRESSION_TEST_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -117,7 +117,7 @@ pub async fn create_default_key(
|
||||
|
||||
/// Create a KMS key with a specific ID (by directly writing to the key directory)
|
||||
pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use rand::RngCore;
|
||||
use rand::Rng;
|
||||
use std::collections::HashMap;
|
||||
use tokio::fs;
|
||||
|
||||
@@ -406,11 +406,11 @@ impl VaultTestEnvironment {
|
||||
let port_check = TcpStream::connect(VAULT_ADDRESS).await.is_ok();
|
||||
if port_check {
|
||||
// Additional check by making a health request
|
||||
if let Ok(response) = reqwest::get(&format!("{VAULT_URL}/v1/sys/health")).await {
|
||||
if response.status().is_success() {
|
||||
info!("Vault server is ready after {} seconds", i);
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(response) = reqwest::get(&format!("{VAULT_URL}/v1/sys/health")).await
|
||||
&& response.status().is_success()
|
||||
{
|
||||
info!("Vault server is ready after {} seconds", i);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ mod data_usage_test;
|
||||
#[cfg(test)]
|
||||
mod kms;
|
||||
|
||||
// Quota tests
|
||||
#[cfg(test)]
|
||||
mod quota_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod bucket_policy_check_test;
|
||||
|
||||
// Special characters in path test modules
|
||||
#[cfg(test)]
|
||||
mod special_chars_test;
|
||||
@@ -37,6 +44,26 @@ mod special_chars_test;
|
||||
#[cfg(test)]
|
||||
mod content_encoding_test;
|
||||
|
||||
// ListObjectsV2 pagination test (Issue #1596)
|
||||
#[cfg(test)]
|
||||
mod list_objects_v2_pagination_test;
|
||||
|
||||
// Policy variables tests
|
||||
#[cfg(test)]
|
||||
mod policy;
|
||||
|
||||
#[cfg(test)]
|
||||
mod compression_test;
|
||||
#[cfg(test)]
|
||||
mod protocols;
|
||||
|
||||
// Object Lock tests
|
||||
#[cfg(test)]
|
||||
mod object_lock;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cluster_concurrency_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! End-to-end tests for ListObjectsV2 pagination
|
||||
//!
|
||||
//! This module tests the ListObjectsV2 pagination functionality to ensure that:
|
||||
//! - `IsTruncated` is set correctly based on whether there are more results
|
||||
//! - `NextContinuationToken` is returned when there are more results
|
||||
//! - Pagination works correctly with `ContinuationToken`
|
||||
//!
|
||||
//! ## Bug Reference
|
||||
//!
|
||||
//! GitHub Issue #1596: ListObjectsV2 pagination fails due to missing NextContinuationToken
|
||||
//! The server was incorrectly setting IsTruncated=true even when all objects fit within max_keys,
|
||||
//! and was returning V1 NextMarker instead of V2 NextContinuationToken.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
/// Helper function to create an S3 client for testing
|
||||
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
||||
env.create_s3_client()
|
||||
}
|
||||
|
||||
/// Helper function to create a test bucket
|
||||
async fn create_bucket(client: &Client, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match client.create_bucket().bucket(bucket).send().await {
|
||||
Ok(_) => {
|
||||
info!("Bucket {} created successfully", bucket);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Ignore if bucket already exists
|
||||
if e.to_string().contains("BucketAlreadyOwnedByYou") || e.to_string().contains("BucketAlreadyExists") {
|
||||
info!("Bucket {} already exists", bucket);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Box::new(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that IsTruncated is false when all objects fit within max_keys
|
||||
///
|
||||
/// This is the core bug from issue #1596: the server was returning
|
||||
/// IsTruncated=true even when all objects fit within the requested max_keys.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_not_truncated_when_all_objects_returned() {
|
||||
init_logging();
|
||||
info!("Starting test: ListObjectsV2 should not be truncated when all objects fit within max_keys");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-list-pagination";
|
||||
|
||||
// Create bucket
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// Create 3 test objects
|
||||
let test_objects = ["file1.txt", "file2.txt", "file3.txt"];
|
||||
for key in &test_objects {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
info!("Created object: {}", key);
|
||||
}
|
||||
|
||||
// List objects with max_keys=10 (larger than the number of objects)
|
||||
let result = client.list_objects_v2().bucket(bucket).max_keys(10).send().await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
|
||||
// Verify we got all 3 objects
|
||||
let contents = output.contents();
|
||||
assert_eq!(contents.len(), 3, "Expected 3 objects, got {}", contents.len());
|
||||
|
||||
// KEY ASSERTION: IsTruncated should be false because all objects fit within max_keys
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(
|
||||
!is_truncated,
|
||||
"BUG: IsTruncated should be false when all objects ({}) fit within max_keys (10)",
|
||||
contents.len()
|
||||
);
|
||||
|
||||
// NextContinuationToken should be None when not truncated
|
||||
assert!(
|
||||
output.next_continuation_token().is_none(),
|
||||
"NextContinuationToken should be None when IsTruncated is false"
|
||||
);
|
||||
|
||||
info!("Test passed: IsTruncated is correctly false when all objects fit within max_keys");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Test that pagination works correctly when there are more objects than max_keys
|
||||
///
|
||||
/// This test verifies that:
|
||||
/// 1. IsTruncated is true when there are more objects
|
||||
/// 2. NextContinuationToken is returned (not NextMarker)
|
||||
/// 3. Using ContinuationToken fetches the remaining objects
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_pagination_with_continuation_token() {
|
||||
init_logging();
|
||||
info!("Starting test: ListObjectsV2 pagination with continuation token");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-pagination-token";
|
||||
|
||||
// Create bucket
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// Create 10 test objects
|
||||
let object_count = 10;
|
||||
for i in 1..=object_count {
|
||||
let key = format!("object{:02}.txt", i);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
info!("Created object: {}", key);
|
||||
}
|
||||
|
||||
// First request: List with max_keys=3 (should get first 3 objects)
|
||||
let result = client.list_objects_v2().bucket(bucket).max_keys(3).send().await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
let contents = output.contents();
|
||||
|
||||
// Verify we got 3 objects
|
||||
assert_eq!(contents.len(), 3, "Expected 3 objects in first page, got {}", contents.len());
|
||||
|
||||
// IsTruncated should be true because there are more objects
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(is_truncated, "IsTruncated should be true when there are more objects than max_keys");
|
||||
|
||||
// NextContinuationToken MUST be present (this is the V2 API requirement)
|
||||
let next_token = output.next_continuation_token();
|
||||
assert!(
|
||||
next_token.is_some(),
|
||||
"BUG: NextContinuationToken must be present when IsTruncated is true (Issue #1596)"
|
||||
);
|
||||
|
||||
info!(
|
||||
"First page: Got {} objects, IsTruncated={}, NextContinuationToken={:?}",
|
||||
contents.len(),
|
||||
is_truncated,
|
||||
next_token
|
||||
);
|
||||
|
||||
// Second request: Use continuation token to get next page
|
||||
let result = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(3)
|
||||
.continuation_token(next_token.unwrap())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects with continuation token: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
let contents = output.contents();
|
||||
|
||||
// Verify we got another page of objects
|
||||
assert_eq!(contents.len(), 3, "Expected 3 objects in second page, got {}", contents.len());
|
||||
|
||||
// IsTruncated should still be true (we have 10 objects, requested 6 so far)
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(is_truncated, "IsTruncated should be true for second page (still more objects)");
|
||||
|
||||
info!("Second page: Got {} objects, IsTruncated={}", contents.len(), is_truncated);
|
||||
|
||||
// Collect all objects using pagination
|
||||
let mut all_objects: Vec<String> = Vec::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
let mut page_count = 0;
|
||||
|
||||
loop {
|
||||
let mut request = client.list_objects_v2().bucket(bucket).max_keys(3);
|
||||
|
||||
if let Some(token) = continuation_token.take() {
|
||||
request = request.continuation_token(token);
|
||||
}
|
||||
|
||||
let output = request.send().await.expect("Failed to list objects");
|
||||
|
||||
for obj in output.contents() {
|
||||
if let Some(key) = obj.key() {
|
||||
all_objects.push(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
page_count += 1;
|
||||
|
||||
if output.is_truncated().unwrap_or(false) {
|
||||
continuation_token = output.next_continuation_token().map(|s| s.to_string());
|
||||
assert!(
|
||||
continuation_token.is_some(),
|
||||
"BUG: NextContinuationToken must be present when IsTruncated is true"
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if page_count > 10 {
|
||||
panic!("Too many pages, possible infinite loop due to pagination bug");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we collected all 10 objects
|
||||
assert_eq!(
|
||||
all_objects.len(),
|
||||
object_count,
|
||||
"Expected {} total objects across all pages, got {}",
|
||||
object_count,
|
||||
all_objects.len()
|
||||
);
|
||||
|
||||
info!(
|
||||
"Pagination test passed: Collected all {} objects in {} pages",
|
||||
all_objects.len(),
|
||||
page_count
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Test ListObjectsV2 with max_keys equal to object count
|
||||
///
|
||||
/// Edge case: when max_keys exactly equals the number of objects,
|
||||
/// IsTruncated should be false.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_max_keys_equals_object_count() {
|
||||
init_logging();
|
||||
info!("Starting test: ListObjectsV2 with max_keys equal to object count");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-exact-count";
|
||||
|
||||
// Create bucket
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// Create exactly 5 objects
|
||||
let object_count = 5;
|
||||
for i in 1..=object_count {
|
||||
let key = format!("item{}.txt", i);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"test"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
}
|
||||
|
||||
// List with max_keys=5 (exactly the number of objects)
|
||||
let result = client.list_objects_v2().bucket(bucket).max_keys(5).send().await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
let contents = output.contents();
|
||||
|
||||
assert_eq!(contents.len(), 5, "Expected 5 objects, got {}", contents.len());
|
||||
|
||||
// IsTruncated should be false when max_keys equals object count
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(
|
||||
!is_truncated,
|
||||
"BUG: IsTruncated should be false when max_keys ({}) equals object count ({})",
|
||||
5,
|
||||
contents.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.next_continuation_token().is_none(),
|
||||
"NextContinuationToken should be None when IsTruncated is false"
|
||||
);
|
||||
|
||||
info!("Test passed: IsTruncated is correctly false when max_keys equals object count");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Test ListObjectsV2 with empty bucket
|
||||
///
|
||||
/// Edge case: IsTruncated should be false for empty bucket.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_empty_bucket() {
|
||||
init_logging();
|
||||
info!("Starting test: ListObjectsV2 with empty bucket");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-empty-bucket";
|
||||
|
||||
// Create empty bucket
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// List objects in empty bucket
|
||||
let result = client.list_objects_v2().bucket(bucket).max_keys(10).send().await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
let contents = output.contents();
|
||||
|
||||
assert!(contents.is_empty(), "Expected empty bucket, got {} objects", contents.len());
|
||||
|
||||
// IsTruncated should be false for empty bucket
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(!is_truncated, "IsTruncated should be false for empty bucket");
|
||||
|
||||
assert!(
|
||||
output.next_continuation_token().is_none(),
|
||||
"NextContinuationToken should be None for empty bucket"
|
||||
);
|
||||
|
||||
info!("Test passed: Empty bucket returns IsTruncated=false");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Test ListObjectsV2 with max_keys=0
|
||||
///
|
||||
/// S3 semantics: when max_keys is 0, the response should include no objects
|
||||
/// and IsTruncated should be false.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_max_keys_zero() {
|
||||
init_logging();
|
||||
info!("Starting test: ListObjectsV2 with max_keys=0");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-max-keys-zero";
|
||||
|
||||
// Create bucket
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// Create 2 objects
|
||||
let test_objects = ["alpha.txt", "beta.txt"];
|
||||
for key in &test_objects {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
}
|
||||
|
||||
// List with max_keys=0
|
||||
let result = client.list_objects_v2().bucket(bucket).max_keys(0).send().await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to list objects: {:?}", result.err());
|
||||
|
||||
let output = result.unwrap();
|
||||
let contents = output.contents();
|
||||
|
||||
assert!(contents.is_empty(), "Expected no objects when max_keys=0");
|
||||
|
||||
let is_truncated = output.is_truncated().unwrap_or(false);
|
||||
assert!(!is_truncated, "IsTruncated should be false when max_keys=0");
|
||||
|
||||
assert!(
|
||||
output.next_continuation_token().is_none(),
|
||||
"NextContinuationToken should be None when max_keys=0"
|
||||
);
|
||||
|
||||
info!("Test passed: max_keys=0 returns no objects and IsTruncated=false");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Object Lock E2E test utilities
|
||||
//!
|
||||
//! This module provides Object Lock-specific functionality for testing:
|
||||
//! - Creating buckets with Object Lock enabled
|
||||
//! - Setting retention policies
|
||||
//! - Testing legal hold operations
|
||||
//! - Bypass governance retention header handling
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||
ObjectLockRetention, ObjectLockRetentionMode, ObjectLockRule,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use tracing::info;
|
||||
|
||||
use crate::common::RustFSTestEnvironment;
|
||||
|
||||
/// Object Lock test environment
|
||||
pub struct ObjectLockTestEnvironment {
|
||||
pub base_env: RustFSTestEnvironment,
|
||||
}
|
||||
|
||||
impl ObjectLockTestEnvironment {
|
||||
/// Create a new Object Lock test environment
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_env = RustFSTestEnvironment::new().await?;
|
||||
Ok(Self { base_env })
|
||||
}
|
||||
|
||||
/// Start RustFS server (no special flags needed for Object Lock)
|
||||
pub async fn start_rustfs(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.base_env.start_rustfs_server(Vec::new()).await
|
||||
}
|
||||
|
||||
/// Get S3 client
|
||||
pub fn s3_client(&self) -> Client {
|
||||
self.base_env.create_s3_client()
|
||||
}
|
||||
|
||||
/// Create a bucket with Object Lock enabled
|
||||
pub async fn create_object_lock_bucket(&self, bucket_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = self.s3_client();
|
||||
|
||||
// Create bucket with Object Lock enabled
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket_name)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
info!("Created bucket with Object Lock enabled: {}", bucket_name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Put Object Lock configuration on a bucket
|
||||
pub async fn put_object_lock_configuration(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
mode: ObjectLockRetentionMode,
|
||||
days: Option<i32>,
|
||||
years: Option<i32>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut default_retention_builder = DefaultRetention::builder().mode(mode);
|
||||
|
||||
if let Some(d) = days {
|
||||
default_retention_builder = default_retention_builder.days(d);
|
||||
}
|
||||
if let Some(y) = years {
|
||||
default_retention_builder = default_retention_builder.years(y);
|
||||
}
|
||||
|
||||
let default_retention = default_retention_builder.build();
|
||||
|
||||
let rule = ObjectLockRule::builder().default_retention(default_retention).build();
|
||||
|
||||
let config = ObjectLockConfiguration::builder()
|
||||
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||
.rule(rule)
|
||||
.build();
|
||||
|
||||
client
|
||||
.put_object_lock_configuration()
|
||||
.bucket(bucket)
|
||||
.object_lock_configuration(config)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
info!("Put Object Lock configuration on bucket: {}", bucket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert ObjectLockRetentionMode to ObjectLockMode for put_object
|
||||
fn retention_mode_to_lock_mode(mode: ObjectLockRetentionMode) -> ObjectLockMode {
|
||||
match mode.as_str() {
|
||||
"GOVERNANCE" => ObjectLockMode::Governance,
|
||||
"COMPLIANCE" => ObjectLockMode::Compliance,
|
||||
_ => ObjectLockMode::Governance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put an object with retention
|
||||
pub async fn put_object_with_retention(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
mode: ObjectLockRetentionMode,
|
||||
retain_until: DateTime<Utc>,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
|
||||
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let retain_until_datetime =
|
||||
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
||||
|
||||
let lock_mode = retention_mode_to_lock_mode(mode.clone());
|
||||
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(data.to_vec()))
|
||||
.object_lock_mode(lock_mode)
|
||||
.object_lock_retain_until_date(retain_until_datetime)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let version_id = response.version_id().unwrap_or("null").to_string();
|
||||
info!("Put object {} with retention mode {:?}, version: {}", key, mode, version_id);
|
||||
Ok(version_id)
|
||||
}
|
||||
|
||||
/// Put an object with legal hold
|
||||
pub async fn put_object_with_legal_hold(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
legal_hold: ObjectLockLegalHoldStatus,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let legal_hold_str = format!("{:?}", legal_hold);
|
||||
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(data.to_vec()))
|
||||
.object_lock_legal_hold_status(legal_hold)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let version_id = response.version_id().unwrap_or("null").to_string();
|
||||
info!("Put object {} with legal hold {}, version: {}", key, legal_hold_str, version_id);
|
||||
Ok(version_id)
|
||||
}
|
||||
|
||||
/// Put object retention on existing object
|
||||
pub async fn put_object_retention(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
mode: ObjectLockRetentionMode,
|
||||
retain_until: DateTime<Utc>,
|
||||
bypass_governance: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
|
||||
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let retain_until_datetime =
|
||||
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
||||
|
||||
let retention = ObjectLockRetention::builder()
|
||||
.mode(mode.clone())
|
||||
.retain_until_date(retain_until_datetime)
|
||||
.build();
|
||||
|
||||
let mut request = client
|
||||
.put_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.retention(retention)
|
||||
.bypass_governance_retention(bypass_governance);
|
||||
|
||||
if let Some(vid) = version_id {
|
||||
request = request.version_id(vid);
|
||||
}
|
||||
|
||||
request.send().await?;
|
||||
info!("Put object retention on {} with mode {:?}", key, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put object legal hold
|
||||
pub async fn put_object_legal_hold(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
status: ObjectLockLegalHoldStatus,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let status_str = format!("{:?}", status);
|
||||
let legal_hold = ObjectLockLegalHold::builder().status(status).build();
|
||||
|
||||
let mut request = client.put_object_legal_hold().bucket(bucket).key(key).legal_hold(legal_hold);
|
||||
|
||||
if let Some(vid) = version_id {
|
||||
request = request.version_id(vid);
|
||||
}
|
||||
|
||||
request.send().await?;
|
||||
info!("Put legal hold {} on {}", status_str, key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete object with optional bypass governance retention
|
||||
pub async fn delete_object_with_bypass(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
bypass_governance: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut request = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.bypass_governance_retention(bypass_governance);
|
||||
|
||||
if let Some(vid) = version_id {
|
||||
request = request.version_id(vid);
|
||||
}
|
||||
|
||||
request.send().await?;
|
||||
info!("Deleted object {} (bypass: {})", key, bypass_governance);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate a future retain_until date
|
||||
pub fn future_retain_until(days: i64) -> DateTime<Utc> {
|
||||
Utc::now() + Duration::days(days)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Object Lock E2E tests
|
||||
//!
|
||||
//! This module contains end-to-end tests for S3 Object Lock functionality, including:
|
||||
//! - COMPLIANCE and GOVERNANCE retention modes
|
||||
//! - Legal Hold
|
||||
//! - Bypass Governance Retention
|
||||
//! - Default bucket retention configuration
|
||||
//! - Delete operations on locked objects
|
||||
|
||||
pub mod common;
|
||||
mod object_lock_test;
|
||||
@@ -0,0 +1,665 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
//! Object Lock E2E Tests
|
||||
//!
|
||||
//! These tests verify the complete Object Lock workflow including:
|
||||
//! - COMPLIANCE mode blocks deletion
|
||||
//! - GOVERNANCE mode blocks deletion without bypass header
|
||||
//! - GOVERNANCE mode allows deletion with bypass header
|
||||
//! - Legal Hold blocks deletion
|
||||
//! - PutObjectRetention modification restrictions
|
||||
//! - Default bucket retention is applied to new objects
|
||||
|
||||
use super::common::*;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
/// Initialize test logging
|
||||
fn init_logging() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("e2e_test=debug,rustfs=info")
|
||||
.try_init();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DeleteObject Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_compliance_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by COMPLIANCE retention");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-compliance-delete";
|
||||
let key = "locked-object";
|
||||
let data = b"test data for compliance mode";
|
||||
|
||||
// Create bucket with Object Lock enabled
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with COMPLIANCE retention (30 days in future)
|
||||
let retain_until = future_retain_until(30);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete - should fail
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object");
|
||||
|
||||
// Even with bypass header, COMPLIANCE should not allow deletion
|
||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
||||
assert!(
|
||||
delete_with_bypass_result.is_err(),
|
||||
"Delete with bypass should still fail for COMPLIANCE mode"
|
||||
);
|
||||
|
||||
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_governance_without_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by GOVERNANCE retention without bypass");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-governance-no-bypass";
|
||||
let key = "governance-locked-object";
|
||||
let data = b"test data for governance mode";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with GOVERNANCE retention
|
||||
let retain_until = future_retain_until(30);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete without bypass - should fail
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object");
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_allowed_by_governance_with_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject allowed by GOVERNANCE retention with bypass");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-governance-with-bypass";
|
||||
let key = "governance-bypass-object";
|
||||
let data = b"test data for governance bypass";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with GOVERNANCE retention
|
||||
let retain_until = future_retain_until(30);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete with bypass header - should succeed
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
||||
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
|
||||
|
||||
// Verify object is deleted
|
||||
let head_result = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await;
|
||||
assert!(head_result.is_err(), "Object should be deleted");
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by Legal Hold");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-legal-hold-delete";
|
||||
let key = "legal-hold-object";
|
||||
let data = b"test data for legal hold";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with legal hold ON
|
||||
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete - should fail (legal hold cannot be bypassed)
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for legal hold object");
|
||||
|
||||
// Even with bypass header, legal hold should block deletion
|
||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
||||
assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold");
|
||||
|
||||
info!("✅ Test passed: Legal Hold blocks deletion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_after_legal_hold_removed() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject succeeds after Legal Hold is removed");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-legal-hold-remove";
|
||||
let key = "legal-hold-remove-object";
|
||||
let data = b"test data for legal hold removal";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with legal hold ON
|
||||
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Remove legal hold
|
||||
put_object_legal_hold(&client, bucket, key, Some(&version_id), ObjectLockLegalHoldStatus::Off)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now deletion should succeed
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_ok(), "Delete should succeed after legal hold is removed");
|
||||
|
||||
info!("✅ Test passed: Deletion succeeds after Legal Hold removal");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DeleteObjects (Batch Delete) Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_objects_mixed_locked_unlocked() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObjects with mixed locked and unlocked objects");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-batch-delete-mixed";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put unlocked object
|
||||
let unlocked_key = "unlocked-object";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(unlocked_key)
|
||||
.body(ByteStream::from(b"unlocked data".to_vec()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Put locked object with COMPLIANCE
|
||||
let locked_key = "locked-object";
|
||||
let retain_until = future_retain_until(30);
|
||||
let locked_version = put_object_with_retention(
|
||||
&client,
|
||||
bucket,
|
||||
locked_key,
|
||||
b"locked data",
|
||||
ObjectLockRetentionMode::Compliance,
|
||||
retain_until,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Batch delete both objects
|
||||
let delete = Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(unlocked_key).build().unwrap())
|
||||
.objects(
|
||||
ObjectIdentifier::builder()
|
||||
.key(locked_key)
|
||||
.version_id(&locked_version)
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let result = client.delete_objects().bucket(bucket).delete(delete).send().await.unwrap();
|
||||
|
||||
// Unlocked object should be deleted
|
||||
let deleted_count = result.deleted().len();
|
||||
let error_count = result.errors().len();
|
||||
|
||||
info!("Deleted: {}, Errors: {}", deleted_count, error_count);
|
||||
|
||||
// Should have 1 successful delete (unlocked) and 1 error (locked)
|
||||
assert_eq!(deleted_count, 1, "One object should be deleted");
|
||||
assert_eq!(error_count, 1, "One object should have error (locked)");
|
||||
|
||||
// Verify locked object still exists
|
||||
let head_result = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(locked_key)
|
||||
.version_id(&locked_version)
|
||||
.send()
|
||||
.await;
|
||||
assert!(head_result.is_ok(), "Locked object should still exist");
|
||||
|
||||
info!("✅ Test passed: Batch delete correctly handles mixed locked/unlocked objects");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PutObjectRetention Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_compliance_cannot_shorten() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention cannot shorten COMPLIANCE retention");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-retention-shorten";
|
||||
let key = "compliance-shorten-object";
|
||||
let data = b"test data";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with COMPLIANCE retention for 60 days
|
||||
let retain_until_60 = future_retain_until(60);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_60)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to shorten to 30 days - should fail
|
||||
let retain_until_30 = future_retain_until(30);
|
||||
let shorten_result = put_object_retention(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
Some(&version_id),
|
||||
ObjectLockRetentionMode::Compliance,
|
||||
retain_until_30,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
|
||||
|
||||
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_compliance_can_extend() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention can extend COMPLIANCE retention");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-retention-extend";
|
||||
let key = "compliance-extend-object";
|
||||
let data = b"test data";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with COMPLIANCE retention for 30 days
|
||||
let retain_until_30 = future_retain_until(30);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_30)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Extend to 60 days - should succeed
|
||||
let retain_until_60 = future_retain_until(60);
|
||||
let extend_result = put_object_retention(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
Some(&version_id),
|
||||
ObjectLockRetentionMode::Compliance,
|
||||
retain_until_60,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(extend_result.is_ok(), "Extending COMPLIANCE retention should succeed");
|
||||
|
||||
info!("✅ Test passed: Can extend COMPLIANCE retention");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_governance_extend_without_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention on GOVERNANCE can extend without bypass");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-governance-extend";
|
||||
let key = "governance-extend-object";
|
||||
let data = b"test data";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with GOVERNANCE retention for 30 days
|
||||
let retain_until_30 = future_retain_until(30);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_30)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Extend to 60 days without bypass - should succeed (AWS S3 behavior)
|
||||
let retain_until_60 = future_retain_until(60);
|
||||
let extend_without_bypass = put_object_retention(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
Some(&version_id),
|
||||
ObjectLockRetentionMode::Governance,
|
||||
retain_until_60,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
extend_without_bypass.is_ok(),
|
||||
"Extending GOVERNANCE retention without bypass should succeed"
|
||||
);
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention can be extended without bypass");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_governance_shorten_requires_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention on GOVERNANCE requires bypass to shorten");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-governance-shorten";
|
||||
let key = "governance-shorten-object";
|
||||
let data = b"test data";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with GOVERNANCE retention for 60 days
|
||||
let retain_until_60 = future_retain_until(60);
|
||||
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_60)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to shorten to 30 days without bypass - should fail
|
||||
let retain_until_30 = future_retain_until(30);
|
||||
let shorten_without_bypass = put_object_retention(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
Some(&version_id),
|
||||
ObjectLockRetentionMode::Governance,
|
||||
retain_until_30,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
shorten_without_bypass.is_err(),
|
||||
"Shortening GOVERNANCE retention without bypass should fail"
|
||||
);
|
||||
|
||||
// Shorten with bypass - should succeed
|
||||
let shorten_with_bypass = put_object_retention(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
Some(&version_id),
|
||||
ObjectLockRetentionMode::Governance,
|
||||
retain_until_30,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(shorten_with_bypass.is_ok(), "Shortening GOVERNANCE retention with bypass should succeed");
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention shortening requires bypass");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Retention Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_default_retention_applied_to_new_objects() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Default retention is applied to new objects");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-default-retention";
|
||||
let key = "object-with-default-retention";
|
||||
let data = b"test data";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Set default retention: GOVERNANCE for 30 days
|
||||
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Put object without explicit retention
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(data.to_vec()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let version_id = response.version_id().unwrap();
|
||||
|
||||
// Try to delete without bypass - should fail due to default retention
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for object with default retention applied");
|
||||
|
||||
info!("✅ Test passed: Default retention is applied to new objects");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Versioning Auto-Enable Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioning_auto_enabled_with_object_lock() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Versioning is auto-enabled when Object Lock is configured");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-versioning-auto-enable";
|
||||
|
||||
// Create bucket with Object Lock enabled
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Check versioning status - should be Enabled
|
||||
let versioning = client.get_bucket_versioning().bucket(bucket).send().await.unwrap();
|
||||
|
||||
// Object Lock buckets must have versioning enabled
|
||||
// Note: Some S3 implementations may report MfaDelete status as well
|
||||
let status = versioning.status();
|
||||
info!("Versioning status: {:?}", status);
|
||||
|
||||
// Put an object and verify it gets a version ID
|
||||
let key = "versioned-object";
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(b"v1".to_vec()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let version1 = response.version_id();
|
||||
assert!(version1.is_some(), "Object should have a version ID");
|
||||
|
||||
// Put another version
|
||||
let response2 = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(b"v2".to_vec()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let version2 = response2.version_id();
|
||||
assert!(version2.is_some(), "Second object should have a version ID");
|
||||
assert_ne!(version1, version2, "Version IDs should be different");
|
||||
|
||||
info!("✅ Test passed: Versioning is auto-enabled with Object Lock");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Message Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
|
||||
let bucket = "test-error-messages";
|
||||
|
||||
env.create_object_lock_bucket(bucket).await.unwrap();
|
||||
|
||||
let client = env.s3_client();
|
||||
|
||||
// Put object with legal hold
|
||||
let legal_hold_key = "legal-hold-object";
|
||||
let lh_version = put_object_with_legal_hold(&client, bucket, legal_hold_key, b"data", ObjectLockLegalHoldStatus::On)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Put object with retention
|
||||
let retention_key = "retention-object";
|
||||
let retain_until = future_retain_until(30);
|
||||
let ret_version =
|
||||
put_object_with_retention(&client, bucket, retention_key, b"data", ObjectLockRetentionMode::Compliance, retain_until)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete legal hold object - check error
|
||||
let lh_delete_result = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(legal_hold_key)
|
||||
.version_id(&lh_version)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(e) = lh_delete_result {
|
||||
let error_str = format!("{:?}", e);
|
||||
info!("Legal hold delete error: {}", error_str);
|
||||
// Error should mention legal hold
|
||||
assert!(
|
||||
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
|
||||
"Error should mention legal hold"
|
||||
);
|
||||
}
|
||||
|
||||
// Delete retention object - check error
|
||||
let ret_delete_result = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(retention_key)
|
||||
.version_id(&ret_version)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(e) = ret_delete_result {
|
||||
let error_str = format!("{:?}", e);
|
||||
info!("Retention delete error: {}", error_str);
|
||||
// Error should mention retention
|
||||
assert!(
|
||||
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
|
||||
"Error should mention retention"
|
||||
);
|
||||
}
|
||||
|
||||
info!("✅ Test passed: Error messages distinguish lock types");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Protocol E2E Tests
|
||||
|
||||
FTPS protocol end-to-end tests for RustFS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install sshpass ssh-keygen
|
||||
|
||||
# RHEL/CentOS
|
||||
sudo yum install sshpass openssh-clients
|
||||
|
||||
# macOS
|
||||
brew install sshpass openssh
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
Run all protocol tests:
|
||||
```bash
|
||||
RUSTFS_BUILD_FEATURES=ftps cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### FTPS Tests
|
||||
- mkdir bucket
|
||||
- cd to bucket
|
||||
- put file
|
||||
- ls list objects
|
||||
- cd . (stay in current directory)
|
||||
- cd / (return to root)
|
||||
- cd nonexistent bucket (should fail)
|
||||
- delete object
|
||||
- cdup
|
||||
- rmdir delete bucket
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Core FTPS tests
|
||||
|
||||
use crate::common::rustfs_binary_path;
|
||||
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
||||
use anyhow::Result;
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rustls::crypto::aws_lc_rs::default_provider;
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use suppaftp::RustlsConnector;
|
||||
use suppaftp::RustlsFtpStream;
|
||||
use tokio::process::Command;
|
||||
use tracing::info;
|
||||
|
||||
// Fixed FTPS port for testing
|
||||
const FTPS_PORT: u16 = 9021;
|
||||
const FTPS_ADDRESS: &str = "127.0.0.1:9021";
|
||||
|
||||
/// Test FTPS: put, ls, mkdir, rmdir, delete operations
|
||||
pub async fn test_ftps_core_operations() -> Result<()> {
|
||||
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let cert_dir = PathBuf::from(&env.temp_dir).join("ftps_certs");
|
||||
tokio::fs::create_dir_all(&cert_dir).await?;
|
||||
|
||||
// Generate default certificate for root directory
|
||||
let default_cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?;
|
||||
let default_cert_path = cert_dir.join("rustfs_cert.pem");
|
||||
let default_key_path = cert_dir.join("rustfs_key.pem");
|
||||
tokio::fs::write(&default_cert_path, default_cert.cert.pem()).await?;
|
||||
tokio::fs::write(&default_key_path, default_cert.signing_key.serialize_pem()).await?;
|
||||
|
||||
// Create subdirectory for domain-specific certificate
|
||||
let example_domain_dir = cert_dir.join("example1.com");
|
||||
tokio::fs::create_dir_all(&example_domain_dir).await?;
|
||||
let domain_cert = generate_simple_self_signed(vec!["example1.com".to_string()])?;
|
||||
let domain_cert_path = example_domain_dir.join("rustfs_cert.pem");
|
||||
let domain_key_path = example_domain_dir.join("rustfs_key.pem");
|
||||
tokio::fs::write(&domain_cert_path, domain_cert.cert.pem()).await?;
|
||||
tokio::fs::write(&domain_key_path, domain_cert.signing_key.serialize_pem()).await?;
|
||||
|
||||
info!("Generated 2 certificates in {:?}", cert_dir);
|
||||
|
||||
// Start server manually
|
||||
info!("Starting FTPS server on {}", FTPS_ADDRESS);
|
||||
let binary_path = rustfs_binary_path();
|
||||
let mut server_process = Command::new(&binary_path)
|
||||
.env("RUSTFS_FTPS_ENABLE", "true")
|
||||
.env("RUSTFS_FTPS_ADDRESS", FTPS_ADDRESS)
|
||||
.env("RUSTFS_FTPS_CERTS_DIR", cert_dir.to_str().unwrap())
|
||||
.arg(&env.temp_dir)
|
||||
.spawn()?;
|
||||
|
||||
// Ensure server is cleaned up even on failure
|
||||
let result = async {
|
||||
// Wait for server to be ready
|
||||
ProtocolTestEnvironment::wait_for_port_ready(FTPS_PORT, 30)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
// Install the aws-lc-rs crypto provider
|
||||
default_provider()
|
||||
.install_default()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to install crypto provider: {:?}", e))?;
|
||||
|
||||
// Create a simple rustls config that accepts any certificate for testing
|
||||
let mut root_store = RootCertStore::empty();
|
||||
// Add the self-signed certificate to the trust store for e2e
|
||||
// Note: In a real environment, you'd use proper root certificates
|
||||
let cert_pem = default_cert.cert.pem();
|
||||
let cert_der = rustls_pemfile::certs(&mut Cursor::new(cert_pem))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse cert: {}", e))?;
|
||||
|
||||
root_store.add_parsable_certificates(cert_der);
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
// Wrap in suppaftp's RustlsConnector
|
||||
let tls_connector = RustlsConnector::from(Arc::new(config));
|
||||
|
||||
// Connect to FTPS server
|
||||
let ftp_stream = RustlsFtpStream::connect(FTPS_ADDRESS).map_err(|e| anyhow::anyhow!("Failed to connect: {}", e))?;
|
||||
|
||||
// Upgrade to secure connection
|
||||
let mut ftp_stream = ftp_stream
|
||||
.into_secure(tls_connector, "127.0.0.1")
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upgrade to TLS: {}", e))?;
|
||||
ftp_stream.login(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY)?;
|
||||
|
||||
info!("Testing FTPS: mkdir bucket");
|
||||
let bucket_name = "testbucket";
|
||||
ftp_stream.mkdir(bucket_name)?;
|
||||
info!("PASS: mkdir bucket '{}' successful", bucket_name);
|
||||
|
||||
info!("Testing FTPS: cd to bucket");
|
||||
ftp_stream.cwd(bucket_name)?;
|
||||
info!("PASS: cd to bucket '{}' successful", bucket_name);
|
||||
|
||||
info!("Testing FTPS: put file");
|
||||
let filename = "test.txt";
|
||||
let content = "Hello, FTPS!";
|
||||
ftp_stream.put_file(filename, &mut Cursor::new(content.as_bytes()))?;
|
||||
info!("PASS: put file '{}' ({} bytes) successful", filename, content.len());
|
||||
|
||||
info!("Testing FTPS: download file");
|
||||
let downloaded_content = ftp_stream.retr(filename, |stream| {
|
||||
let mut buffer = Vec::new();
|
||||
stream.read_to_end(&mut buffer).map_err(suppaftp::FtpError::ConnectionError)?;
|
||||
Ok(buffer)
|
||||
})?;
|
||||
let downloaded_str = String::from_utf8(downloaded_content)?;
|
||||
assert_eq!(downloaded_str, content, "Downloaded content should match uploaded content");
|
||||
info!("PASS: download file '{}' successful, content matches", filename);
|
||||
|
||||
info!("Testing FTPS: ls list objects in bucket");
|
||||
let list = ftp_stream.list(None)?;
|
||||
assert!(list.iter().any(|line| line.contains(filename)), "File should appear in list");
|
||||
info!("PASS: ls command successful, file '{}' found in bucket", filename);
|
||||
|
||||
info!("Testing FTPS: ls . (list current directory)");
|
||||
let list_dot = ftp_stream.list(Some(".")).unwrap_or_else(|_| ftp_stream.list(None).unwrap());
|
||||
assert!(list_dot.iter().any(|line| line.contains(filename)), "File should appear in ls .");
|
||||
info!("PASS: ls . successful, file '{}' found", filename);
|
||||
|
||||
info!("Testing FTPS: ls / (list root directory)");
|
||||
let list_root = ftp_stream.list(Some("/")).unwrap();
|
||||
assert!(list_root.iter().any(|line| line.contains(bucket_name)), "Bucket should appear in ls /");
|
||||
assert!(!list_root.iter().any(|line| line.contains(filename)), "File should not appear in ls /");
|
||||
info!(
|
||||
"PASS: ls / successful, bucket '{}' found, file '{}' not found in root",
|
||||
bucket_name, filename
|
||||
);
|
||||
|
||||
info!("Testing FTPS: ls /. (list root directory with /.)");
|
||||
let list_root_dot = ftp_stream
|
||||
.list(Some("/."))
|
||||
.unwrap_or_else(|_| ftp_stream.list(Some("/")).unwrap());
|
||||
assert!(
|
||||
list_root_dot.iter().any(|line| line.contains(bucket_name)),
|
||||
"Bucket should appear in ls /."
|
||||
);
|
||||
info!("PASS: ls /. successful, bucket '{}' found", bucket_name);
|
||||
|
||||
info!("Testing FTPS: ls /bucket (list bucket by absolute path)");
|
||||
let list_bucket = ftp_stream.list(Some(&format!("/{}", bucket_name))).unwrap();
|
||||
assert!(list_bucket.iter().any(|line| line.contains(filename)), "File should appear in ls /bucket");
|
||||
info!("PASS: ls /{} successful, file '{}' found", bucket_name, filename);
|
||||
|
||||
info!("Testing FTPS: cd . (stay in current directory)");
|
||||
ftp_stream.cwd(".")?;
|
||||
info!("PASS: cd . successful (stays in current directory)");
|
||||
|
||||
info!("Testing FTPS: ls after cd . (should still see file)");
|
||||
let list_after_dot = ftp_stream.list(None)?;
|
||||
assert!(
|
||||
list_after_dot.iter().any(|line| line.contains(filename)),
|
||||
"File should still appear in list after cd ."
|
||||
);
|
||||
info!("PASS: ls after cd . successful, file '{}' still found in bucket", filename);
|
||||
|
||||
info!("Testing FTPS: cd / (go to root directory)");
|
||||
ftp_stream.cwd("/")?;
|
||||
info!("PASS: cd / successful (back to root directory)");
|
||||
|
||||
info!("Testing FTPS: ls after cd / (should see bucket only)");
|
||||
let root_list_after = ftp_stream.list(None)?;
|
||||
assert!(
|
||||
!root_list_after.iter().any(|line| line.contains(filename)),
|
||||
"File should not appear in root ls"
|
||||
);
|
||||
assert!(
|
||||
root_list_after.iter().any(|line| line.contains(bucket_name)),
|
||||
"Bucket should appear in root ls"
|
||||
);
|
||||
info!("PASS: ls after cd / successful, file not in root, bucket '{}' found in root", bucket_name);
|
||||
|
||||
info!("Testing FTPS: cd back to bucket");
|
||||
ftp_stream.cwd(bucket_name)?;
|
||||
info!("PASS: cd back to bucket '{}' successful", bucket_name);
|
||||
|
||||
info!("Testing FTPS: delete object");
|
||||
ftp_stream.rm(filename)?;
|
||||
info!("PASS: delete object '{}' successful", filename);
|
||||
|
||||
info!("Testing FTPS: ls verify object deleted");
|
||||
let list_after = ftp_stream.list(None)?;
|
||||
assert!(!list_after.iter().any(|line| line.contains(filename)), "File should be deleted");
|
||||
info!("PASS: ls after delete successful, file '{}' is not found", filename);
|
||||
|
||||
info!("Testing FTPS: cd up to root directory");
|
||||
ftp_stream.cdup()?;
|
||||
info!("PASS: cd up to root directory successful");
|
||||
|
||||
info!("Testing FTPS: cd to nonexistent bucket (should fail)");
|
||||
let nonexistent_bucket = "nonexistent-bucket";
|
||||
let cd_result = ftp_stream.cwd(nonexistent_bucket);
|
||||
assert!(cd_result.is_err(), "cd to nonexistent bucket should fail");
|
||||
info!("PASS: cd to nonexistent bucket '{}' failed as expected", nonexistent_bucket);
|
||||
|
||||
info!("Testing FTPS: ls verify bucket exists in root");
|
||||
let root_list = ftp_stream.list(None)?;
|
||||
assert!(root_list.iter().any(|line| line.contains(bucket_name)), "Bucket should exist in root");
|
||||
info!("PASS: ls root successful, bucket '{}' found in root", bucket_name);
|
||||
|
||||
info!("Testing FTPS: rmdir delete bucket");
|
||||
ftp_stream.rmdir(bucket_name)?;
|
||||
info!("PASS: rmdir bucket '{}' successful", bucket_name);
|
||||
|
||||
info!("Testing FTPS: ls verify bucket deleted");
|
||||
let root_list_after = ftp_stream.list(None)?;
|
||||
assert!(!root_list_after.iter().any(|line| line.contains(bucket_name)), "Bucket should be deleted");
|
||||
info!("PASS: ls root after delete successful, bucket '{}' is not found", bucket_name);
|
||||
|
||||
ftp_stream.quit()?;
|
||||
|
||||
info!("FTPS core tests passed");
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always cleanup server process
|
||||
let _ = server_process.kill().await;
|
||||
let _ = server_process.wait().await;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Protocol tests for FTPS
|
||||
|
||||
pub mod ftps_core;
|
||||
pub mod test_env;
|
||||
pub mod test_runner;
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Protocol test environment for FTPS and SFTP
|
||||
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Default credentials
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
|
||||
|
||||
/// Custom test environment that doesn't automatically stop servers
|
||||
pub struct ProtocolTestEnvironment {
|
||||
pub temp_dir: String,
|
||||
}
|
||||
|
||||
impl ProtocolTestEnvironment {
|
||||
/// Create a new test environment
|
||||
/// This environment won't stop any server when dropped
|
||||
pub fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let temp_dir = format!("/tmp/rustfs_protocol_test_{}", uuid::Uuid::new_v4());
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
Ok(Self { temp_dir })
|
||||
}
|
||||
|
||||
/// Wait for server to be ready
|
||||
pub async fn wait_for_port_ready(port: u16, max_attempts: u32) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let address = format!("127.0.0.1:{}", port);
|
||||
|
||||
info!("Waiting for server to be ready on {}", address);
|
||||
|
||||
for i in 0..max_attempts {
|
||||
if TcpStream::connect(&address).is_ok() {
|
||||
info!("Server is ready after {} s", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if i == max_attempts - 1 {
|
||||
return Err(format!("Server did not become ready within {} s", max_attempts).into());
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Drop trait that doesn't stop servers
|
||||
impl Drop for ProtocolTestEnvironment {
|
||||
fn drop(&mut self) {
|
||||
// Clean up temp directory only, don't stop any server
|
||||
if let Err(e) = std::fs::remove_dir_all(&self.temp_dir) {
|
||||
warn!("Failed to clean up temp directory {}: {}", self.temp_dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Protocol test runner
|
||||
|
||||
use crate::common::init_logging;
|
||||
use crate::protocols::ftps_core::test_ftps_core_operations;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Test result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestResult {
|
||||
pub test_name: String,
|
||||
pub success: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl TestResult {
|
||||
pub fn success(test_name: String) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
success: true,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure(test_name: String, error: String) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
success: false,
|
||||
error_message: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol test suite
|
||||
pub struct ProtocolTestSuite {
|
||||
tests: Vec<TestDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TestDefinition {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ProtocolTestSuite {
|
||||
/// Create default test suite
|
||||
pub fn new() -> Self {
|
||||
let tests = vec![TestDefinition {
|
||||
name: "test_ftps_core_operations".to_string(),
|
||||
}];
|
||||
|
||||
Self { tests }
|
||||
}
|
||||
|
||||
/// Run test suite
|
||||
pub async fn run_test_suite(&self) -> Vec<TestResult> {
|
||||
init_logging();
|
||||
info!("Starting Protocol test suite");
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut results = Vec::new();
|
||||
|
||||
info!("Scheduled {} tests", self.tests.len());
|
||||
|
||||
// Run tests
|
||||
for (i, test_def) in self.tests.iter().enumerate() {
|
||||
let test_description = match test_def.name.as_str() {
|
||||
"test_ftps_core_operations" => {
|
||||
info!("=== Starting FTPS Module Test ===");
|
||||
"FTPS core operations (put, ls, mkdir, rmdir, delete)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
|
||||
info!("Test {}/{} - {}", i + 1, self.tests.len(), test_description);
|
||||
info!("Running: {}", test_def.name);
|
||||
|
||||
let test_start = Instant::now();
|
||||
|
||||
let result = self.run_single_test(test_def).await;
|
||||
let test_duration = test_start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!("Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64());
|
||||
results.push(TestResult::success(test_def.name.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e);
|
||||
results.push(TestResult::failure(test_def.name.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between tests to avoid resource conflicts
|
||||
if i < self.tests.len() - 1 {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
self.print_summary(&results, start_time.elapsed());
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Run a single test
|
||||
async fn run_single_test(&self, test_def: &TestDefinition) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match test_def.name.as_str() {
|
||||
"test_ftps_core_operations" => test_ftps_core_operations().await.map_err(|e| e.into()),
|
||||
_ => Err(format!("Test {} not implemented", test_def.name).into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print test summary
|
||||
fn print_summary(&self, results: &[TestResult], total_duration: Duration) {
|
||||
info!("=== Test Suite Summary ===");
|
||||
info!("Total duration: {:.2}s", total_duration.as_secs_f64());
|
||||
info!("Total tests: {}", results.len());
|
||||
|
||||
let passed = results.iter().filter(|r| r.success).count();
|
||||
let failed = results.len() - passed;
|
||||
let success_rate = (passed as f64 / results.len() as f64) * 100.0;
|
||||
|
||||
info!("Passed: {} | Failed: {}", passed, failed);
|
||||
info!("Success rate: {:.1}%", success_rate);
|
||||
|
||||
if failed > 0 {
|
||||
error!("Failed tests:");
|
||||
for result in results.iter().filter(|r| !r.success) {
|
||||
error!(" - {}: {}", result.test_name, result.error_message.as_ref().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test suite
|
||||
#[tokio::test]
|
||||
async fn test_protocol_core_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let suite = ProtocolTestSuite::new();
|
||||
let results = suite.run_test_suite().await;
|
||||
|
||||
let failed = results.iter().filter(|r| !r.success).count();
|
||||
if failed > 0 {
|
||||
return Err(format!("Protocol tests failed: {failed} failures").into());
|
||||
}
|
||||
|
||||
info!("All protocol tests passed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use serial_test::serial;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Test environment setup for quota tests
|
||||
pub struct QuotaTestEnv {
|
||||
pub env: RustFSTestEnvironment,
|
||||
pub client: Client,
|
||||
pub bucket_name: String,
|
||||
}
|
||||
|
||||
impl QuotaTestEnv {
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bucket_name = format!("quota-test-{}", uuid::Uuid::new_v4());
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let client = env.create_s3_client();
|
||||
|
||||
Ok(Self {
|
||||
env,
|
||||
client,
|
||||
bucket_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_bucket(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.env.create_test_bucket(&self.bucket_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cleanup_bucket(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let objects = self.client.list_objects_v2().bucket(&self.bucket_name).send().await?;
|
||||
for object in objects.contents() {
|
||||
self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket_name)
|
||||
.key(object.key().unwrap_or_default())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
self.env.delete_test_bucket(&self.bucket_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_bucket_quota(&self, quota_bytes: u64) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, self.bucket_name);
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
"quota_type": "HARD"
|
||||
});
|
||||
|
||||
let response = awscurl_put(&url, "a_config.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to set quota: {}", response).into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_quota(&self) -> Result<Option<u64>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, self.bucket_name);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota: {}", response).into())
|
||||
} else {
|
||||
let quota_info: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(quota_info.get("quota").and_then(|v| v.as_u64()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_bucket_quota(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, self.bucket_name);
|
||||
let response = awscurl_delete(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to clear quota: {}", response).into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_bucket_quota(
|
||||
&self,
|
||||
operation_type: &str,
|
||||
operation_size: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
|
||||
let check_request = serde_json::json!({
|
||||
"operation_type": operation_type,
|
||||
"operation_size": operation_size
|
||||
});
|
||||
|
||||
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to check quota: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let data = vec![0u8; size_bytes];
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket_name)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(data))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn object_exists(&self, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match self.client.head_object().bucket(&self.bucket_name).key(key).send().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
// Check for any 404-related errors and return false instead of propagating
|
||||
let error_str = e.to_string();
|
||||
if error_str.contains("404") || error_str.contains("Not Found") || error_str.contains("NotFound") {
|
||||
Ok(false)
|
||||
} else {
|
||||
// Also check the error code directly
|
||||
if let Some(service_err) = e.as_service_error()
|
||||
&& service_err.is_not_found()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats = self.get_bucket_quota_stats().await?;
|
||||
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn set_bucket_quota_for(
|
||||
&self,
|
||||
bucket: &str,
|
||||
quota_bytes: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, bucket);
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
"quota_type": "HARD"
|
||||
});
|
||||
|
||||
let response = awscurl_put(&url, "a_config.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to set quota: {}", response).into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get bucket quota statistics for specific bucket
|
||||
pub async fn get_bucket_quota_stats_for(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!("Getting quota stats for bucket: {}", bucket);
|
||||
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
let stats: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload an object to specific bucket
|
||||
pub async fn upload_object_to_bucket(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
size_bytes: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!("Uploading object {} with size {} bytes to bucket {}", key, size_bytes, bucket);
|
||||
|
||||
let data = vec![0u8; size_bytes];
|
||||
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(data))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
info!("Successfully uploaded object: {} ({} bytes) to bucket: {}", key, size_bytes, bucket);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
// Create test bucket
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 1MB
|
||||
env.set_bucket_quota(1024 * 1024).await?;
|
||||
|
||||
// Verify quota is set
|
||||
let quota = env.get_bucket_quota().await?;
|
||||
assert_eq!(quota, Some(1024 * 1024));
|
||||
|
||||
// Upload a 512KB object (should succeed)
|
||||
env.upload_object("test1.txt", 512 * 1024).await?;
|
||||
assert!(env.object_exists("test1.txt").await?);
|
||||
|
||||
// Upload another 512KB object (should succeed, total 1MB)
|
||||
env.upload_object("test2.txt", 512 * 1024).await?;
|
||||
assert!(env.object_exists("test2.txt").await?);
|
||||
|
||||
// Try to upload 1KB more (should fail due to quota)
|
||||
let upload_result = env.upload_object("test3.txt", 1024).await;
|
||||
assert!(upload_result.is_err());
|
||||
assert!(!env.object_exists("test3.txt").await?);
|
||||
|
||||
// Clean up
|
||||
env.clear_bucket_quota().await?;
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set initial quota
|
||||
env.set_bucket_quota(512 * 1024).await?;
|
||||
assert_eq!(env.get_bucket_quota().await?, Some(512 * 1024));
|
||||
|
||||
// Update quota to larger size
|
||||
env.set_bucket_quota(2 * 1024 * 1024).await?;
|
||||
assert_eq!(env.get_bucket_quota().await?, Some(2 * 1024 * 1024));
|
||||
|
||||
// Upload 1MB object (should succeed with new quota)
|
||||
env.upload_object("large_file.txt", 1024 * 1024).await?;
|
||||
assert!(env.object_exists("large_file.txt").await?);
|
||||
|
||||
// Clear quota
|
||||
env.clear_bucket_quota().await?;
|
||||
assert_eq!(env.get_bucket_quota().await?, None);
|
||||
|
||||
// Upload another large object (should succeed with no quota)
|
||||
env.upload_object("unlimited_file.txt", 5 * 1024 * 1024).await?;
|
||||
assert!(env.object_exists("unlimited_file.txt").await?);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_delete_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 1MB
|
||||
env.set_bucket_quota(1024 * 1024).await?;
|
||||
|
||||
// Fill up to quota limit
|
||||
env.upload_object("file1.txt", 512 * 1024).await?;
|
||||
env.upload_object("file2.txt", 512 * 1024).await?;
|
||||
|
||||
// Delete one file
|
||||
env.client
|
||||
.delete_object()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("file1.txt")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(!env.object_exists("file1.txt").await?);
|
||||
|
||||
// Now we should be able to upload again (quota freed up)
|
||||
env.upload_object("file3.txt", 256 * 1024).await?;
|
||||
assert!(env.object_exists("file3.txt").await?);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_usage_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota
|
||||
env.set_bucket_quota(2 * 1024 * 1024).await?;
|
||||
|
||||
// Upload some files
|
||||
env.upload_object("file1.txt", 512 * 1024).await?;
|
||||
env.upload_object("file2.txt", 256 * 1024).await?;
|
||||
|
||||
// Check usage
|
||||
let usage = env.get_bucket_usage().await?;
|
||||
assert_eq!(usage, (512 + 256) * 1024);
|
||||
|
||||
// Delete a file
|
||||
env.client
|
||||
.delete_object()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("file1.txt")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Check updated usage
|
||||
let updated_usage = env.get_bucket_usage().await?;
|
||||
assert_eq!(updated_usage, 256 * 1024);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_statistics() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 2MB
|
||||
env.set_bucket_quota(2 * 1024 * 1024).await?;
|
||||
|
||||
// Upload files to use 1.5MB
|
||||
env.upload_object("file1.txt", 1024 * 1024).await?;
|
||||
env.upload_object("file2.txt", 512 * 1024).await?;
|
||||
|
||||
// Get detailed quota statistics
|
||||
let stats = env.get_bucket_quota_stats().await?;
|
||||
|
||||
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
|
||||
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
|
||||
assert_eq!(stats.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
||||
assert_eq!(stats.get("remaining_quota").unwrap().as_u64().unwrap(), 512 * 1024);
|
||||
|
||||
let usage_percentage = stats.get("usage_percentage").unwrap().as_f64().unwrap();
|
||||
assert!((usage_percentage - 75.0).abs() < 0.1);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_check_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 1MB
|
||||
env.set_bucket_quota(1024 * 1024).await?;
|
||||
|
||||
// Upload 512KB file
|
||||
env.upload_object("existing_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Check if we can upload another 512KB (should succeed, exactly fill quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
|
||||
|
||||
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
|
||||
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
|
||||
let check_result = env.check_bucket_quota("PUT", 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
|
||||
|
||||
// Check if we can upload 600KB (should fail - would exceed quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
|
||||
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
// Check delete operation (should always be allowed)
|
||||
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_multiple_buckets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
// Create two buckets in the same environment
|
||||
let bucket1 = format!("quota-test-{}-1", uuid::Uuid::new_v4());
|
||||
let bucket2 = format!("quota-test-{}-2", uuid::Uuid::new_v4());
|
||||
|
||||
env.env.create_test_bucket(&bucket1).await?;
|
||||
env.env.create_test_bucket(&bucket2).await?;
|
||||
|
||||
// Set different quotas for each bucket
|
||||
env.set_bucket_quota_for(&bucket1, 1024 * 1024).await?; // 1MB
|
||||
env.set_bucket_quota_for(&bucket2, 2 * 1024 * 1024).await?; // 2MB
|
||||
|
||||
// Fill first bucket to quota
|
||||
env.upload_object_to_bucket(&bucket1, "big_file.txt", 1024 * 1024).await?;
|
||||
|
||||
// Should still be able to upload to second bucket
|
||||
env.upload_object_to_bucket(&bucket2, "big_file.txt", 1024 * 1024).await?;
|
||||
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Verify statistics are independent
|
||||
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
|
||||
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
|
||||
|
||||
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
|
||||
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
||||
|
||||
// Clean up
|
||||
env.env.delete_test_bucket(&bucket1).await?;
|
||||
env.env.delete_test_bucket(&bucket2).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_error_handling() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Test invalid quota type
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||
|
||||
let invalid_config = serde_json::json!({
|
||||
"quota": 1024,
|
||||
"quota_type": "SOFT" // Invalid type
|
||||
});
|
||||
|
||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
||||
assert!(response.is_err());
|
||||
let error_msg = response.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("InvalidArgument"));
|
||||
|
||||
// Test operations on non-existent bucket
|
||||
let url = format!("{}/rustfs/admin/v3/quota/non-existent-bucket", env.env.url);
|
||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await;
|
||||
assert!(response.is_err());
|
||||
let error_msg = response.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("NoSuchBucket"));
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_http_endpoints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Test 1: GET quota for bucket without quota config
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("quota") && response.contains("null"));
|
||||
|
||||
// Test 2: PUT quota - valid config
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": 1048576,
|
||||
"quota_type": "HARD"
|
||||
});
|
||||
let response = awscurl_put(&url, "a_config.to_string(), &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("success") || !response.contains("error"));
|
||||
|
||||
// Test 3: GET quota after setting
|
||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("1048576"));
|
||||
|
||||
// Test 4: GET quota stats
|
||||
let stats_url = format!("{}/rustfs/admin/v3/quota-stats/{}", env.env.url, env.bucket_name);
|
||||
let response = awscurl_get(&stats_url, &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("quota_limit") && response.contains("current_usage"));
|
||||
|
||||
// Test 5: POST quota check
|
||||
let check_url = format!("{}/rustfs/admin/v3/quota-check/{}", env.env.url, env.bucket_name);
|
||||
let check_request = serde_json::json!({
|
||||
"operation_type": "PUT",
|
||||
"operation_size": 1024
|
||||
});
|
||||
let response = awscurl_post(&check_url, &check_request.to_string(), &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("allowed"));
|
||||
|
||||
// Test 6: DELETE quota
|
||||
let response = awscurl_delete(&url, &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(!response.contains("error"));
|
||||
|
||||
// Test 7: GET quota after deletion
|
||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await?;
|
||||
assert!(response.contains("quota") && response.contains("null"));
|
||||
|
||||
// Test 8: Invalid quota type
|
||||
let invalid_config = serde_json::json!({
|
||||
"quota": 1024,
|
||||
"quota_type": "SOFT"
|
||||
});
|
||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
||||
assert!(response.is_err());
|
||||
let error_msg = response.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("InvalidArgument"));
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that a normal user with `readwrite` policy can read quota but cannot set/clear quota.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_normal_user_permissions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Admin sets quota first
|
||||
env.set_bucket_quota(1024 * 1024).await?;
|
||||
|
||||
// Create a normal user via admin API
|
||||
let normal_ak = "normaluser";
|
||||
let normal_sk = "normaluser123";
|
||||
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.env.url, normal_ak);
|
||||
let user_body = serde_json::json!({ "secretKey": normal_sk, "status": "enabled" }).to_string();
|
||||
awscurl_put(&add_user_url, &user_body, &env.env.access_key, &env.env.secret_key).await?;
|
||||
|
||||
// Attach `readwrite` policy to the normal user
|
||||
let policy_url = format!(
|
||||
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName=readwrite&userOrGroup={}&isGroup=false",
|
||||
env.env.url, normal_ak
|
||||
);
|
||||
awscurl_put(&policy_url, "", &env.env.access_key, &env.env.secret_key).await?;
|
||||
|
||||
// Normal user reads quota — should succeed
|
||||
let get_url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||
let resp = awscurl_get(&get_url, normal_ak, normal_sk).await?;
|
||||
let quota_info: serde_json::Value = serde_json::from_str(&resp)?;
|
||||
assert_eq!(quota_info.get("quota").and_then(|v| v.as_u64()), Some(1024 * 1024));
|
||||
|
||||
// Normal user reads quota stats — should succeed
|
||||
let stats_url = format!("{}/rustfs/admin/v3/quota-stats/{}", env.env.url, env.bucket_name);
|
||||
let resp = awscurl_get(&stats_url, normal_ak, normal_sk).await?;
|
||||
assert!(resp.contains("quota_limit"));
|
||||
|
||||
// Normal user sets quota — should be denied
|
||||
let set_resp = awscurl_put(
|
||||
&get_url,
|
||||
&serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string(),
|
||||
normal_ak,
|
||||
normal_sk,
|
||||
)
|
||||
.await;
|
||||
assert!(set_resp.is_err(), "normal user should not be able to set quota");
|
||||
|
||||
// Normal user clears quota — should be denied
|
||||
let del_resp = awscurl_delete(&get_url, normal_ak, normal_sk).await;
|
||||
assert!(del_resp.is_err(), "normal user should not be able to clear quota");
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_copy_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 2MB
|
||||
env.set_bucket_quota(2 * 1024 * 1024).await?;
|
||||
|
||||
// Upload initial file
|
||||
env.upload_object("original.txt", 1024 * 1024).await?;
|
||||
|
||||
// Copy file - should succeed (1MB each, total 2MB)
|
||||
env.client
|
||||
.copy_object()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("copy1.txt")
|
||||
.copy_source(format!("{}/{}", env.bucket_name, "original.txt"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(env.object_exists("copy1.txt").await?);
|
||||
|
||||
// Try to copy again - should fail (1.5MB each, total 3MB > 2MB quota)
|
||||
let copy_result = env
|
||||
.client
|
||||
.copy_object()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("copy2.txt")
|
||||
.copy_source(format!("{}/{}", env.bucket_name, "original.txt"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(copy_result.is_err());
|
||||
assert!(!env.object_exists("copy2.txt").await?);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_batch_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 2MB
|
||||
env.set_bucket_quota(2 * 1024 * 1024).await?;
|
||||
|
||||
// Upload files to fill quota
|
||||
env.upload_object("file1.txt", 1024 * 1024).await?;
|
||||
env.upload_object("file2.txt", 1024 * 1024).await?;
|
||||
|
||||
// Verify quota is full
|
||||
let upload_result = env.upload_object("file3.txt", 1024).await;
|
||||
assert!(upload_result.is_err());
|
||||
|
||||
// Delete multiple objects using batch delete
|
||||
let objects = vec![
|
||||
aws_sdk_s3::types::ObjectIdentifier::builder()
|
||||
.key("file1.txt")
|
||||
.build()
|
||||
.unwrap(),
|
||||
aws_sdk_s3::types::ObjectIdentifier::builder()
|
||||
.key("file2.txt")
|
||||
.build()
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
let delete_result = env
|
||||
.client
|
||||
.delete_objects()
|
||||
.bucket(&env.bucket_name)
|
||||
.delete(
|
||||
aws_sdk_s3::types::Delete::builder()
|
||||
.set_objects(Some(objects))
|
||||
.quiet(true)
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(delete_result.deleted().len(), 2);
|
||||
|
||||
// Now should be able to upload again (quota freed up)
|
||||
env.upload_object("file3.txt", 256 * 1024).await?;
|
||||
assert!(env.object_exists("file3.txt").await?);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
|
||||
// Set quota of 10MB
|
||||
env.set_bucket_quota(10 * 1024 * 1024).await?;
|
||||
|
||||
let key = "multipart_test.txt";
|
||||
let part_size = 5 * 1024 * 1024; // 5MB minimum per part (S3 requirement)
|
||||
|
||||
// Test 1: Multipart upload within quota (single 5MB part)
|
||||
let create_result = env
|
||||
.client
|
||||
.create_multipart_upload()
|
||||
.bucket(&env.bucket_name)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let upload_id = create_result.upload_id().unwrap();
|
||||
|
||||
// Upload single 5MB part (S3 allows single part with any size ≥ 5MB for the only part)
|
||||
let part_data = vec![1u8; part_size];
|
||||
let part_result = env
|
||||
.client
|
||||
.upload_part()
|
||||
.bucket(&env.bucket_name)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(part_data))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let uploaded_parts = vec![
|
||||
aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part_result.e_tag().unwrap())
|
||||
.build(),
|
||||
];
|
||||
|
||||
env.client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&env.bucket_name)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
aws_sdk_s3::types::CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(uploaded_parts))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(env.object_exists(key).await?);
|
||||
|
||||
// Test 2: Multipart upload exceeds quota (should fail)
|
||||
// Upload 6MB filler (total now: 5MB + 6MB = 11MB > 10MB quota)
|
||||
let upload_filler = env.upload_object("filler.txt", 6 * 1024 * 1024).await;
|
||||
// This should fail due to quota
|
||||
assert!(upload_filler.is_err());
|
||||
|
||||
// Verify filler doesn't exist
|
||||
assert!(!env.object_exists("filler.txt").await?);
|
||||
|
||||
// Now try a multipart upload that exceeds quota
|
||||
// Current usage: 5MB (from Test 1), quota: 10MB
|
||||
// Trying to upload 6MB via multipart → should fail
|
||||
|
||||
let create_result2 = env
|
||||
.client
|
||||
.create_multipart_upload()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("over_quota.txt")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let upload_id2 = create_result2.upload_id().unwrap();
|
||||
|
||||
let mut uploaded_parts2 = vec![];
|
||||
for part_num in 1..=2 {
|
||||
let part_data = vec![part_num as u8; part_size];
|
||||
let part_result = env
|
||||
.client
|
||||
.upload_part()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("over_quota.txt")
|
||||
.upload_id(upload_id2)
|
||||
.part_number(part_num)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(part_data))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
uploaded_parts2.push(
|
||||
aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(part_num)
|
||||
.e_tag(part_result.e_tag().unwrap())
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
let complete_result = env
|
||||
.client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&env.bucket_name)
|
||||
.key("over_quota.txt")
|
||||
.upload_id(upload_id2)
|
||||
.multipart_upload(
|
||||
aws_sdk_s3::types::CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(uploaded_parts2))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(complete_result.is_err());
|
||||
assert!(!env.object_exists("over_quota.txt").await?);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Used by test_distributed_lock_4_nodes_grpc in lock.rs
|
||||
#![allow(dead_code)]
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rustfs_ecstore::rpc::node_service_time_out_client_no_auth;
|
||||
use rustfs_lock::{
|
||||
LockClient, LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
||||
types::{LockMetadata, LockPriority},
|
||||
};
|
||||
use rustfs_protos::proto_gen::node_service::{GenerallyLockRequest, PingRequest};
|
||||
use tonic::Request;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// gRPC lock client without authentication for testing
|
||||
/// Similar to RemoteClient but uses no_auth client
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrpcLockClient {
|
||||
addr: String,
|
||||
}
|
||||
|
||||
impl GrpcLockClient {
|
||||
pub fn new(endpoint: String) -> Self {
|
||||
Self { addr: endpoint }
|
||||
}
|
||||
|
||||
async fn get_client(
|
||||
&self,
|
||||
) -> Result<
|
||||
rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient<
|
||||
tonic::service::interceptor::InterceptedService<tonic::transport::Channel, rustfs_ecstore::rpc::TonicInterceptor>,
|
||||
>,
|
||||
> {
|
||||
node_service_time_out_client_no_auth(&self.addr)
|
||||
.await
|
||||
.map_err(|err| LockError::internal(format!("can not get client, err: {err}")))
|
||||
}
|
||||
|
||||
/// Create a minimal LockRequest for unlock operations using only lock_id
|
||||
fn create_unlock_request(lock_id: &LockId) -> LockRequest {
|
||||
LockRequest {
|
||||
lock_id: lock_id.clone(),
|
||||
resource: lock_id.resource.clone(),
|
||||
lock_type: LockType::Exclusive, // Type doesn't matter for unlock
|
||||
owner: String::new(), // Owner not needed, server uses lock_id
|
||||
acquire_timeout: std::time::Duration::from_secs(30),
|
||||
ttl: std::time::Duration::from_secs(300),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LockClient for GrpcLockClient {
|
||||
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
|
||||
info!("grpc acquire_lock for {}", request.resource);
|
||||
let mut client = self.get_client().await?;
|
||||
let req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.lock(req)
|
||||
.await
|
||||
.map_err(|e| LockError::internal(e.to_string()))?
|
||||
.into_inner();
|
||||
|
||||
// Check for explicit error first
|
||||
if let Some(error_info) = resp.error_info {
|
||||
return Err(LockError::internal(error_info));
|
||||
}
|
||||
|
||||
// Check if the lock acquisition was successful
|
||||
if resp.success {
|
||||
// Try to deserialize lock_info from response
|
||||
let lock_info = if let Some(lock_info_json) = resp.lock_info {
|
||||
match serde_json::from_str::<LockInfo>(&lock_info_json) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
// If deserialization fails, fall back to constructing from request
|
||||
warn!("Failed to deserialize lock_info from response: {}, using request data", e);
|
||||
LockInfo {
|
||||
id: request.lock_id.clone(),
|
||||
resource: request.resource.clone(),
|
||||
lock_type: request.lock_type,
|
||||
status: LockStatus::Acquired,
|
||||
owner: request.owner.clone(),
|
||||
acquired_at: std::time::SystemTime::now(),
|
||||
expires_at: std::time::SystemTime::now() + request.ttl,
|
||||
last_refreshed: std::time::SystemTime::now(),
|
||||
metadata: request.metadata.clone(),
|
||||
priority: request.priority,
|
||||
wait_start_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If lock_info is not provided, construct from request
|
||||
LockInfo {
|
||||
id: request.lock_id.clone(),
|
||||
resource: request.resource.clone(),
|
||||
lock_type: request.lock_type,
|
||||
status: LockStatus::Acquired,
|
||||
owner: request.owner.clone(),
|
||||
acquired_at: std::time::SystemTime::now(),
|
||||
expires_at: std::time::SystemTime::now() + request.ttl,
|
||||
last_refreshed: std::time::SystemTime::now(),
|
||||
metadata: request.metadata.clone(),
|
||||
priority: request.priority,
|
||||
wait_start_time: None,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LockResponse::success(lock_info, std::time::Duration::ZERO))
|
||||
} else {
|
||||
// Lock acquisition failed
|
||||
Ok(LockResponse::failure(
|
||||
"Lock acquisition failed on remote server".to_string(),
|
||||
std::time::Duration::ZERO,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn release(&self, lock_id: &LockId) -> Result<bool> {
|
||||
info!("grpc release for {}", lock_id);
|
||||
|
||||
let unlock_request = Self::create_unlock_request(lock_id);
|
||||
let request_string = serde_json::to_string(&unlock_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||
let mut client = self.get_client().await?;
|
||||
|
||||
let req = Request::new(GenerallyLockRequest {
|
||||
args: request_string.clone(),
|
||||
});
|
||||
let resp = client
|
||||
.un_lock(req)
|
||||
.await
|
||||
.map_err(|e| LockError::internal(e.to_string()))?
|
||||
.into_inner();
|
||||
|
||||
if let Some(error_info) = resp.error_info {
|
||||
return Err(LockError::internal(error_info));
|
||||
}
|
||||
Ok(resp.success)
|
||||
}
|
||||
|
||||
async fn refresh(&self, lock_id: &LockId) -> Result<bool> {
|
||||
info!("grpc refresh for {}", lock_id);
|
||||
let refresh_request = Self::create_unlock_request(lock_id);
|
||||
let mut client = self.get_client().await?;
|
||||
let req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&refresh_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
let resp = client
|
||||
.refresh(req)
|
||||
.await
|
||||
.map_err(|e| LockError::internal(e.to_string()))?
|
||||
.into_inner();
|
||||
if let Some(error_info) = resp.error_info {
|
||||
return Err(LockError::internal(error_info));
|
||||
}
|
||||
Ok(resp.success)
|
||||
}
|
||||
|
||||
async fn force_release(&self, lock_id: &LockId) -> Result<bool> {
|
||||
info!("grpc force_release for {}", lock_id);
|
||||
let force_request = Self::create_unlock_request(lock_id);
|
||||
let mut client = self.get_client().await?;
|
||||
let req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&force_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
let resp = client
|
||||
.force_un_lock(req)
|
||||
.await
|
||||
.map_err(|e| LockError::internal(e.to_string()))?
|
||||
.into_inner();
|
||||
if let Some(error_info) = resp.error_info {
|
||||
return Err(LockError::internal(error_info));
|
||||
}
|
||||
Ok(resp.success)
|
||||
}
|
||||
|
||||
async fn check_status(&self, lock_id: &LockId) -> Result<Option<LockInfo>> {
|
||||
info!("grpc check_status for {}", lock_id);
|
||||
|
||||
// Since there's no direct status query in the gRPC service,
|
||||
// we attempt a non-blocking lock acquisition to check if the resource is available
|
||||
let status_request = Self::create_unlock_request(lock_id);
|
||||
let mut client = self.get_client().await?;
|
||||
|
||||
// Try to acquire a very short-lived lock to test availability
|
||||
let req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&status_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
|
||||
// Try exclusive lock first with very short timeout
|
||||
let resp = client.lock(req).await;
|
||||
|
||||
match resp {
|
||||
Ok(response) => {
|
||||
let resp = response.into_inner();
|
||||
if resp.success {
|
||||
// If we successfully acquired the lock, the resource was free
|
||||
// Immediately release it
|
||||
let release_req = Request::new(GenerallyLockRequest {
|
||||
args: serde_json::to_string(&status_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||
});
|
||||
let _ = client.un_lock(release_req).await; // Best effort release
|
||||
|
||||
// Return None since no one was holding the lock
|
||||
Ok(None)
|
||||
} else {
|
||||
// Lock acquisition failed, meaning someone is holding it
|
||||
// We can't determine the exact details remotely, so return a generic status
|
||||
Ok(Some(LockInfo {
|
||||
id: lock_id.clone(),
|
||||
resource: lock_id.resource.clone(),
|
||||
lock_type: LockType::Exclusive, // We can't know the exact type
|
||||
status: LockStatus::Acquired,
|
||||
owner: "unknown".to_string(), // Remote client can't determine owner
|
||||
acquired_at: std::time::SystemTime::now(),
|
||||
expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
|
||||
last_refreshed: std::time::SystemTime::now(),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
wait_start_time: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Communication error or lock is held
|
||||
Ok(Some(LockInfo {
|
||||
id: lock_id.clone(),
|
||||
resource: lock_id.resource.clone(),
|
||||
lock_type: LockType::Exclusive,
|
||||
status: LockStatus::Acquired,
|
||||
owner: "unknown".to_string(),
|
||||
acquired_at: std::time::SystemTime::now(),
|
||||
expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
|
||||
last_refreshed: std::time::SystemTime::now(),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
wait_start_time: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stats(&self) -> Result<LockStats> {
|
||||
info!("grpc get_stats from {}", self.addr);
|
||||
|
||||
// Since there's no direct statistics endpoint in the gRPC service,
|
||||
// we return basic stats indicating this is a remote client
|
||||
let stats = LockStats {
|
||||
last_updated: std::time::SystemTime::now(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
// Use Ping interface to test if remote service is online
|
||||
let mut client = match self.get_client().await {
|
||||
Ok(client) => client,
|
||||
Err(_) => {
|
||||
info!("grpc client {} connection failed", self.addr);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let ping_req = Request::new(PingRequest {
|
||||
version: 1,
|
||||
body: bytes::Bytes::new(),
|
||||
});
|
||||
|
||||
match client.ping(ping_req).await {
|
||||
Ok(_) => {
|
||||
info!("grpc client {} is online", self.addr);
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
info!("grpc client {} ping failed", self.addr);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_local(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Used by test_distributed_lock_4_nodes_grpc in lock.rs
|
||||
#![allow(dead_code)]
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use rustfs_lock::{LockClient, LockRequest};
|
||||
use rustfs_protos::{
|
||||
models::PingBodyBuilder,
|
||||
proto_gen::node_service::{
|
||||
GenerallyLockRequest, GenerallyLockResponse, PingRequest, PingResponse, node_service_server::NodeService,
|
||||
},
|
||||
};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::debug;
|
||||
|
||||
type ResponseStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send>>;
|
||||
|
||||
/// Minimal NodeService implementation that only supports Lock RPCs
|
||||
/// Used for testing distributed lock scenarios with real gRPC
|
||||
#[derive(Debug)]
|
||||
pub struct MinimalLockNodeService {
|
||||
lock_client: Arc<dyn LockClient>,
|
||||
}
|
||||
|
||||
impl MinimalLockNodeService {
|
||||
pub fn new(lock_client: Arc<dyn LockClient>) -> Self {
|
||||
Self { lock_client }
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl NodeService for MinimalLockNodeService {
|
||||
async fn ping(&self, _request: Request<PingRequest>) -> Result<Response<PingResponse>, Status> {
|
||||
debug!("MinimalLockNodeService: PING");
|
||||
|
||||
let mut fbb = flatbuffers::FlatBufferBuilder::new();
|
||||
let payload = fbb.create_vector(b"pong");
|
||||
|
||||
let mut builder = PingBodyBuilder::new(&mut fbb);
|
||||
builder.add_payload(payload);
|
||||
let root = builder.finish();
|
||||
fbb.finish(root, None);
|
||||
|
||||
let finished_data = fbb.finished_data();
|
||||
|
||||
Ok(Response::new(PingResponse {
|
||||
version: 1,
|
||||
body: Bytes::copy_from_slice(finished_data),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||
let request = request.into_inner();
|
||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||
Ok(args) => args,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!("can not decode args, err: {err}")),
|
||||
lock_info: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match self.lock_client.acquire_lock(&args).await {
|
||||
Ok(result) => {
|
||||
let lock_info_json = result.lock_info.as_ref().and_then(|info| serde_json::to_string(info).ok());
|
||||
Ok(Response::new(GenerallyLockResponse {
|
||||
success: result.success,
|
||||
error_info: None,
|
||||
lock_info: lock_info_json,
|
||||
}))
|
||||
}
|
||||
Err(err) => Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!(
|
||||
"can not lock, resource: {0}, owner: {1}, err: {2}",
|
||||
args.resource, args.owner, err
|
||||
)),
|
||||
lock_info: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn un_lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||
let request = request.into_inner();
|
||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||
Ok(args) => args,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!("can not decode args, err: {err}")),
|
||||
lock_info: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match self.lock_client.release(&args.lock_id).await {
|
||||
Ok(success) => Ok(Response::new(GenerallyLockResponse {
|
||||
success,
|
||||
error_info: None,
|
||||
lock_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!(
|
||||
"can not unlock, resource: {0}, owner: {1}, err: {2}",
|
||||
args.resource, args.owner, err
|
||||
)),
|
||||
lock_info: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn force_un_lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||
let request = request.into_inner();
|
||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||
Ok(args) => args,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!("can not decode args, err: {err}")),
|
||||
lock_info: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match self.lock_client.force_release(&args.lock_id).await {
|
||||
Ok(success) => Ok(Response::new(GenerallyLockResponse {
|
||||
success,
|
||||
error_info: None,
|
||||
lock_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!(
|
||||
"can not force_unlock, resource: {0}, owner: {1}, err: {2}",
|
||||
args.resource, args.owner, err
|
||||
)),
|
||||
lock_info: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||
let request = request.into_inner();
|
||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||
Ok(args) => args,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!("can not decode args, err: {err}")),
|
||||
lock_info: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match self.lock_client.refresh(&args.lock_id).await {
|
||||
Ok(success) => Ok(Response::new(GenerallyLockResponse {
|
||||
success,
|
||||
error_info: None,
|
||||
lock_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GenerallyLockResponse {
|
||||
success: false,
|
||||
error_info: Some(format!("can not refresh, err: {err}")),
|
||||
lock_info: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// All other methods return unimplemented
|
||||
async fn heal_bucket(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::HealBucketRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::HealBucketResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn list_bucket(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ListBucketRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ListBucketResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_bucket(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeBucketRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::MakeBucketResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_bucket_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetBucketInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetBucketInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_bucket(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteBucketRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteBucketResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_all(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadAllRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadAllResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn write_all(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::WriteAllRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteAllResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn verify_file(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::VerifyFileRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::VerifyFileResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_parts(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadPartsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadPartsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn check_parts(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::CheckPartsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::CheckPartsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_part(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenamePartRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenamePartResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameFileRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameFileResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::WriteRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
type WriteStreamStream = ResponseStream<rustfs_protos::proto_gen::node_service::WriteResponse>;
|
||||
|
||||
async fn write_stream(
|
||||
&self,
|
||||
_request: Request<tonic::Streaming<rustfs_protos::proto_gen::node_service::WriteRequest>>,
|
||||
) -> Result<Response<Self::WriteStreamStream>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
type ReadAtStream = ResponseStream<rustfs_protos::proto_gen::node_service::ReadAtResponse>;
|
||||
|
||||
async fn read_at(
|
||||
&self,
|
||||
_request: Request<tonic::Streaming<rustfs_protos::proto_gen::node_service::ReadAtRequest>>,
|
||||
) -> Result<Response<Self::ReadAtStream>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn list_dir(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ListDirRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ListDirResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
type WalkDirStream = ResponseStream<rustfs_protos::proto_gen::node_service::WalkDirResponse>;
|
||||
|
||||
async fn walk_dir(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::WalkDirRequest>,
|
||||
) -> Result<Response<Self::WalkDirStream>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_volumes(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::MakeVolumesResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_volume(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumeRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::MakeVolumeResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn list_volumes(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ListVolumesRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ListVolumesResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn stat_volume(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::StatVolumeRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::StatVolumeResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_paths(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeletePathsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeletePathsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn update_metadata(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::UpdateMetadataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::UpdateMetadataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_metadata(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadMetadataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadMetadataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn write_metadata(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::WriteMetadataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteMetadataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_version(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadVersionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadVersionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_xl(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadXlRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadXlResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_version(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_versions(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn read_multiple(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReadMultipleRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReadMultipleResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_volume(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVolumeRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVolumeResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn disk_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DiskInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DiskInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn local_storage_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LocalStorageInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LocalStorageInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn server_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ServerInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ServerInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_cpus(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetCpusRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetCpusResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_net_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetNetInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetNetInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_partitions(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetPartitionsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetPartitionsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_os_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetOsInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetOsInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_se_linux_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetSeLinuxInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetSeLinuxInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_sys_config(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetSysConfigRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetSysConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_sys_errors(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetSysErrorsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetSysErrorsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_mem_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetMemInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetMemInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_proc_info(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetProcInfoRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetProcInfoResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_bucket_metadata(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadBucketMetadataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadBucketMetadataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_policy(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadPolicyRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadPolicyResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_group(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadGroupRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadGroupResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_policy_mapping(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadPolicyMappingRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadPolicyMappingResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_rebalance_meta(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadRebalanceMetaRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadRebalanceMetaResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_metrics(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetMetricsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetMetricsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn start_profiling(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::StartProfilingRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::StartProfilingResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn download_profile_data(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DownloadProfileDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DownloadProfileDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_bucket_stats(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetBucketStatsDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetBucketStatsDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_sr_metrics(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetSrMetricsDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetSrMetricsDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_all_bucket_stats(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetAllBucketStatsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetAllBucketStatsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_bucket_metadata(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteBucketMetadataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteBucketMetadataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_policy(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeletePolicyRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeletePolicyResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_user(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteUserRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteUserResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_service_account(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteServiceAccountRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteServiceAccountResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_user(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadUserRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadUserResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_service_account(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadServiceAccountRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadServiceAccountResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn reload_site_replication_config(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReloadSiteReplicationConfigRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReloadSiteReplicationConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn signal_service(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::SignalServiceRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::SignalServiceResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn background_heal_status(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::BackgroundHealStatusResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn get_metacache_listing(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GetMetacacheListingRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetMetacacheListingResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn update_metacache_listing(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::UpdateMetacacheListingRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::UpdateMetacacheListingResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn reload_pool_meta(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ReloadPoolMetaRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReloadPoolMetaResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn stop_rebalance(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::StopRebalanceRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::StopRebalanceResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn load_transition_tier_config(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a gRPC lock server on a random port
|
||||
/// Returns the address and a shutdown handle
|
||||
pub async fn spawn_lock_server(
|
||||
lock_client: Arc<dyn LockClient>,
|
||||
) -> std::result::Result<(String, tokio::task::JoinHandle<()>), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let addr_str = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let service = MinimalLockNodeService::new(lock_client);
|
||||
let server = tonic::transport::Server::builder()
|
||||
.add_service(rustfs_protos::proto_gen::node_service::node_service_server::NodeServiceServer::new(
|
||||
service,
|
||||
))
|
||||
.serve_with_incoming(TcpListenerStream::new(listener));
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = server.await {
|
||||
eprintln!("gRPC server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok((addr_str, handle))
|
||||
}
|
||||
@@ -13,779 +13,118 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rustfs_ecstore::disk::endpoint::Endpoint;
|
||||
use rustfs_lock::client::{LockClient, local::LocalClient, remote::RemoteClient};
|
||||
use rustfs_lock::types::{LockInfo, LockResponse, LockStats};
|
||||
use rustfs_lock::{LockId, LockMetadata, LockPriority, LockType};
|
||||
use rustfs_lock::{LockRequest, NamespaceLock, NamespaceLockManager};
|
||||
use rustfs_protos::{node_service_time_out_client, proto_gen::node_service::GenerallyLockRequest};
|
||||
use serial_test::serial;
|
||||
use std::{collections::HashMap, error::Error, sync::Arc, time::Duration};
|
||||
use tokio::time::sleep;
|
||||
use tonic::Request;
|
||||
use url::Url;
|
||||
|
||||
const CLUSTER_ADDR: &str = "http://localhost:9000";
|
||||
|
||||
fn get_cluster_endpoints() -> Vec<Endpoint> {
|
||||
vec![Endpoint {
|
||||
url: Url::parse(CLUSTER_ADDR).unwrap(),
|
||||
is_local: false,
|
||||
pool_idx: 0,
|
||||
set_idx: 0,
|
||||
disk_idx: 0,
|
||||
}]
|
||||
}
|
||||
|
||||
async fn create_unique_clients(endpoints: &[Endpoint]) -> Result<Vec<Arc<dyn LockClient>>, Box<dyn Error>> {
|
||||
let mut unique_endpoints: HashMap<String, &Endpoint> = HashMap::new();
|
||||
|
||||
for endpoint in endpoints {
|
||||
if endpoint.is_local {
|
||||
unique_endpoints.insert("local".to_string(), endpoint);
|
||||
} else {
|
||||
let host_port = format!(
|
||||
"{}:{}",
|
||||
endpoint.url.host_str().unwrap_or("localhost"),
|
||||
endpoint.url.port().unwrap_or(9000)
|
||||
);
|
||||
unique_endpoints.insert(host_port, endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
let mut clients = Vec::new();
|
||||
for (_key, endpoint) in unique_endpoints {
|
||||
if endpoint.is_local {
|
||||
clients.push(Arc::new(LocalClient::new()) as Arc<dyn LockClient>);
|
||||
} else {
|
||||
clients.push(Arc::new(RemoteClient::new(endpoint.url.to_string())) as Arc<dyn LockClient>);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(clients)
|
||||
}
|
||||
use super::{grpc_lock_client::GrpcLockClient, grpc_lock_server::spawn_lock_server};
|
||||
use rustfs_lock::{GlobalLockManager, NamespaceLock, ObjectKey, client::local::LocalClient};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_guard_drop_releases_exclusive_lock_local() -> Result<(), Box<dyn Error>> {
|
||||
// Single local client; no external server required
|
||||
let client: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let ns_lock = NamespaceLock::with_clients("e2e_guard_local".to_string(), vec![client]);
|
||||
async fn test_distributed_lock_4_nodes_grpc() {
|
||||
// Spawn 4 gRPC lock servers, each with its own GlobalLockManager
|
||||
let manager1 = Arc::new(GlobalLockManager::new());
|
||||
let manager2 = Arc::new(GlobalLockManager::new());
|
||||
let manager3 = Arc::new(GlobalLockManager::new());
|
||||
let manager4 = Arc::new(GlobalLockManager::new());
|
||||
|
||||
// Acquire exclusive guard
|
||||
let g1 = ns_lock
|
||||
.lock_guard("guard_exclusive", "owner1", Duration::from_millis(100), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(g1.is_some(), "first guard acquisition should succeed");
|
||||
let client1: Arc<dyn rustfs_lock::LockClient> = Arc::new(LocalClient::with_manager(manager1));
|
||||
let client2: Arc<dyn rustfs_lock::LockClient> = Arc::new(LocalClient::with_manager(manager2));
|
||||
let client3: Arc<dyn rustfs_lock::LockClient> = Arc::new(LocalClient::with_manager(manager3));
|
||||
let client4: Arc<dyn rustfs_lock::LockClient> = Arc::new(LocalClient::with_manager(manager4));
|
||||
|
||||
// While g1 is alive, second exclusive acquisition should fail
|
||||
let g2 = ns_lock
|
||||
.lock_guard("guard_exclusive", "owner2", Duration::from_millis(50), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(g2.is_none(), "second guard acquisition should fail while first is held");
|
||||
// Spawn 4 gRPC servers on random ports
|
||||
let (addr1, handle1) = spawn_lock_server(client1).await.expect("Failed to spawn server 1");
|
||||
let (addr2, handle2) = spawn_lock_server(client2).await.expect("Failed to spawn server 2");
|
||||
let (addr3, handle3) = spawn_lock_server(client3).await.expect("Failed to spawn server 3");
|
||||
let (addr4, handle4) = spawn_lock_server(client4).await.expect("Failed to spawn server 4");
|
||||
|
||||
// Drop first guard to trigger background release
|
||||
drop(g1);
|
||||
// Give the background unlock worker a short moment to process
|
||||
sleep(Duration::from_millis(80)).await;
|
||||
// Give servers a moment to start
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Now acquisition should succeed
|
||||
let g3 = ns_lock
|
||||
.lock_guard("guard_exclusive", "owner2", Duration::from_millis(100), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(g3.is_some(), "acquisition should succeed after guard drop releases the lock");
|
||||
drop(g3);
|
||||
// Create 4 gRPC clients (no auth)
|
||||
let grpc_client1: Arc<dyn rustfs_lock::LockClient> = Arc::new(GrpcLockClient::new(addr1));
|
||||
let grpc_client2: Arc<dyn rustfs_lock::LockClient> = Arc::new(GrpcLockClient::new(addr2));
|
||||
let grpc_client3: Arc<dyn rustfs_lock::LockClient> = Arc::new(GrpcLockClient::new(addr3));
|
||||
let grpc_client4: Arc<dyn rustfs_lock::LockClient> = Arc::new(GrpcLockClient::new(addr4));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
let clients = vec![grpc_client1, grpc_client2, grpc_client3, grpc_client4];
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_guard_shared_then_write_after_drop() -> Result<(), Box<dyn Error>> {
|
||||
// Two shared read guards should coexist; write should be blocked until they drop
|
||||
let client: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let ns_lock = NamespaceLock::with_clients("e2e_guard_rw".to_string(), vec![client]);
|
||||
// Create NamespaceLock with 4 clients and quorum=3
|
||||
let lock = NamespaceLock::with_clients_and_quorum("grpc-4-node".to_string(), clients, 3);
|
||||
assert_eq!(lock.namespace(), "grpc-4-node");
|
||||
|
||||
// Acquire two read guards
|
||||
let r1 = ns_lock
|
||||
.rlock_guard("rw_resource", "reader1", Duration::from_millis(100), Duration::from_secs(5))
|
||||
.await?;
|
||||
let r2 = ns_lock
|
||||
.rlock_guard("rw_resource", "reader2", Duration::from_millis(100), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(r1.is_some() && r2.is_some(), "both read guards should be acquired");
|
||||
|
||||
// Attempt write while readers hold the lock should fail
|
||||
let w_fail = ns_lock
|
||||
.lock_guard("rw_resource", "writer", Duration::from_millis(50), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(w_fail.is_none(), "write should be blocked when read guards are active");
|
||||
|
||||
// Drop read guards to release
|
||||
drop(r1);
|
||||
drop(r2);
|
||||
sleep(Duration::from_millis(80)).await;
|
||||
|
||||
// Now write should succeed
|
||||
let w_ok = ns_lock
|
||||
.lock_guard("rw_resource", "writer", Duration::from_millis(150), Duration::from_secs(5))
|
||||
.await?;
|
||||
assert!(w_ok.is_some(), "write should succeed after read guards are dropped");
|
||||
drop(w_ok);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_lock_unlock_rpc() -> Result<(), Box<dyn Error>> {
|
||||
let args = LockRequest {
|
||||
lock_id: LockId::new_deterministic("dandan"),
|
||||
resource: "dandan".to_string(),
|
||||
lock_type: LockType::Exclusive,
|
||||
owner: "dd".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
let resource = ObjectKey {
|
||||
bucket: Arc::from("test-bucket"),
|
||||
object: Arc::from("test-object"),
|
||||
version: None,
|
||||
};
|
||||
let args = serde_json::to_string(&args)?;
|
||||
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
println!("got client");
|
||||
let request = Request::new(GenerallyLockRequest { args: args.clone() });
|
||||
// Test 1: Owner A acquires write lock successfully
|
||||
let mut guard_a = lock
|
||||
.get_write_lock(resource.clone(), "owner-a", Duration::from_secs(5))
|
||||
.await
|
||||
.expect("Owner A should acquire write lock");
|
||||
|
||||
println!("start request");
|
||||
let response = client.lock(request).await?.into_inner();
|
||||
println!("request ended");
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not get lock: {error_info}");
|
||||
}
|
||||
|
||||
let request = Request::new(GenerallyLockRequest { args });
|
||||
let response = client.un_lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not get un_lock: {error_info}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mock client that simulates remote node failures
|
||||
#[derive(Debug)]
|
||||
struct FailingMockClient {
|
||||
local_client: Arc<dyn LockClient>,
|
||||
should_fail_acquire: bool,
|
||||
should_fail_release: bool,
|
||||
}
|
||||
|
||||
impl FailingMockClient {
|
||||
fn new(should_fail_acquire: bool, should_fail_release: bool) -> Self {
|
||||
Self {
|
||||
local_client: Arc::new(LocalClient::new()),
|
||||
should_fail_acquire,
|
||||
should_fail_release,
|
||||
// Verify it's a Standard guard (DistributedLock path)
|
||||
match &guard_a {
|
||||
rustfs_lock::NamespaceLockGuard::Standard(_) => {
|
||||
// Expected for distributed lock
|
||||
}
|
||||
rustfs_lock::NamespaceLockGuard::Fast(_) => {
|
||||
panic!("Expected Standard guard for distributed lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LockClient for FailingMockClient {
|
||||
async fn acquire_exclusive(&self, request: &LockRequest) -> rustfs_lock::error::Result<LockResponse> {
|
||||
if self.should_fail_acquire {
|
||||
// Simulate network timeout or remote node failure
|
||||
return Ok(LockResponse::failure("Simulated remote node failure", Duration::from_millis(100)));
|
||||
}
|
||||
self.local_client.acquire_exclusive(request).await
|
||||
}
|
||||
|
||||
async fn acquire_shared(&self, request: &LockRequest) -> rustfs_lock::error::Result<LockResponse> {
|
||||
if self.should_fail_acquire {
|
||||
return Ok(LockResponse::failure("Simulated remote node failure", Duration::from_millis(100)));
|
||||
}
|
||||
self.local_client.acquire_shared(request).await
|
||||
}
|
||||
|
||||
async fn release(&self, lock_id: &LockId) -> rustfs_lock::error::Result<bool> {
|
||||
if self.should_fail_release {
|
||||
return Err(rustfs_lock::error::LockError::internal("Simulated release failure"));
|
||||
}
|
||||
self.local_client.release(lock_id).await
|
||||
}
|
||||
|
||||
async fn refresh(&self, lock_id: &LockId) -> rustfs_lock::error::Result<bool> {
|
||||
self.local_client.refresh(lock_id).await
|
||||
}
|
||||
|
||||
async fn force_release(&self, lock_id: &LockId) -> rustfs_lock::error::Result<bool> {
|
||||
self.local_client.force_release(lock_id).await
|
||||
}
|
||||
|
||||
async fn check_status(&self, lock_id: &LockId) -> rustfs_lock::error::Result<Option<LockInfo>> {
|
||||
self.local_client.check_status(lock_id).await
|
||||
}
|
||||
|
||||
async fn get_stats(&self) -> rustfs_lock::error::Result<LockStats> {
|
||||
self.local_client.get_stats().await
|
||||
}
|
||||
|
||||
async fn close(&self) -> rustfs_lock::error::Result<()> {
|
||||
self.local_client.close().await
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
if self.should_fail_acquire {
|
||||
return false; // Simulate offline node
|
||||
}
|
||||
true // Simulate online node
|
||||
}
|
||||
|
||||
async fn is_local(&self) -> bool {
|
||||
false // Simulate remote client
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_transactional_lock_with_remote_failure() -> Result<(), Box<dyn Error>> {
|
||||
println!("🧪 Testing transactional lock with simulated remote node failure");
|
||||
|
||||
// Create a two-node cluster: one local (success) + one remote (failure)
|
||||
let local_client: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let failing_remote_client: Arc<dyn LockClient> = Arc::new(FailingMockClient::new(true, false));
|
||||
|
||||
let clients = vec![local_client, failing_remote_client];
|
||||
let ns_lock = NamespaceLock::with_clients("test_transactional".to_string(), clients);
|
||||
|
||||
let resource = "critical_resource".to_string();
|
||||
|
||||
// Test single lock operation with 2PC
|
||||
println!("📝 Testing single lock with remote failure...");
|
||||
let request = LockRequest::new(&resource, LockType::Exclusive, "test_owner").with_ttl(Duration::from_secs(30));
|
||||
|
||||
let response = ns_lock.acquire_lock(&request).await?;
|
||||
|
||||
// Should fail because quorum (2/2) is not met due to remote failure
|
||||
assert!(!response.success, "Lock should fail due to remote node failure");
|
||||
println!("✅ Single lock correctly failed due to remote node failure");
|
||||
|
||||
// Verify no locks are left behind on the local node
|
||||
let local_client_direct = LocalClient::new();
|
||||
let lock_id = LockId::new_deterministic(&ns_lock.get_resource_key(&resource));
|
||||
let lock_status = local_client_direct.check_status(&lock_id).await?;
|
||||
assert!(lock_status.is_none(), "No lock should remain on local node after rollback");
|
||||
println!("✅ Verified rollback: no locks left on local node");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_transactional_batch_lock_with_mixed_failures() -> Result<(), Box<dyn Error>> {
|
||||
println!("🧪 Testing transactional batch lock with mixed node failures");
|
||||
|
||||
// Create a cluster with different failure patterns
|
||||
let local_client: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let failing_remote_client: Arc<dyn LockClient> = Arc::new(FailingMockClient::new(true, false));
|
||||
|
||||
let clients = vec![local_client, failing_remote_client];
|
||||
let ns_lock = NamespaceLock::with_clients("test_batch_transactional".to_string(), clients);
|
||||
|
||||
let resources = vec!["resource_1".to_string(), "resource_2".to_string(), "resource_3".to_string()];
|
||||
|
||||
println!("📝 Testing batch lock with remote failure...");
|
||||
let result = ns_lock
|
||||
.lock_batch(&resources, "batch_owner", Duration::from_millis(100), Duration::from_secs(30))
|
||||
.await?;
|
||||
|
||||
// Should fail because remote node cannot acquire locks
|
||||
assert!(!result, "Batch lock should fail due to remote node failure");
|
||||
println!("✅ Batch lock correctly failed due to remote node failure");
|
||||
|
||||
// Verify no locks are left behind on any resource
|
||||
let local_client_direct = LocalClient::new();
|
||||
for resource in &resources {
|
||||
let lock_id = LockId::new_deterministic(&ns_lock.get_resource_key(resource));
|
||||
let lock_status = local_client_direct.check_status(&lock_id).await?;
|
||||
assert!(lock_status.is_none(), "No lock should remain for resource: {resource}");
|
||||
}
|
||||
println!("✅ Verified rollback: no locks left on any resource");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_transactional_lock_with_quorum_success() -> Result<(), Box<dyn Error>> {
|
||||
println!("🧪 Testing transactional lock with quorum success");
|
||||
|
||||
// Create a three-node cluster where 2 succeed and 1 fails (quorum = 2 automatically)
|
||||
let local_client1: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let local_client2: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let failing_remote_client: Arc<dyn LockClient> = Arc::new(FailingMockClient::new(true, false));
|
||||
|
||||
let clients = vec![local_client1, local_client2, failing_remote_client];
|
||||
let ns_lock = NamespaceLock::with_clients("test_quorum".to_string(), clients);
|
||||
|
||||
let resource = "quorum_resource".to_string();
|
||||
|
||||
println!("📝 Testing lock with automatic quorum=2, 2 success + 1 failure...");
|
||||
let request = LockRequest::new(&resource, LockType::Exclusive, "quorum_owner").with_ttl(Duration::from_secs(30));
|
||||
|
||||
let response = ns_lock.acquire_lock(&request).await?;
|
||||
|
||||
// Should fail because we require all nodes to succeed for consistency
|
||||
// (even though quorum is met, the implementation requires all nodes for consistency)
|
||||
assert!(!response.success, "Lock should fail due to consistency requirement");
|
||||
println!("✅ Lock correctly failed due to consistency requirement (partial success rolled back)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_transactional_lock_rollback_on_release_failure() -> Result<(), Box<dyn Error>> {
|
||||
println!("🧪 Testing rollback behavior when release fails");
|
||||
|
||||
// Create clients where acquire succeeds but release fails
|
||||
let local_client: Arc<dyn LockClient> = Arc::new(LocalClient::new());
|
||||
let failing_release_client: Arc<dyn LockClient> = Arc::new(FailingMockClient::new(false, true));
|
||||
|
||||
let clients = vec![local_client, failing_release_client];
|
||||
let ns_lock = NamespaceLock::with_clients("test_release_failure".to_string(), clients);
|
||||
|
||||
let resource = "release_test_resource".to_string();
|
||||
|
||||
println!("📝 Testing lock acquisition with release failure handling...");
|
||||
let request = LockRequest::new(&resource, LockType::Exclusive, "test_owner").with_ttl(Duration::from_secs(30));
|
||||
|
||||
// This should fail because both LocalClient instances share the same global lock map
|
||||
// The first client (LocalClient) will acquire the lock, but the second client
|
||||
// (FailingMockClient's internal LocalClient) will fail to acquire the same resource
|
||||
let response = ns_lock.acquire_lock(&request).await?;
|
||||
|
||||
// The operation should fail due to lock contention between the two LocalClient instances
|
||||
assert!(
|
||||
!response.success,
|
||||
"Lock should fail due to lock contention between LocalClient instances sharing global lock map"
|
||||
);
|
||||
println!("✅ Lock correctly failed due to lock contention (both clients use same global lock map)");
|
||||
|
||||
// Verify no locks are left behind after rollback
|
||||
let local_client_direct = LocalClient::new();
|
||||
let lock_id = LockId::new_deterministic(&ns_lock.get_resource_key(&resource));
|
||||
let lock_status = local_client_direct.check_status(&lock_id).await?;
|
||||
assert!(lock_status.is_none(), "No lock should remain after rollback");
|
||||
println!("✅ Verified rollback: no locks left after failed acquisition");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_lock_unlock_ns_lock() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock = NamespaceLock::with_clients("test".to_string(), clients);
|
||||
|
||||
let resources = vec!["foo".to_string()];
|
||||
let result = ns_lock
|
||||
.lock_batch(&resources, "dandan", Duration::from_secs(5), Duration::from_secs(10))
|
||||
// Test 2: Owner B tries to acquire write lock while A holds it - should fail
|
||||
// Since all 4 backends are holding locks from owner-a, owner-b cannot acquire on any backend
|
||||
// This means 0 successes < quorum(3), so acquisition should fail
|
||||
let result_b = lock
|
||||
.get_write_lock(resource.clone(), "owner-b", Duration::from_millis(100))
|
||||
.await;
|
||||
match &result {
|
||||
Ok(success) => println!("Lock result: {success}"),
|
||||
Err(e) => println!("Lock error: {e}"),
|
||||
}
|
||||
let result = result?;
|
||||
assert!(result, "Lock should succeed, but got: {result}");
|
||||
|
||||
ns_lock.unlock_batch(&resources, "dandan").await?;
|
||||
Ok(())
|
||||
}
|
||||
assert!(result_b.is_err(), "Owner B should fail to acquire lock while owner A holds it");
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_concurrent_lock_attempts() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock = NamespaceLock::with_clients("test".to_string(), clients);
|
||||
let resource = vec!["concurrent_resource".to_string()];
|
||||
|
||||
// First lock should succeed
|
||||
println!("Attempting first lock...");
|
||||
let result1 = ns_lock
|
||||
.lock_batch(&resource, "owner1", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("First lock result: {result1}");
|
||||
assert!(result1, "First lock should succeed");
|
||||
|
||||
// Second lock should fail (resource already locked)
|
||||
println!("Attempting second lock...");
|
||||
let result2 = ns_lock
|
||||
.lock_batch(&resource, "owner2", Duration::from_secs(1), Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("Second lock result: {result2}");
|
||||
assert!(!result2, "Second lock should fail");
|
||||
|
||||
// Unlock by first owner
|
||||
println!("Unlocking first lock...");
|
||||
ns_lock.unlock_batch(&resource, "owner1").await?;
|
||||
println!("First lock unlocked");
|
||||
|
||||
// Now second owner should be able to lock
|
||||
println!("Attempting third lock...");
|
||||
let result3 = ns_lock
|
||||
.lock_batch(&resource, "owner2", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("Third lock result: {result3}");
|
||||
assert!(result3, "Lock should succeed after unlock");
|
||||
|
||||
// Clean up
|
||||
println!("Cleaning up...");
|
||||
ns_lock.unlock_batch(&resource, "owner2").await?;
|
||||
println!("Test completed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_read_write_lock_compatibility() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock = NamespaceLock::with_clients("test_rw".to_string(), clients);
|
||||
let resource = vec!["rw_resource".to_string()];
|
||||
|
||||
// First read lock should succeed
|
||||
let result1 = ns_lock
|
||||
.rlock_batch(&resource, "reader1", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result1, "First read lock should succeed");
|
||||
|
||||
// Second read lock should also succeed (read locks are compatible)
|
||||
let result2 = ns_lock
|
||||
.rlock_batch(&resource, "reader2", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result2, "Second read lock should succeed");
|
||||
|
||||
// Write lock should fail (read locks are held)
|
||||
let result3 = ns_lock
|
||||
.lock_batch(&resource, "writer1", Duration::from_secs(1), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(!result3, "Write lock should fail when read locks are held");
|
||||
|
||||
// Release read locks
|
||||
ns_lock.runlock_batch(&resource, "reader1").await?;
|
||||
ns_lock.runlock_batch(&resource, "reader2").await?;
|
||||
|
||||
// Now write lock should succeed
|
||||
let result4 = ns_lock
|
||||
.lock_batch(&resource, "writer1", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result4, "Write lock should succeed after read locks released");
|
||||
|
||||
// Clean up
|
||||
ns_lock.unlock_batch(&resource, "writer1").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_lock_timeout() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock = NamespaceLock::with_clients("test_timeout".to_string(), clients);
|
||||
let resource = vec!["timeout_resource".to_string()];
|
||||
|
||||
// First lock with short timeout
|
||||
let result1 = ns_lock
|
||||
.lock_batch(&resource, "owner1", Duration::from_secs(2), Duration::from_secs(1))
|
||||
.await?;
|
||||
assert!(result1, "First lock should succeed");
|
||||
|
||||
// Wait for lock to expire
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Second lock should succeed after timeout
|
||||
let result2 = ns_lock
|
||||
.lock_batch(&resource, "owner2", Duration::from_secs(5), Duration::from_secs(1))
|
||||
.await?;
|
||||
assert!(result2, "Lock should succeed after timeout");
|
||||
|
||||
// Clean up
|
||||
ns_lock.unlock_batch(&resource, "owner2").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_batch_lock_operations() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock = NamespaceLock::with_clients("test_batch".to_string(), clients);
|
||||
let resources = vec![
|
||||
"batch_resource1".to_string(),
|
||||
"batch_resource2".to_string(),
|
||||
"batch_resource3".to_string(),
|
||||
];
|
||||
|
||||
// Lock all resources
|
||||
let result = ns_lock
|
||||
.lock_batch(&resources, "batch_owner", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result, "Batch lock should succeed");
|
||||
|
||||
// Try to lock one of the resources with different owner - should fail
|
||||
let single_resource = vec!["batch_resource2".to_string()];
|
||||
let result2 = ns_lock
|
||||
.lock_batch(&single_resource, "other_owner", Duration::from_secs(1), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(!result2, "Lock should fail for already locked resource");
|
||||
|
||||
// Unlock all resources
|
||||
ns_lock.unlock_batch(&resources, "batch_owner").await?;
|
||||
|
||||
// Now should be able to lock single resource
|
||||
let result3 = ns_lock
|
||||
.lock_batch(&single_resource, "other_owner", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result3, "Lock should succeed after batch unlock");
|
||||
|
||||
// Clean up
|
||||
ns_lock.unlock_batch(&single_resource, "other_owner").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_multiple_namespaces() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock1 = NamespaceLock::with_clients("namespace1".to_string(), clients.clone());
|
||||
let ns_lock2 = NamespaceLock::with_clients("namespace2".to_string(), clients);
|
||||
let resource = vec!["shared_resource".to_string()];
|
||||
|
||||
// Lock same resource in different namespaces - both should succeed
|
||||
let result1 = ns_lock1
|
||||
.lock_batch(&resource, "owner1", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result1, "Lock in namespace1 should succeed");
|
||||
|
||||
let result2 = ns_lock2
|
||||
.lock_batch(&resource, "owner2", Duration::from_secs(5), Duration::from_secs(10))
|
||||
.await?;
|
||||
assert!(result2, "Lock in namespace2 should succeed");
|
||||
|
||||
// Clean up
|
||||
ns_lock1.unlock_batch(&resource, "owner1").await?;
|
||||
ns_lock2.unlock_batch(&resource, "owner2").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_rpc_read_lock() -> Result<(), Box<dyn Error>> {
|
||||
let args = LockRequest {
|
||||
lock_id: LockId::new_deterministic("read_resource"),
|
||||
resource: "read_resource".to_string(),
|
||||
lock_type: LockType::Shared,
|
||||
owner: "reader1".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
};
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
|
||||
// First read lock
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str.clone() });
|
||||
let response = client.r_lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not get read lock: {error_info}");
|
||||
// Verify the error is a timeout or quorum failure
|
||||
if let Err(err) = result_b {
|
||||
let err_str = err.to_string().to_lowercase();
|
||||
assert!(
|
||||
err_str.contains("timeout") || err_str.contains("quorum") || err_str.contains("not reached"),
|
||||
"Error should be timeout or quorum related, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// Second read lock with different owner should also succeed
|
||||
let args2 = LockRequest {
|
||||
lock_id: LockId::new_deterministic("read_resource"),
|
||||
resource: "read_resource".to_string(),
|
||||
lock_type: LockType::Shared,
|
||||
owner: "reader2".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
};
|
||||
let args2_str = serde_json::to_string(&args2)?;
|
||||
let request2 = Request::new(GenerallyLockRequest { args: args2_str });
|
||||
let response2 = client.r_lock(request2).await?.into_inner();
|
||||
if let Some(error_info) = response2.error_info {
|
||||
panic!("can not get second read lock: {error_info}");
|
||||
// Test 3: Release owner A's lock
|
||||
assert!(guard_a.release(), "Should release guard_a successfully");
|
||||
assert!(guard_a.is_released(), "Guard A should be marked as released");
|
||||
|
||||
// Test 4: Owner B should now be able to acquire the lock
|
||||
let guard_b = lock
|
||||
.get_write_lock(resource.clone(), "owner-b", Duration::from_secs(5))
|
||||
.await
|
||||
.expect("Owner B should acquire write lock after A releases");
|
||||
|
||||
match &guard_b {
|
||||
rustfs_lock::NamespaceLockGuard::Standard(_) => {
|
||||
// Expected for distributed lock
|
||||
}
|
||||
rustfs_lock::NamespaceLockGuard::Fast(_) => {
|
||||
panic!("Expected Standard guard for distributed lock");
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock both
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str });
|
||||
let response = client.r_un_lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not unlock read lock: {error_info}");
|
||||
}
|
||||
// Test 5: Verify health check shows 4 nodes
|
||||
let health = lock.get_health().await;
|
||||
assert_eq!(health.node_id, "grpc-4-node");
|
||||
assert_eq!(health.total_nodes, 4);
|
||||
assert_eq!(health.connected_nodes, 4);
|
||||
assert_eq!(health.status, rustfs_lock::types::HealthStatus::Healthy);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_lock_refresh() -> Result<(), Box<dyn Error>> {
|
||||
let args = LockRequest {
|
||||
lock_id: LockId::new_deterministic("refresh_resource"),
|
||||
resource: "refresh_resource".to_string(),
|
||||
lock_type: LockType::Exclusive,
|
||||
owner: "refresh_owner".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
};
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
|
||||
// Acquire lock
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str.clone() });
|
||||
let response = client.lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not get lock: {error_info}");
|
||||
}
|
||||
|
||||
// Refresh lock
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str.clone() });
|
||||
let response = client.refresh(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not refresh lock: {error_info}");
|
||||
}
|
||||
assert!(response.success, "Lock refresh should succeed");
|
||||
|
||||
// Unlock
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str });
|
||||
let response = client.un_lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not unlock: {error_info}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_force_unlock() -> Result<(), Box<dyn Error>> {
|
||||
let args = LockRequest {
|
||||
lock_id: LockId::new_deterministic("force_resource"),
|
||||
resource: "force_resource".to_string(),
|
||||
lock_type: LockType::Exclusive,
|
||||
owner: "force_owner".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
};
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
|
||||
// Acquire lock
|
||||
let request = Request::new(GenerallyLockRequest { args: args_str.clone() });
|
||||
let response = client.lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not get lock: {error_info}");
|
||||
}
|
||||
|
||||
// Force unlock (even by different owner)
|
||||
let force_args = LockRequest {
|
||||
lock_id: LockId::new_deterministic("force_resource"),
|
||||
resource: "force_resource".to_string(),
|
||||
lock_type: LockType::Exclusive,
|
||||
owner: "admin".to_string(),
|
||||
acquire_timeout: Duration::from_secs(30),
|
||||
ttl: Duration::from_secs(30),
|
||||
metadata: LockMetadata::default(),
|
||||
priority: LockPriority::Normal,
|
||||
deadlock_detection: false,
|
||||
};
|
||||
let force_args_str = serde_json::to_string(&force_args)?;
|
||||
let request = Request::new(GenerallyLockRequest { args: force_args_str });
|
||||
let response = client.force_un_lock(request).await?.into_inner();
|
||||
if let Some(error_info) = response.error_info {
|
||||
panic!("can not force unlock: {error_info}");
|
||||
}
|
||||
assert!(response.success, "Force unlock should succeed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_global_lock_map_sharing() -> Result<(), Box<dyn Error>> {
|
||||
let endpoints = get_cluster_endpoints();
|
||||
let clients = create_unique_clients(&endpoints).await?;
|
||||
let ns_lock1 = NamespaceLock::with_clients("global_test".to_string(), clients.clone());
|
||||
let ns_lock2 = NamespaceLock::with_clients("global_test".to_string(), clients);
|
||||
|
||||
let resource = vec!["global_test_resource".to_string()];
|
||||
|
||||
// First instance acquires lock
|
||||
println!("First lock map attempting to acquire lock...");
|
||||
let result1 = ns_lock1
|
||||
.lock_batch(&resource, "owner1", std::time::Duration::from_secs(5), std::time::Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("First lock result: {result1}");
|
||||
assert!(result1, "First lock should succeed");
|
||||
|
||||
// Second instance should fail to acquire the same lock
|
||||
println!("Second lock map attempting to acquire lock...");
|
||||
let result2 = ns_lock2
|
||||
.lock_batch(&resource, "owner2", std::time::Duration::from_secs(1), std::time::Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("Second lock result: {result2}");
|
||||
assert!(!result2, "Second lock should fail because resource is already locked");
|
||||
|
||||
// Release lock from first instance
|
||||
println!("First lock map releasing lock...");
|
||||
ns_lock1.unlock_batch(&resource, "owner1").await?;
|
||||
|
||||
// Now second instance should be able to acquire lock
|
||||
println!("Second lock map attempting to acquire lock again...");
|
||||
let result3 = ns_lock2
|
||||
.lock_batch(&resource, "owner2", std::time::Duration::from_secs(5), std::time::Duration::from_secs(10))
|
||||
.await?;
|
||||
println!("Third lock result: {result3}");
|
||||
assert!(result3, "Lock should succeed after first lock is released");
|
||||
|
||||
// Clean up
|
||||
ns_lock2.unlock_batch(&resource, "owner2").await?;
|
||||
|
||||
Ok(())
|
||||
// Cleanup
|
||||
drop(guard_b);
|
||||
|
||||
// Shutdown servers
|
||||
handle1.abort();
|
||||
handle2.abort();
|
||||
handle3.abort();
|
||||
handle4.abort();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
mod conditional_writes;
|
||||
mod get_deleted_object_test;
|
||||
mod grpc_lock_client;
|
||||
mod grpc_lock_server;
|
||||
mod head_deleted_object_versioning_test;
|
||||
mod lifecycle;
|
||||
mod lock;
|
||||
|
||||
@@ -17,11 +17,11 @@ use crate::common::workspace_root;
|
||||
use futures::future::join_all;
|
||||
use rmp_serde::{Deserializer, Serializer};
|
||||
use rustfs_ecstore::disk::{VolumeInfo, WalkDirOptions};
|
||||
use rustfs_ecstore::rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||
use rustfs_filemeta::{MetaCacheEntry, MetacacheReader, MetacacheWriter};
|
||||
use rustfs_protos::proto_gen::node_service::WalkDirRequest;
|
||||
use rustfs_protos::{
|
||||
models::{PingBody, PingBodyBuilder},
|
||||
node_service_time_out_client,
|
||||
proto_gen::node_service::{
|
||||
ListVolumesRequest, LocalStorageInfoRequest, MakeVolumeRequest, PingRequest, PingResponse, ReadAllRequest,
|
||||
},
|
||||
@@ -53,7 +53,9 @@ async fn ping() -> Result<(), Box<dyn Error>> {
|
||||
assert!(decoded_payload.is_ok());
|
||||
|
||||
// Create client
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
|
||||
// Construct PingRequest
|
||||
let request = Request::new(PingRequest {
|
||||
@@ -78,7 +80,9 @@ async fn ping() -> Result<(), Box<dyn Error>> {
|
||||
#[tokio::test]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn make_volume() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
let request = Request::new(MakeVolumeRequest {
|
||||
disk: "data".to_string(),
|
||||
volume: "dandan".to_string(),
|
||||
@@ -96,7 +100,9 @@ async fn make_volume() -> Result<(), Box<dyn Error>> {
|
||||
#[tokio::test]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn list_volumes() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
let request = Request::new(ListVolumesRequest {
|
||||
disk: "data".to_string(),
|
||||
});
|
||||
@@ -126,7 +132,9 @@ async fn walk_dir() -> Result<(), Box<dyn Error>> {
|
||||
let (rd, mut wr) = tokio::io::duplex(1024);
|
||||
let mut buf = Vec::new();
|
||||
opts.serialize(&mut Serializer::new(&mut buf))?;
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
let disk_path = std::env::var_os("RUSTFS_DISK_PATH").map(PathBuf::from).unwrap_or_else(|| {
|
||||
let mut path = workspace_root();
|
||||
path.push("target");
|
||||
@@ -179,7 +187,9 @@ async fn walk_dir() -> Result<(), Box<dyn Error>> {
|
||||
#[tokio::test]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn read_all() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
let request = Request::new(ReadAllRequest {
|
||||
disk: "data".to_string(),
|
||||
volume: "ff".to_string(),
|
||||
@@ -197,7 +207,9 @@ async fn read_all() -> Result<(), Box<dyn Error>> {
|
||||
#[tokio::test]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn storage_info() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = node_service_time_out_client(&CLUSTER_ADDR.to_string()).await?;
|
||||
let mut client =
|
||||
node_service_time_out_client(&CLUSTER_ADDR.to_string(), TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await?;
|
||||
let request = Request::new(LocalStorageInfoRequest { metrics: true });
|
||||
|
||||
let response = client.local_storage_info(request).await?.into_inner();
|
||||
|
||||
@@ -67,6 +67,22 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn suspend_versioning(client: &Client, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let versioning_config = VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Suspended)
|
||||
.build();
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(versioning_config)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
info!("✅ Versioning suspended for bucket {}", bucket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test 1: PutObject should return version_id when versioning is enabled
|
||||
/// This directly addresses the Veeam issue from #1066
|
||||
#[tokio::test]
|
||||
@@ -395,4 +411,91 @@ mod tests {
|
||||
|
||||
info!("✅ PASSED: Veeam backup workflow simulation completed successfully");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
// Use a versioned bucket for this test
|
||||
let bucket = "terraform";
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
let key = "terraform.tfstate";
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(b"v1".to_vec()))
|
||||
.send()
|
||||
.await;
|
||||
assert!(response.is_ok());
|
||||
|
||||
client.delete_object().bucket(bucket).key(key).send().await?;
|
||||
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(b"v1".to_vec()))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(response.is_ok());
|
||||
|
||||
let get_response = client.get_object().bucket(bucket).key(key).send().await;
|
||||
assert!(get_response.is_ok(), "Object should exist after PUT");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test 7: PutObject should return "null" version_id when versioning is Suspended
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_object_returns_null_version_id_with_suspended_versioning() {
|
||||
init_logging();
|
||||
info!("🧪 TEST: PutObject returns null version_id with versioning suspended");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-suspended-version-id";
|
||||
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
suspend_versioning(&client, bucket)
|
||||
.await
|
||||
.expect("Failed to suspend versioning");
|
||||
|
||||
let key = "test-file-suspended.txt";
|
||||
let content = b"Test content for suspended version ID test";
|
||||
|
||||
info!("📤 Uploading object to suspended versioning bucket");
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "PutObject failed: {:?}", result.err());
|
||||
let output = result.unwrap();
|
||||
|
||||
info!("📥 PutObject response - version_id: {:?}", output.version_id);
|
||||
|
||||
// When suspended, version_id must be "null"
|
||||
assert_eq!(
|
||||
output.version_id.as_deref(),
|
||||
Some("null"),
|
||||
"❌ FAILED: version_id should be 'null' when versioning is suspended"
|
||||
);
|
||||
|
||||
info!("✅ PASSED: PutObject correctly returns 'null' version_id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ glob = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
flatbuffers.workspace = true
|
||||
futures.workspace = true
|
||||
futures-util.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
time.workspace = true
|
||||
@@ -60,6 +61,8 @@ serde_json.workspace = true
|
||||
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
|
||||
s3s.workspace = true
|
||||
http.workspace = true
|
||||
http-body = { workspace = true }
|
||||
http-body-util.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
|
||||
reed-solomon-simd = { workspace = true }
|
||||
@@ -109,7 +112,6 @@ google-cloud-auth = { workspace = true }
|
||||
aws-config = { workspace = true }
|
||||
faster-hex = { workspace = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_from_backend};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||
use crate::{
|
||||
disk::endpoint::Endpoint,
|
||||
global::{GLOBAL_BOOT_TIME, GLOBAL_Endpoints},
|
||||
@@ -29,7 +30,6 @@ use rustfs_madmin::{
|
||||
};
|
||||
use rustfs_protos::{
|
||||
models::{PingBody, PingBodyBuilder},
|
||||
node_service_time_out_client,
|
||||
proto_gen::node_service::{PingRequest, PingResponse},
|
||||
};
|
||||
use std::{
|
||||
@@ -101,9 +101,9 @@ async fn is_server_resolvable(endpoint: &Endpoint) -> Result<()> {
|
||||
let decoded_payload = flatbuffers::root::<PingBody>(finished_data);
|
||||
assert!(decoded_payload.is_ok());
|
||||
|
||||
let mut client = node_service_time_out_client(&addr)
|
||||
let mut client = node_service_time_out_client(&addr, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await
|
||||
.map_err(|err| Error::other(err.to_string()))?;
|
||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
|
||||
|
||||
let request = Request::new(PingRequest {
|
||||
version: 1,
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::disk::{self, DiskAPI as _, DiskStore};
|
||||
use crate::disk::{self, DiskAPI as _, DiskStore, error::DiskError};
|
||||
use crate::erasure_coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::metadata::BucketMetadata;
|
||||
use crate::bucket::metadata_sys::get_bucket_targets_config;
|
||||
use crate::bucket::metadata_sys::get_replication_config;
|
||||
use crate::bucket::replication::ObjectOpts;
|
||||
use crate::bucket::replication::ReplicationConfigurationExt;
|
||||
use crate::bucket::target::ARN;
|
||||
use crate::bucket::target::BucketTargetType;
|
||||
use crate::bucket::target::{self, BucketTarget, BucketTargets, Credentials};
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use aws_credential_types::Credentials as SdkCredentials;
|
||||
use aws_sdk_s3::config::Region as SdkRegion;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput;
|
||||
use aws_sdk_s3::operation::head_bucket::HeadBucketError;
|
||||
@@ -52,18 +61,11 @@ use tracing::warn;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bucket::metadata_sys::get_bucket_targets_config;
|
||||
use crate::bucket::metadata_sys::get_replication_config;
|
||||
use crate::bucket::replication::ObjectOpts;
|
||||
use crate::bucket::replication::ReplicationConfigurationExt;
|
||||
use crate::bucket::target::ARN;
|
||||
use crate::bucket::target::BucketTargetType;
|
||||
use crate::bucket::target::{self, BucketTarget, BucketTargets, Credentials};
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
|
||||
const DEFAULT_HEALTH_CHECK_DURATION: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_HEALTH_CHECK_RELOAD_DURATION: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
const REPLICATION_REQUEST_TRUE: HeaderValue = HeaderValue::from_static("true");
|
||||
|
||||
pub static GLOBAL_BUCKET_TARGET_SYS: OnceLock<BucketTargetSys> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -431,13 +433,15 @@ impl BucketTargetSys {
|
||||
let versioning = target_client
|
||||
.get_bucket_versioning(&target.target_bucket)
|
||||
.await
|
||||
.map_err(|_e| BucketTargetError::BucketReplicationSourceNotVersioned {
|
||||
bucket: bucket.to_string(),
|
||||
.map_err(|e| BucketTargetError::RemoteTargetConnectionErr {
|
||||
bucket: target.target_bucket.clone(),
|
||||
access_key: target.credentials.as_ref().map(|c| c.access_key.clone()).unwrap_or_default(),
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
|
||||
if versioning.is_none() {
|
||||
return Err(BucketTargetError::BucketReplicationSourceNotVersioned {
|
||||
bucket: bucket.to_string(),
|
||||
return Err(BucketTargetError::BucketRemoteTargetNotVersioned {
|
||||
bucket: target.target_bucket.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -498,19 +502,19 @@ impl BucketTargetSys {
|
||||
bucket: bucket.to_string(),
|
||||
})?;
|
||||
|
||||
if arn.arn_type == BucketTargetType::ReplicationService {
|
||||
if let Ok((config, _)) = get_replication_config(bucket).await {
|
||||
for rule in config.filter_target_arns(&ObjectOpts {
|
||||
op_type: ReplicationType::All,
|
||||
..Default::default()
|
||||
}) {
|
||||
if rule == arn_str || config.role == arn_str {
|
||||
let arn_remotes_map = self.arn_remotes_map.read().await;
|
||||
if arn_remotes_map.get(arn_str).is_some() {
|
||||
return Err(BucketTargetError::BucketRemoteRemoveDisallowed {
|
||||
bucket: bucket.to_string(),
|
||||
});
|
||||
}
|
||||
if arn.arn_type == BucketTargetType::ReplicationService
|
||||
&& let Ok((config, _)) = get_replication_config(bucket).await
|
||||
{
|
||||
for rule in config.filter_target_arns(&ObjectOpts {
|
||||
op_type: ReplicationType::All,
|
||||
..Default::default()
|
||||
}) {
|
||||
if rule == arn_str || config.role == arn_str {
|
||||
let arn_remotes_map = self.arn_remotes_map.read().await;
|
||||
if arn_remotes_map.get(arn_str).is_some() {
|
||||
return Err(BucketTargetError::BucketRemoteRemoveDisallowed {
|
||||
bucket: bucket.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,22 +695,22 @@ impl BucketTargetSys {
|
||||
}
|
||||
|
||||
// Add new targets
|
||||
if let Some(new_targets) = targets {
|
||||
if !new_targets.is_empty() {
|
||||
for target in &new_targets.targets {
|
||||
if let Ok(client) = self.get_remote_target_client_internal(target).await {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
}
|
||||
if let Some(new_targets) = targets
|
||||
&& !new_targets.is_empty()
|
||||
{
|
||||
for target in &new_targets.targets {
|
||||
if let Ok(client) = self.get_remote_target_client_internal(target).await {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
}
|
||||
targets_map.insert(bucket.to_string(), new_targets.targets.clone());
|
||||
}
|
||||
targets_map.insert(bucket.to_string(), new_targets.targets.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,10 +1001,14 @@ impl PutObjectOptions {
|
||||
if self.internal.source_mtime.unix_timestamp() != 0 {
|
||||
header.insert(
|
||||
RUSTFS_BUCKET_SOURCE_MTIME,
|
||||
HeaderValue::from_str(&self.internal.source_mtime.unix_timestamp().to_string()).expect("err"),
|
||||
HeaderValue::from_str(&self.internal.source_mtime.format(&Rfc3339).unwrap_or_default()).expect("err"),
|
||||
);
|
||||
}
|
||||
|
||||
if self.internal.replication_request {
|
||||
header.insert(RUSTFS_BUCKET_REPLICATION_REQUEST, REPLICATION_REQUEST_TRUE);
|
||||
}
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
@@ -1095,8 +1103,7 @@ pub struct TargetClient {
|
||||
|
||||
impl TargetClient {
|
||||
pub fn to_url(&self) -> Url {
|
||||
let scheme = if self.secure { "https" } else { "http" };
|
||||
Url::parse(&format!("{scheme}://{}", self.endpoint)).unwrap()
|
||||
Url::parse(&self.endpoint).unwrap()
|
||||
}
|
||||
|
||||
pub async fn bucket_exists(&self, bucket: &str) -> Result<bool, S3ClientError> {
|
||||
@@ -1105,9 +1112,17 @@ impl TargetClient {
|
||||
Err(e) => match e {
|
||||
SdkError::ServiceError(oe) => match oe.into_err() {
|
||||
HeadBucketError::NotFound(_) => Ok(false),
|
||||
other => Err(S3ClientError::new(format!(
|
||||
"failed to check bucket exists for bucket:{bucket} please check the bucket name and credentials, error:{other:?}"
|
||||
))),
|
||||
other => {
|
||||
warn!(
|
||||
"failed to check bucket exists for bucket:{bucket} please check the bucket name and credentials, error:{:?}",
|
||||
other
|
||||
);
|
||||
let message = other.meta().meta();
|
||||
Err(S3ClientError::new(format!(
|
||||
"failed to check bucket exists for bucket:{bucket} please check the bucket name and credentials, error:{:?}",
|
||||
message
|
||||
)))
|
||||
}
|
||||
},
|
||||
SdkError::DispatchFailure(e) => Err(S3ClientError::new(format!(
|
||||
"failed to dispatch bucket exists for bucket:{bucket} error:{e:?}"
|
||||
@@ -1155,10 +1170,17 @@ impl TargetClient {
|
||||
body: ByteStream,
|
||||
opts: &PutObjectOptions,
|
||||
) -> Result<(), S3ClientError> {
|
||||
let headers = opts.header();
|
||||
let mut headers = opts.header();
|
||||
|
||||
let builder = self.client.put_object();
|
||||
|
||||
let version_id = opts.internal.source_version_id.clone();
|
||||
if !version_id.is_empty()
|
||||
&& let Ok(header_value) = HeaderValue::from_str(&version_id)
|
||||
{
|
||||
headers.insert(RUSTFS_BUCKET_SOURCE_VERSION_ID, header_value);
|
||||
}
|
||||
|
||||
match builder
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
@@ -1186,9 +1208,36 @@ impl TargetClient {
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
_opts: &PutObjectOptions,
|
||||
opts: &PutObjectOptions,
|
||||
) -> Result<String, S3ClientError> {
|
||||
match self.client.create_multipart_upload().bucket(bucket).key(object).send().await {
|
||||
let mut headers = HeaderMap::new();
|
||||
let version_id = opts.internal.source_version_id.clone();
|
||||
if !version_id.is_empty()
|
||||
&& let Ok(header_value) = HeaderValue::from_str(&version_id)
|
||||
{
|
||||
headers.insert(RUSTFS_BUCKET_SOURCE_VERSION_ID, header_value);
|
||||
}
|
||||
if opts.internal.replication_request {
|
||||
headers.insert(RUSTFS_BUCKET_REPLICATION_REQUEST, REPLICATION_REQUEST_TRUE);
|
||||
}
|
||||
|
||||
match self
|
||||
.client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
.customize()
|
||||
.map_request(move |mut req| {
|
||||
for (k, v) in headers.clone().into_iter() {
|
||||
let key_str = k.unwrap().as_str().to_string();
|
||||
let value_str = v.to_str().unwrap_or("").to_string();
|
||||
req.headers_mut().insert(key_str, value_str);
|
||||
}
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => Ok(res.upload_id.unwrap_or_default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user