diff --git a/.agents/skills/security-advisory-lessons/references/advisory-patterns.md b/.agents/skills/security-advisory-lessons/references/advisory-patterns.md
index 954e0d0ef..7380499ca 100644
--- a/.agents/skills/security-advisory-lessons/references/advisory-patterns.md
+++ b/.agents/skills/security-advisory-lessons/references/advisory-patterns.md
@@ -48,6 +48,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### S3 object actions, copy, multipart, and upload policy validation
+- `GHSA-g8w9-qw9q-fghr`: a valid presigned `PutObject` accepted extra `x-amz-tagging`, website redirect, and storage-class headers omitted from `SignedHeaders`. Lesson: a presigned URL is a bounded capability; reject `x-amz-*` headers that are not cryptographically bound by the signature so unsigned metadata cannot change authorization, lifecycle, redirect, cost, or durability semantics.
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
@@ -119,7 +120,7 @@ Use these targeted searches when a diff touches security-sensitive code:
```bash
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
-rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
+rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|content-length-range|starts-with" rustfs crates
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
@@ -136,6 +137,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
+- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, and storage-class headers; require rejection before storage access, and verify explicitly signed equivalents still work.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
diff --git a/.config/e2e-nightly-selection.txt b/.config/e2e-nightly-selection.txt
index 1c8b22d22..a3a2c14ad 100644
--- a/.config/e2e-nightly-selection.txt
+++ b/.config/e2e-nightly-selection.txt
@@ -1 +1 @@
-sha256=071be531eef021e9b772837d47bb32b0aa2c146c68baf055ce6e7f2cc3fce4c1
+sha256=9c2b958035a038ffd5ab98cac5f59a1b8e6a16e141f109ec7fb956afc0f11105
diff --git a/.github/workflows/rustfs-heal-test.yml b/.github/workflows/rustfs-heal-test.yml
index d890335e5..3e366481b 100644
--- a/.github/workflows/rustfs-heal-test.yml
+++ b/.github/workflows/rustfs-heal-test.yml
@@ -70,11 +70,24 @@ jobs:
warp --version || true
df -h /data | tail -1
- - name: Reset test environment (before)
+ - name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
- chmod +x auto-testing/rustfs_heal_test.sh
- ./auto-testing/rustfs_heal_test.sh --reset -y
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
- name: Install RustFS package & start cluster
run: |
@@ -115,10 +128,24 @@ jobs:
/tmp/rustfs-warp.*.log
if-no-files-found: warn
- - name: Reset test environment (after)
+ - name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
- ./auto-testing/rustfs_heal_test.sh --reset -y
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
- name: Notify on failure
if: failure()
diff --git a/.github/workflows/rustfs-kms-test.yml b/.github/workflows/rustfs-kms-test.yml
index 07f80d520..0cc3dd16a 100644
--- a/.github/workflows/rustfs-kms-test.yml
+++ b/.github/workflows/rustfs-kms-test.yml
@@ -12,7 +12,7 @@ on:
required: false
type: string
workflow_run:
- # Strict shared-environment order: run after S3 compatibility test succeeds.
+ # Strict shared-environment order: run after S3 compatibility test completes.
workflows: ["RustFS S3 Compatibility Test"]
types: [completed]
@@ -38,8 +38,9 @@ env:
jobs:
kms-test:
runs-on: smoke-testing
+ continue-on-error: true
timeout-minutes: 420
- if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -86,6 +87,7 @@ jobs:
- name: Run KMS suite
id: test
+ continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-kms.log
run: |
@@ -119,12 +121,65 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
+ CASE_TABLE="/tmp/rustfs-kms-cases.md"
+ python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
+ import re
+ import sys
+
+ log_file, out_file = sys.argv[1], sys.argv[2]
+ ansi = re.compile(r'\x1b\[[0-9;]*m')
+ start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
+ done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
+
+ rows = []
+ index = {}
+ try:
+ with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
+ for raw in fh:
+ line = ansi.sub('', raw).strip()
+ m = start_re.match(line)
+ if m:
+ case_id, name = m.group(1), m.group(2)
+ if case_id not in index:
+ index[case_id] = len(rows)
+ rows.append([case_id, name, 'RUNNING'])
+ continue
+ m = done_re.match(line)
+ if m:
+ status, case_id = m.group(1), m.group(2)
+ if case_id in index:
+ rows[index[case_id]][2] = status
+ else:
+ rows.append([case_id, case_id, status])
+ index[case_id] = len(rows) - 1
+ except FileNotFoundError:
+ rows = []
+
+ counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
+ for _, _, status in rows:
+ counts[status] = counts.get(status, 0) + 1
+
+ with open(out_file, 'w', encoding='utf-8') as out:
+ out.write('## Case Summary\n\n')
+ out.write(f"- Total: {len(rows)}\\n")
+ out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
+ out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
+ out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
+ out.write('\\n')
+ out.write('| Case | Name | Status |\\n')
+ out.write('| --- | --- | --- |\\n')
+ for case_id, name, status in rows:
+ out.write(f'| {case_id} | {name} | {status} |\\n')
+ PY
{
echo "# RustFS KMS test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
+ echo "- Test Step Outcome: ${{ steps.test.outcome }}"
+ echo ""
+ cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
@@ -133,6 +188,129 @@ jobs:
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
+ - name: Upload functional report to dashboard
+ if: always()
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
+ REPORT_FILE: /tmp/rustfs-kms-report.md
+ SUITE: kms
+ run: |
+ set -euo pipefail
+ if [ -z "${GH_TOKEN:-}" ]; then
+ echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
+ exit 0
+ fi
+ DATE="$(date -u +%Y-%m-%d)"
+ REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
+ CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
+ SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${SHA}" ]; then
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ fi
+
+ cat > /tmp/rustfs-functional-index.html <<'EOF'
+
+
+
+
+
+ RustFS Functional Test Reports
+
+
+
+
+
+
RustFS Functional Test Reports
+
S3, KMS, Tier report tabs. Each tab lists reports by date.
+
+
+
+
+
+
+
+ EOF
+
+ INDEX_PATH="functional/index.html"
+ INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
+ INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${INDEX_SHA}" ]; then
+ jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ fi
+
- name: Upload report and logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
diff --git a/.github/workflows/rustfs-pool-expand-test.yml b/.github/workflows/rustfs-pool-expand-test.yml
index 7dedb7787..31db6579a 100644
--- a/.github/workflows/rustfs-pool-expand-test.yml
+++ b/.github/workflows/rustfs-pool-expand-test.yml
@@ -46,7 +46,7 @@ on:
type: boolean
default: true
workflow_run:
- # Strict shared-environment order: run after tier test succeeds.
+ # Strict shared-environment order: run after tier test completes.
workflows: ["RustFS Tier Test"]
types: [completed]
@@ -75,11 +75,124 @@ env:
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
+ heal-test:
+ name: Heal test
+ runs-on: smoke-testing
+ timeout-minutes: 480
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
+ steps:
+ - name: Checkout auto-testing scripts
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ repository: rustfs/auto-testing
+ ref: main
+ path: auto-testing
+ persist-credentials: false
+ token: ${{ secrets.PF_TESTING_GH_TOKEN }}
+
+ - name: Show environment
+ run: |
+ uname -a
+ jq --version
+ openssl version
+ df -h /data | tail -1
+
+ - name: Cleanup environment (before)
+ if: ${{ inputs.cleanup_before != 'false' }}
+ run: |
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
+
+ - name: Install RustFS package & start cluster
+ run: |
+ ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
+ if [ -n "${{ inputs.package_url }}" ]; then
+ ARGS+=(--package-url "${{ inputs.package_url }}")
+ else
+ ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
+ fi
+ ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
+
+ - name: Preflight checks
+ run: |
+ ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
+ if [ -n "${{ inputs.package_url }}" ]; then
+ ARGS+=(--package-url "${{ inputs.package_url }}")
+ else
+ ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
+ fi
+ ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
+
+ - name: Run heal test (write -> outage -> heal -> verify)
+ run: |
+ ARGS=(--steps "3,4,5,6,7" -y \
+ --endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
+ --stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
+ --warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
+ --log-file /tmp/rustfs-heal-test.log)
+ if [ -n "${{ inputs.package_url }}" ]; then
+ ARGS+=(--package-url "${{ inputs.package_url }}")
+ else
+ ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
+ fi
+ ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
+
+ - name: Upload test logs
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: rustfs-heal-test-${{ github.run_id }}
+ path: |
+ /tmp/rustfs-heal-test.log
+ /tmp/rustfs-warp.*.log
+ if-no-files-found: warn
+
+ - name: Cleanup environment (after)
+ if: ${{ always() && inputs.cleanup_after != 'false' }}
+ run: |
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
+
+ - name: Notify on failure
+ if: failure()
+ run: |
+ echo "RustFS heal test failed"
+ echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
+ echo "See the uploaded log artifact for details."
+
+ # Pool expansion runs after heal regardless of heal outcome.
pool-expansion-test:
name: Pool expansion / decommission test
runs-on: smoke-testing
timeout-minutes: 360
- if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+ needs: heal-test
+ if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run') }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -98,13 +211,26 @@ jobs:
warp --version || true
df -h /data | tail -1
- - name: Reset test environment (before)
+ - name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
- chmod +x auto-testing/rustfs_pool_expand.sh
- ./auto-testing/rustfs_pool_expand.sh --reset -y
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
- - name: Install RustFS package & start first pool
+ - name: Install RustFS package & start cluster
run: |
ARGS=(--steps "1,2,3" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
if [ -n "${{ inputs.package_url }}" ]; then
@@ -163,10 +289,24 @@ jobs:
/tmp/rustfs-warp.*.log
if-no-files-found: warn
- - name: Reset test environment (after)
+ - name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
- ./auto-testing/rustfs_pool_expand.sh --reset -y
+ set -euo pipefail
+ read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
+ SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
+ for node in "${NODES[@]}"; do
+ ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
+ set -euo pipefail
+ SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
+ ${SUDO} systemctl stop rustfs 2>/dev/null || true
+ if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
+ ${SUDO} dpkg -P rustfs
+ fi
+ for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
+ ${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
+ '
+ done
- name: Notify on failure
if: failure()
@@ -174,82 +314,3 @@ jobs:
echo "RustFS pool expansion test failed"
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
echo "See the uploaded log artifact for details."
-
- # Heal regression runs after the pool test regardless of its outcome: a pool
- # failure must be reported (it makes the run red) but must not block heal.
- heal-test:
- name: Heal test (after pool test)
- runs-on: smoke-testing
- timeout-minutes: 480
- needs: pool-expansion-test
- if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
- steps:
- - name: Checkout auto-testing scripts
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- with:
- repository: rustfs/auto-testing
- ref: main
- path: auto-testing
- persist-credentials: false
- token: ${{ secrets.PF_TESTING_GH_TOKEN }}
-
- - name: Reset test environment (before)
- if: ${{ inputs.cleanup_before != 'false' }}
- run: |
- chmod +x auto-testing/rustfs_heal_test.sh
- ./auto-testing/rustfs_heal_test.sh --reset -y
-
- - name: Install RustFS package & start cluster
- run: |
- ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
- if [ -n "${{ inputs.package_url }}" ]; then
- ARGS+=(--package-url "${{ inputs.package_url }}")
- else
- ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
- fi
- ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
-
- - name: Preflight checks
- run: |
- ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
- if [ -n "${{ inputs.package_url }}" ]; then
- ARGS+=(--package-url "${{ inputs.package_url }}")
- else
- ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
- fi
- ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
-
- - name: Run heal test (write -> outage -> heal -> verify)
- run: |
- ARGS=(--steps "3,4,5,6,7" -y \
- --endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
- --stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
- --warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
- --log-file /tmp/rustfs-heal-test.log)
- if [ -n "${{ inputs.package_url }}" ]; then
- ARGS+=(--package-url "${{ inputs.package_url }}")
- else
- ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
- fi
- ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
-
- - name: Upload test logs
- if: always()
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- with:
- name: rustfs-heal-test-${{ github.run_id }}
- path: |
- /tmp/rustfs-heal-test.log
- /tmp/rustfs-warp.*.log
- if-no-files-found: warn
-
- - name: Reset test environment (after)
- if: ${{ always() && inputs.cleanup_after != 'false' }}
- run: |
- ./auto-testing/rustfs_heal_test.sh --reset -y
-
- - name: Notify on failure
- if: failure()
- run: |
- echo "RustFS heal test failed"
- echo "See the uploaded log artifact for details."
diff --git a/.github/workflows/rustfs-s3-compat-test.yml b/.github/workflows/rustfs-s3-compat-test.yml
index 2efe2986b..fffd0621f 100644
--- a/.github/workflows/rustfs-s3-compat-test.yml
+++ b/.github/workflows/rustfs-s3-compat-test.yml
@@ -33,10 +33,12 @@ env:
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
+ PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
jobs:
s3-compat-test:
runs-on: smoke-testing
+ continue-on-error: true
timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
@@ -76,6 +78,7 @@ jobs:
- name: Run S3 compatibility suite
id: test
+ continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-s3-compat.log
run: |
@@ -109,12 +112,68 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
+ CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
+ python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
+ import re
+ import sys
+
+ log_file, out_file = sys.argv[1], sys.argv[2]
+ ansi = re.compile(r'\x1b\[[0-9;]*m')
+ start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
+ done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
+
+ rows = []
+ index = {}
+ current = None
+ try:
+ with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
+ for raw in fh:
+ line = ansi.sub('', raw).strip()
+ m = start_re.match(line)
+ if m:
+ case_id, name = m.group(1), m.group(2)
+ current = case_id
+ if case_id not in index:
+ index[case_id] = len(rows)
+ rows.append([case_id, name, 'RUNNING'])
+ continue
+ m = done_re.match(line)
+ if m:
+ status, case_id = m.group(1), m.group(2)
+ if case_id in index:
+ rows[index[case_id]][2] = status
+ else:
+ rows.append([case_id, case_id, status])
+ index[case_id] = len(rows) - 1
+ current = None
+ except FileNotFoundError:
+ rows = []
+
+ counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
+ for _, _, status in rows:
+ counts[status] = counts.get(status, 0) + 1
+
+ with open(out_file, 'w', encoding='utf-8') as out:
+ out.write('## Case Summary\n\n')
+ out.write(f"- Total: {len(rows)}\\n")
+ out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
+ out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
+ out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
+ out.write('\\n')
+ out.write('| Case | Name | Status |\\n')
+ out.write('| --- | --- | --- |\\n')
+ for case_id, name, status in rows:
+ out.write(f'| {case_id} | {name} | {status} |\\n')
+ PY
{
echo "# RustFS S3 compatibility test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
+ echo "- Test Step Outcome: ${{ steps.test.outcome }}"
+ echo ""
+ cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
@@ -123,6 +182,129 @@ jobs:
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
+ - name: Upload functional report to dashboard
+ if: always()
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
+ REPORT_FILE: /tmp/rustfs-s3-compat-report.md
+ SUITE: s3
+ run: |
+ set -euo pipefail
+ if [ -z "${GH_TOKEN:-}" ]; then
+ echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
+ exit 0
+ fi
+ DATE="$(date -u +%Y-%m-%d)"
+ REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
+ CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
+ SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${SHA}" ]; then
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ fi
+
+ cat > /tmp/rustfs-functional-index.html <<'EOF'
+
+
+
+
+
+ RustFS Functional Test Reports
+
+
+
+
+
+
RustFS Functional Test Reports
+
S3, KMS, Tier report tabs. Each tab lists reports by date.
+
+
+
+
+
+
+
+ EOF
+
+ INDEX_PATH="functional/index.html"
+ INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
+ INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${INDEX_SHA}" ]; then
+ jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ fi
+
- name: Upload report and logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
diff --git a/.github/workflows/rustfs-tier-test.yml b/.github/workflows/rustfs-tier-test.yml
index 2fda50b70..765e0d867 100644
--- a/.github/workflows/rustfs-tier-test.yml
+++ b/.github/workflows/rustfs-tier-test.yml
@@ -12,7 +12,7 @@ on:
required: false
type: string
workflow_run:
- # Strict shared-environment order: run after KMS test succeeds.
+ # Strict shared-environment order: run after KMS test completes.
workflows: ["RustFS KMS Test"]
types: [completed]
@@ -38,8 +38,9 @@ env:
jobs:
tier-test:
runs-on: smoke-testing
+ continue-on-error: true
timeout-minutes: 420
- if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -107,6 +108,7 @@ jobs:
- name: Run tier suite
id: test
+ continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-tier.log
run: |
@@ -140,12 +142,65 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
+ CASE_TABLE="/tmp/rustfs-tier-cases.md"
+ python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
+ import re
+ import sys
+
+ log_file, out_file = sys.argv[1], sys.argv[2]
+ ansi = re.compile(r'\x1b\[[0-9;]*m')
+ start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
+ done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
+
+ rows = []
+ index = {}
+ try:
+ with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
+ for raw in fh:
+ line = ansi.sub('', raw).strip()
+ m = start_re.match(line)
+ if m:
+ case_id, name = m.group(1), m.group(2)
+ if case_id not in index:
+ index[case_id] = len(rows)
+ rows.append([case_id, name, 'RUNNING'])
+ continue
+ m = done_re.match(line)
+ if m:
+ status, case_id = m.group(1), m.group(2)
+ if case_id in index:
+ rows[index[case_id]][2] = status
+ else:
+ rows.append([case_id, case_id, status])
+ index[case_id] = len(rows) - 1
+ except FileNotFoundError:
+ rows = []
+
+ counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
+ for _, _, status in rows:
+ counts[status] = counts.get(status, 0) + 1
+
+ with open(out_file, 'w', encoding='utf-8') as out:
+ out.write('## Case Summary\n\n')
+ out.write(f"- Total: {len(rows)}\\n")
+ out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
+ out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
+ out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
+ out.write('\\n')
+ out.write('| Case | Name | Status |\\n')
+ out.write('| --- | --- | --- |\\n')
+ for case_id, name, status in rows:
+ out.write(f'| {case_id} | {name} | {status} |\\n')
+ PY
{
echo "# RustFS tier test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
+ echo "- Test Step Outcome: ${{ steps.test.outcome }}"
+ echo ""
+ cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
@@ -154,6 +209,129 @@ jobs:
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
+ - name: Upload functional report to dashboard
+ if: always()
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
+ REPORT_FILE: /tmp/rustfs-tier-report.md
+ SUITE: tier
+ run: |
+ set -euo pipefail
+ if [ -z "${GH_TOKEN:-}" ]; then
+ echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
+ exit 0
+ fi
+ DATE="$(date -u +%Y-%m-%d)"
+ REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
+ CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
+ SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${SHA}" ]; then
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
+ fi
+
+ cat > /tmp/rustfs-functional-index.html <<'EOF'
+
+
+
+
+
+ RustFS Functional Test Reports
+
+
+
+
+
+
RustFS Functional Test Reports
+
S3, KMS, Tier report tabs. Each tab lists reports by date.
+
+
+
+
+
+
+
+ EOF
+
+ INDEX_PATH="functional/index.html"
+ INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
+ INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
+ if [ -n "${INDEX_SHA}" ]; then
+ jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
+ '{message:$msg, content:$content, sha:$sha}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ else
+ jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
+ '{message:$msg, content:$content}' \
+ | gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
+ fi
+
- name: Upload report and logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
diff --git a/Cargo.lock b/Cargo.lock
index 1f6087f27..1375bf3b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3926,6 +3926,7 @@ dependencies = [
"aws-sdk-s3",
"aws-sdk-sts",
"aws-smithy-http-client",
+ "aws-smithy-types",
"base64-simd",
"bytes",
"chrono",
@@ -10557,10 +10558,14 @@ dependencies = [
name = "rustfs-s3select-api"
version = "1.0.0-rc.4"
dependencies = [
+ "arc-swap",
+ "async-compression",
"async-trait",
"bytes",
"chrono",
+ "crc-fast",
"datafusion",
+ "flate2",
"futures",
"futures-core",
"hotpath",
@@ -10576,6 +10581,7 @@ dependencies = [
"serial_test",
"thiserror 2.0.20",
"tokio",
+ "tokio-stream",
"tokio-util",
"tracing",
"transform-stream",
diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml
index 75a539561..0a0a70872 100644
--- a/crates/e2e_test/Cargo.toml
+++ b/crates/e2e_test/Cargo.toml
@@ -100,6 +100,7 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-config = { workspace = true }
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
+aws-smithy-types.workspace = true
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
async-trait = { workspace = true }
flate2.workspace = true
diff --git a/crates/e2e_test/src/cluster_multidrive_pool_test.rs b/crates/e2e_test/src/cluster_multidrive_pool_test.rs
index 3f88e5af4..411336754 100644
--- a/crates/e2e_test/src/cluster_multidrive_pool_test.rs
+++ b/crates/e2e_test/src/cluster_multidrive_pool_test.rs
@@ -27,8 +27,10 @@
//! Readiness is established by the harness's `start()` handshake (TCP reachability
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
//!
-//! Out of scope for this block (tracked separately): network fault injection
-//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
+//! The volume-proxy smoke below also proves that the socket-level fault proxy
+//! can be installed before startup without changing the client-facing node URL.
+//! A full lock-plane partition matrix and 5GiB large-object budget remain
+//! tracked separately.
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
@@ -76,6 +78,28 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
Ok(())
}
+/// 4 nodes x 4 drives, single pool: exercise the maximum local erasure layout
+/// supported by the cluster harness. This remains in the nightly lane because
+/// it starts four real server processes and sixteen data directories.
+#[tokio::test]
+async fn cluster_four_node_four_drive_single_pool_smoke() -> TestResult {
+ crate::common::init_logging();
+
+ let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
+
+ let volumes = cluster.rustfs_volumes_arg();
+ assert_eq!(volumes.split(' ').count(), 16, "expected 16 explicit endpoints, got: {volumes}");
+ assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
+ assert!(cluster.nodes.iter().all(|node| node.data_dirs.len() == 4));
+
+ cluster.start().await?;
+ cluster.create_test_bucket(BUCKET).await?;
+
+ let payload = vec![0x3Cu8; 1024 * 1024];
+ put_get_roundtrip(&cluster, "multidrive-4/object", &payload).await?;
+ Ok(())
+}
+
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
#[tokio::test]
@@ -103,3 +127,27 @@ async fn cluster_two_pool_smoke() -> TestResult {
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
Ok(())
}
+
+/// A real cluster smoke for the volume FaultProxy wiring. The proxy target is
+/// not listening yet when it is created; cluster startup must still converge
+/// once the target node starts, and peer disk/RPC traffic must traverse it.
+#[tokio::test]
+async fn cluster_volume_fault_proxy_pass_smoke() -> TestResult {
+ crate::common::init_logging();
+
+ let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)).await?;
+ let proxy = cluster.start_volume_proxy_for_node(0).await?;
+ let proxied = proxy.local_addr().to_string();
+ assert!(cluster.rustfs_volumes_arg().contains(&proxied));
+
+ let result: TestResult = async {
+ cluster.start().await?;
+ cluster.create_test_bucket(BUCKET).await?;
+ let payload = vec![0x6Du8; 256 * 1024];
+ put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await
+ }
+ .await;
+
+ proxy.shutdown().await;
+ result
+}
diff --git a/crates/e2e_test/src/common.rs b/crates/e2e_test/src/common.rs
index 79a6f6864..cb942830a 100644
--- a/crates/e2e_test/src/common.rs
+++ b/crates/e2e_test/src/common.rs
@@ -34,6 +34,7 @@ use serde_json;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::io::ErrorKind;
+use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
@@ -1214,6 +1215,9 @@ pub struct RustFSTestClusterEnvironment {
pub node_extra_env: Vec>,
pub node_capture_log_paths: Vec