mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 12:35:54 +00:00
Merge branch 'main' into houseme/health-readiness-contract-followup
This commit is contained in:
@@ -34,6 +34,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
|
./scripts/test_fuzz_runner.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
python3 ./scripts/check_security_coverage.py --self-test
|
python3 ./scripts/check_security_coverage.py --self-test
|
||||||
|
|||||||
+29
-42
@@ -181,22 +181,14 @@ jobs:
|
|||||||
needs: [ quick-checks ]
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
# Both lines are required. Job-level `permissions` replaces the workflow
|
|
||||||
# block rather than merging with it, so declaring only `actions: write`
|
|
||||||
# would drop `contents: read` and break this job's checkout and the
|
|
||||||
# repo-token the setup action hands to setup-protoc.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
# This job's token can cancel runs and delete Actions caches. Checkout
|
# Checkout otherwise writes the token into .git/config, where a PR's
|
||||||
# otherwise writes it into .git/config, where a PR's own build.rs or
|
# own build.rs or proc-macro could read it back out.
|
||||||
# proc-macro could read it back out.
|
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -347,41 +339,36 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Early stop. Once this job has failed the PR cannot merge, so the sibling
|
# Record the reason before this job completes as FAILURE. A separate
|
||||||
# lanes are burning runners on a result nobody can act on: on run
|
# dependent job cancels sibling lanes only after GitHub has preserved this
|
||||||
# 30674613104 three lanes had already failed while Test and Lint and the
|
# required check's failure verdict.
|
||||||
# rio-v2 variant kept going past 70 minutes.
|
|
||||||
#
|
|
||||||
# Only this job may cancel. The lanes that are NOT required checks
|
|
||||||
# (protocols, ILM, e2e, s3-tests) must never hold that power: a flake in
|
|
||||||
# one of them would turn the required "Test and Lint" into `cancelled`,
|
|
||||||
# which blocks the merge. Today a maintainer can merge with sftp red, and
|
|
||||||
# that has to stay true.
|
|
||||||
#
|
|
||||||
# These steps run last so the `if: always()` artifact upload above still
|
|
||||||
# captures logs and diagnostics before the run goes away.
|
|
||||||
- name: Annotate early-stop reason
|
- name: Annotate early-stop reason
|
||||||
if: failure() && github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; cancelling run ${GITHUB_RUN_ID} to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by this job, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# curl rather than `gh`: every existing `gh` call in this repo runs on
|
|
||||||
# ubuntu-latest, and the sm-standard-* images are custom and trimmed (they
|
|
||||||
# ship no C toolchain, see the e2e job below), so `gh` is not known to
|
|
||||||
# exist here.
|
|
||||||
#
|
|
||||||
# Fork PRs are excluded explicitly instead of relying on the error path:
|
|
||||||
# their GITHUB_TOKEN is forced read-only and job-level permissions cannot
|
|
||||||
# raise it, so the call would always 403. Skipping keeps their logs clean.
|
|
||||||
- name: Cancel run on failure (same-repo PR only)
|
|
||||||
if: >-
|
if: >-
|
||||||
failure() && github.event_name == 'pull_request'
|
failure() && github.event_name == 'pull_request'
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||||
continue-on-error: true
|
run: |
|
||||||
|
{
|
||||||
|
echo "## CI early-stop"
|
||||||
|
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
||||||
|
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
||||||
|
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
||||||
|
# CANCELLED and hid the actionable failure in the PR checks UI.
|
||||||
|
cancel-after-test-and-lint-failure:
|
||||||
|
name: Cancel siblings after Test and Lint failure
|
||||||
|
if: >-
|
||||||
|
failure() && needs.test-and-lint.result == 'failure'
|
||||||
|
&& github.event_name == 'pull_request'
|
||||||
|
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
needs: [ test-and-lint ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
- name: Cancel remaining jobs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -389,7 +376,7 @@ jobs:
|
|||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel" || true
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
name: Upgrade Compatibility
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/lib.rs"
|
||||||
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
|
- "crates/ecstore/**"
|
||||||
|
- "crates/filemeta/**"
|
||||||
|
- "crates/kms/**"
|
||||||
|
- "crates/storage-api/**"
|
||||||
|
- "rustfs/**"
|
||||||
|
- "Cargo.lock"
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "[0-9]*.[0-9]*.[0-9]*"
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
direct-upgrade:
|
||||||
|
name: Direct upgrade from rc.2
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: e2e-direct-upgrade
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: "false"
|
||||||
|
|
||||||
|
- name: Download pinned previous release
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$SOURCE_DIR"
|
||||||
|
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||||
|
curl --fail --location --retry 3 --output "$archive" \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||||
|
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||||
|
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||||
|
chmod +x "$SOURCE_DIR/rustfs"
|
||||||
|
test -x "$SOURCE_DIR/rustfs"
|
||||||
|
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||||
|
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build current RustFS binary
|
||||||
|
run: |
|
||||||
|
cargo build --locked -p rustfs --bin rustfs
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
|
- name: Run direct-upgrade compatibility test
|
||||||
|
run: |
|
||||||
|
cargo test --locked -p e2e_test \
|
||||||
|
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
|
||||||
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
- name: Upload server logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: direct-upgrade-server-logs-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
@@ -173,7 +173,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
fuzz/artifacts/**
|
fuzz/artifacts/**
|
||||||
fuzz/corpus/${{ matrix.target }}/**
|
fuzz/corpus/${{ matrix.target }}/**
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -227,7 +227,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
fuzz/artifacts/**
|
fuzz/artifacts/**
|
||||||
fuzz/corpus/${{ matrix.target }}/**
|
fuzz/corpus/${{ matrix.target }}/**
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
name: OIDC Keycloak Live
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/oidc-keycloak.yml"
|
||||||
|
- "crates/config/src/constants/oidc.rs"
|
||||||
|
- "crates/iam/src/federation/**"
|
||||||
|
- "crates/iam/src/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/sts.rs"
|
||||||
|
- "scripts/test/oidc_keycloak_live.sh"
|
||||||
|
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/oidc-keycloak.yml"
|
||||||
|
- "crates/config/src/constants/oidc.rs"
|
||||||
|
- "crates/iam/src/federation/**"
|
||||||
|
- "crates/iam/src/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/sts.rs"
|
||||||
|
- "scripts/test/oidc_keycloak_live.sh"
|
||||||
|
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||||
|
schedule:
|
||||||
|
- cron: "23 2 * * 1"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: oidc-keycloak-live-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
oidc-keycloak-live:
|
||||||
|
name: OIDC Keycloak live gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: oidc-keycloak-live
|
||||||
|
cache-save-if: "true"
|
||||||
|
install-build-packaging-tools: "false"
|
||||||
|
install-test-tools: "false"
|
||||||
|
|
||||||
|
- name: Build RustFS
|
||||||
|
run: cargo build --locked -p rustfs --bin rustfs
|
||||||
|
|
||||||
|
- name: Install pinned request signer
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||||
|
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
|
- name: Run live Keycloak discovery, JWT and STS checks
|
||||||
|
run: bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs
|
||||||
|
|
||||||
|
- name: Upload service logs
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: oidc-keycloak-live-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-keycloak-live-*/**/*.log
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: oidc-keycloak-live
|
||||||
|
if: >-
|
||||||
|
always() && github.event_name == 'schedule' &&
|
||||||
|
(needs.oidc-keycloak-live.result == 'failure' || needs.oidc-keycloak-live.result == 'cancelled')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Open or update failure-tracking issue
|
||||||
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -27,6 +27,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ the same profile for membership and execution with one nightly worker.
|
|||||||
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
||||||
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||||
|
| Direct upgrade from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
||||||
RequestPaymentConfiguration, WebsiteConfiguration,
|
RequestPaymentConfiguration, WebsiteConfiguration,
|
||||||
@@ -26,6 +28,26 @@ mod tests {
|
|||||||
use http::header::CONTENT_TYPE;
|
use http::header::CONTENT_TYPE;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
fn assert_s3_error<T, E, R>(result: Result<T, R>, expected_status: u16, expected_code: &str, context: &str)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug,
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let error = result.expect_err(context);
|
||||||
|
let sdk_error = error.borrow();
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(expected_status),
|
||||||
|
"{context}: expected HTTP {expected_status}, got: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some(expected_code),
|
||||||
|
"{context}: expected {expected_code}, got: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dummy_bucket_compatibility_endpoints() {
|
async fn test_dummy_bucket_compatibility_endpoints() {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -217,17 +239,11 @@ mod tests {
|
|||||||
.expect("DeleteBucketWebsite should return success");
|
.expect("DeleteBucketWebsite should return success");
|
||||||
|
|
||||||
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
|
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
|
||||||
assert!(
|
assert_s3_error(
|
||||||
website_after_delete.is_err(),
|
website_after_delete,
|
||||||
"GetBucketWebsite should return NoSuchWebsiteConfiguration after deletion"
|
404,
|
||||||
);
|
"NoSuchWebsiteConfiguration",
|
||||||
let website_err = website_after_delete.err().unwrap();
|
"GetBucketWebsite after deleting the website configuration",
|
||||||
let website_code = website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(website_code, Some("NoSuchWebsiteConfiguration")),
|
|
||||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
website_code,
|
|
||||||
website_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
@@ -245,15 +261,7 @@ mod tests {
|
|||||||
let missing_bucket = "test-dummy-bucket-missing";
|
let missing_bucket = "test-dummy-bucket-missing";
|
||||||
|
|
||||||
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
|
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
|
||||||
assert!(get_logging.is_err(), "GetBucketLogging should fail for missing bucket");
|
assert_s3_error(get_logging, 404, "NoSuchBucket", "GetBucketLogging for a missing bucket");
|
||||||
let get_logging_err = get_logging.err().unwrap();
|
|
||||||
let get_logging_code = get_logging_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_logging_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketLogging error code: {:?}, err: {:?}",
|
|
||||||
get_logging_code,
|
|
||||||
get_logging_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_logging = client
|
let put_logging = client
|
||||||
.put_bucket_logging()
|
.put_bucket_logging()
|
||||||
@@ -261,41 +269,22 @@ mod tests {
|
|||||||
.bucket_logging_status(BucketLoggingStatus::builder().build())
|
.bucket_logging_status(BucketLoggingStatus::builder().build())
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_logging.is_err(), "PutBucketLogging should fail for missing bucket");
|
assert_s3_error(put_logging, 404, "NoSuchBucket", "PutBucketLogging for a missing bucket");
|
||||||
let put_logging_err = put_logging.err().unwrap();
|
|
||||||
let put_logging_code = put_logging_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_logging_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketLogging error code: {:?}, err: {:?}",
|
|
||||||
put_logging_code,
|
|
||||||
put_logging_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_accelerate = client
|
let get_accelerate = client
|
||||||
.get_bucket_accelerate_configuration()
|
.get_bucket_accelerate_configuration()
|
||||||
.bucket(missing_bucket)
|
.bucket(missing_bucket)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(get_accelerate.is_err(), "GetBucketAccelerateConfiguration should fail for missing bucket");
|
assert_s3_error(
|
||||||
let get_accelerate_err = get_accelerate.err().unwrap();
|
get_accelerate,
|
||||||
let get_accelerate_code = get_accelerate_err.as_service_error().and_then(|e| e.code());
|
404,
|
||||||
assert!(
|
"NoSuchBucket",
|
||||||
matches!(get_accelerate_code, Some("NoSuchBucket")),
|
"GetBucketAccelerateConfiguration for a missing bucket",
|
||||||
"Unexpected GetBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
|
||||||
get_accelerate_code,
|
|
||||||
get_accelerate_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
|
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
|
||||||
assert!(get_request_payment.is_err(), "GetBucketRequestPayment should fail for missing bucket");
|
assert_s3_error(get_request_payment, 404, "NoSuchBucket", "GetBucketRequestPayment for a missing bucket");
|
||||||
let get_request_payment_err = get_request_payment.err().unwrap();
|
|
||||||
let get_request_payment_code = get_request_payment_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_request_payment_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketRequestPayment error code: {:?}, err: {:?}",
|
|
||||||
get_request_payment_code,
|
|
||||||
get_request_payment_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_accelerate = client
|
let put_accelerate = client
|
||||||
.put_bucket_accelerate_configuration()
|
.put_bucket_accelerate_configuration()
|
||||||
@@ -307,14 +296,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_accelerate.is_err(), "PutBucketAccelerateConfiguration should fail for missing bucket");
|
assert_s3_error(
|
||||||
let put_accelerate_err = put_accelerate.err().unwrap();
|
put_accelerate,
|
||||||
let put_accelerate_code = put_accelerate_err.as_service_error().and_then(|e| e.code());
|
404,
|
||||||
assert!(
|
"NoSuchBucket",
|
||||||
matches!(put_accelerate_code, Some("NoSuchBucket")),
|
"PutBucketAccelerateConfiguration for a missing bucket",
|
||||||
"Unexpected PutBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
|
||||||
put_accelerate_code,
|
|
||||||
put_accelerate_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let put_request_payment = client
|
let put_request_payment = client
|
||||||
@@ -328,15 +314,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_request_payment.is_err(), "PutBucketRequestPayment should fail for missing bucket");
|
assert_s3_error(put_request_payment, 404, "NoSuchBucket", "PutBucketRequestPayment for a missing bucket");
|
||||||
let put_request_payment_err = put_request_payment.err().unwrap();
|
|
||||||
let put_request_payment_code = put_request_payment_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_request_payment_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketRequestPayment error code: {:?}, err: {:?}",
|
|
||||||
put_request_payment_code,
|
|
||||||
put_request_payment_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_website = client
|
let put_website = client
|
||||||
.put_bucket_website()
|
.put_bucket_website()
|
||||||
@@ -353,37 +331,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_website.is_err(), "PutBucketWebsite should fail for missing bucket");
|
assert_s3_error(put_website, 404, "NoSuchBucket", "PutBucketWebsite for a missing bucket");
|
||||||
let put_website_err = put_website.err().unwrap();
|
|
||||||
let put_website_code = put_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
put_website_code,
|
|
||||||
put_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
|
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
|
||||||
assert!(get_website.is_err(), "GetBucketWebsite should fail for missing bucket");
|
assert_s3_error(get_website, 404, "NoSuchBucket", "GetBucketWebsite for a missing bucket");
|
||||||
let get_website_err = get_website.err().unwrap();
|
|
||||||
let get_website_code = get_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
get_website_code,
|
|
||||||
get_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
|
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
|
||||||
assert!(delete_website.is_err(), "DeleteBucketWebsite should fail for missing bucket");
|
assert_s3_error(delete_website, 404, "NoSuchBucket", "DeleteBucketWebsite for a missing bucket");
|
||||||
let delete_website_err = delete_website.err().unwrap();
|
|
||||||
let delete_website_code = delete_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(delete_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected DeleteBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
delete_website_code,
|
|
||||||
delete_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -73,10 +74,14 @@ async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::erro
|
|||||||
let user_client = create_user_client(&env, user_access, user_secret);
|
let user_client = create_user_client(&env, user_access, user_secret);
|
||||||
|
|
||||||
// 4. Verify Access Denied initially (No Policy)
|
// 4. Verify Access Denied initially (No Policy)
|
||||||
let result = user_client.list_objects_v2().bucket(bucket_name).send().await;
|
let denied = user_client
|
||||||
if result.is_ok() {
|
.list_objects_v2()
|
||||||
return Err("Should be Access Denied initially".into());
|
.bucket(bucket_name)
|
||||||
}
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a user without a bucket policy must be denied");
|
||||||
|
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||||
|
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||||
|
|
||||||
// 5. Apply Bucket Policy Allowed User
|
// 5. Apply Bucket Policy Allowed User
|
||||||
let policy_json = serde_json::json!({
|
let policy_json = serde_json::json!({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ mod tests {
|
|||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
@@ -186,16 +187,16 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(
|
let error = result.expect_err("PutObject with a mismatched SHA256 must be rejected (issue #4341)");
|
||||||
result.is_err(),
|
assert_eq!(
|
||||||
"PutObject with a mismatched SHA256 must be rejected, but it succeeded (issue #4341)"
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"Mismatched SHA256 must return HTTP 400, got {error:?}"
|
||||||
);
|
);
|
||||||
let err = result.err().unwrap();
|
assert_eq!(
|
||||||
let msg = format!("{err:?}");
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
info!("PutObject correctly rejected mismatched checksum: {msg}");
|
Some("BadDigest"),
|
||||||
assert!(
|
"Mismatched SHA256 must return BadDigest, got {error:?}"
|
||||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
|
||||||
"Expected a BadDigest/checksum error, got: {msg}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the object must not have been stored.
|
// And the object must not have been stored.
|
||||||
@@ -556,11 +557,16 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_bad.is_err(), "{header}: a mismatched checksum must be rejected");
|
let error = put_bad.expect_err("a mismatched checksum must be rejected");
|
||||||
let msg = format!("{:?}", put_bad.err().unwrap());
|
assert_eq!(
|
||||||
assert!(
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
Some(400),
|
||||||
"{header}: expected a BadDigest/checksum error, got: {msg}"
|
"{header}: mismatched checksum must return HTTP 400, got {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("BadDigest"),
|
||||||
|
"{header}: mismatched checksum must return BadDigest, got {error:?}"
|
||||||
);
|
);
|
||||||
let error = client
|
let error = client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
@@ -638,6 +638,18 @@ impl RustFSTestEnvironment {
|
|||||||
extra_args: Vec<&str>,
|
extra_args: Vec<&str>,
|
||||||
extra_env: &[(&str, &str)],
|
extra_env: &[(&str, &str)],
|
||||||
cleanup_existing: bool,
|
cleanup_existing: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let binary_path = rustfs_binary_path();
|
||||||
|
self.start_rustfs_server_inner_with_binary(&binary_path, extra_args, extra_env, cleanup_existing)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_rustfs_server_inner_with_binary(
|
||||||
|
&mut self,
|
||||||
|
binary_path: &Path,
|
||||||
|
extra_args: Vec<&str>,
|
||||||
|
extra_env: &[(&str, &str)],
|
||||||
|
cleanup_existing: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
if cleanup_existing {
|
if cleanup_existing {
|
||||||
self.cleanup_existing_processes().await?;
|
self.cleanup_existing_processes().await?;
|
||||||
@@ -647,8 +659,7 @@ impl RustFSTestEnvironment {
|
|||||||
|
|
||||||
info!("Starting RustFS server with args: {:?}", args);
|
info!("Starting RustFS server with args: {:?}", args);
|
||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
let mut command = Command::new(binary_path);
|
||||||
let mut command = Command::new(&binary_path);
|
|
||||||
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
||||||
// The embedded console would bind the fixed default port :9001, which
|
// The embedded console would bind the fixed default port :9001, which
|
||||||
// collides with unrelated local services (e.g. Docker Desktop). Tests
|
// collides with unrelated local services (e.g. Docker Desktop). Tests
|
||||||
@@ -668,6 +679,19 @@ impl RustFSTestEnvironment {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a specific RustFS binary against this environment's isolated
|
||||||
|
/// data directory. Upgrade tests use this to seed an old on-disk format
|
||||||
|
/// before restarting the same environment with the workspace binary.
|
||||||
|
pub async fn start_rustfs_server_from_binary(
|
||||||
|
&mut self,
|
||||||
|
binary_path: &Path,
|
||||||
|
extra_args: Vec<&str>,
|
||||||
|
extra_env: &[(&str, &str)],
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
self.start_rustfs_server_inner_with_binary(binary_path, extra_args, extra_env, true)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Start RustFS server with basic configuration
|
/// Start RustFS server with basic configuration
|
||||||
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
self.start_rustfs_server_inner(extra_args, &[], true).await
|
self.start_rustfs_server_inner(extra_args, &[], true).await
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||||
@@ -50,6 +51,9 @@ pub const VAULT_TOKEN: &str = "dev-root-token";
|
|||||||
pub const VAULT_TRANSIT_PATH: &str = "transit";
|
pub const VAULT_TRANSIT_PATH: &str = "transit";
|
||||||
pub const VAULT_KEY_NAME: &str = "rustfs-master-key";
|
pub const VAULT_KEY_NAME: &str = "rustfs-master-key";
|
||||||
pub const ENV_TEST_VAULT_BIN: &str = "RUSTFS_TEST_VAULT_BIN";
|
pub const ENV_TEST_VAULT_BIN: &str = "RUSTFS_TEST_VAULT_BIN";
|
||||||
|
pub const SSE_C_KEY_MISMATCH_MESSAGE: &str =
|
||||||
|
"The provided encryption parameters did not match the ones used originally to encrypt the object.";
|
||||||
|
pub const SSE_C_MISSING_PARAMETERS_MESSAGE: &str = "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.";
|
||||||
|
|
||||||
/// Initialize tracing for KMS tests with KMS-specific log levels
|
/// Initialize tracing for KMS tests with KMS-specific log levels
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
@@ -63,6 +67,24 @@ pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
|||||||
BASE64.encode(hasher.finalize())
|
BASE64.encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn assert_s3_error<T, E>(result: Result<T, SdkError<E>>, status: u16, code: &str, message: &str, context: &str)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug,
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let error = result.expect_err(context);
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(status),
|
||||||
|
"{context}: unexpected HTTP status: {error:?}"
|
||||||
|
);
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.expect("request failure should retain an S3 service error");
|
||||||
|
assert_eq!(service_error.code(), Some(code), "{context}: unexpected error code: {error:?}");
|
||||||
|
assert_eq!(service_error.message(), Some(message), "{context}: unexpected error message: {error:?}");
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn kms_admin_request(
|
pub async fn kms_admin_request(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
method: http::Method,
|
method: http::Method,
|
||||||
@@ -559,7 +581,13 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Download with wrong SSE-C key should fail");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"download with a wrong SSE-C key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected download with wrong SSE-C key");
|
info!("✅ Correctly rejected download with wrong SSE-C key");
|
||||||
|
|
||||||
info!("Error scenario tests completed successfully");
|
info!("Error scenario tests completed successfully");
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
//! complex workflows.
|
//! complex workflows.
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, sse_customer_key_md5_base64,
|
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error,
|
||||||
test_all_multipart_encryption_types, test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption,
|
create_sse_c_config, sse_customer_key_md5_base64, test_all_multipart_encryption_types, test_kms_key_management,
|
||||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
test_multipart_upload_with_config, test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||||
};
|
};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -191,7 +191,13 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_read_result.is_err(), "The encrypted file should not be readable with the wrong key");
|
assert_s3_error(
|
||||||
|
wrong_read_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"multipart SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Confirm that key isolation is working correctly");
|
info!("✅ Confirm that key isolation is working correctly");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -21,21 +21,14 @@
|
|||||||
//! - Concurrent encryption operations
|
//! - Concurrent encryption operations
|
||||||
//! - Security validation tests
|
//! - Security validation tests
|
||||||
|
|
||||||
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
|
use super::common::{LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, sse_customer_key_md5_base64};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
hasher.update(input.as_ref());
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test encryption of zero-byte files (empty files)
|
/// Test encryption of zero-byte files (empty files)
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -295,7 +288,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
info!("🔍 Testing invalid SSE-C key length");
|
info!("🔍 Testing invalid SSE-C key length");
|
||||||
let invalid_short_key = "short"; // Too short
|
let invalid_short_key = "short"; // Too short
|
||||||
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
|
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
|
||||||
let invalid_key_md5 = md5_hex(invalid_short_key);
|
let invalid_key_md5 = sse_customer_key_md5_base64(invalid_short_key);
|
||||||
|
|
||||||
let invalid_key_result = s3_client
|
let invalid_key_result = s3_client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -308,14 +301,32 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(invalid_key_result.is_err(), "Should reject invalid key length");
|
assert_s3_error(
|
||||||
|
invalid_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"SSE-C key must be 32 bytes (256 bits), got 5 bytes.",
|
||||||
|
"invalid SSE-C key length must be rejected",
|
||||||
|
);
|
||||||
|
assert_s3_error(
|
||||||
|
s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key("test-invalid-key-length")
|
||||||
|
.send()
|
||||||
|
.await,
|
||||||
|
404,
|
||||||
|
"NoSuchKey",
|
||||||
|
"The specified key does not exist.",
|
||||||
|
"rejected invalid-key PUT must not create an object",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected invalid key length");
|
info!("✅ Correctly rejected invalid key length");
|
||||||
|
|
||||||
// Test 2: Mismatched MD5 for SSE-C
|
// Test 2: Mismatched MD5 for SSE-C
|
||||||
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
||||||
let valid_key = "01234567890123456789012345678901";
|
let valid_key = "01234567890123456789012345678901";
|
||||||
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
|
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
|
||||||
let wrong_md5 = "wrongmd5hash12345678901234567890"; // Wrong MD5
|
let wrong_md5 = sse_customer_key_md5_base64("98765432109876543210987654321098");
|
||||||
|
|
||||||
let wrong_md5_result = s3_client
|
let wrong_md5_result = s3_client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -324,11 +335,24 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.body(aws_sdk_s3::primitives::ByteStream::from(test_data.to_vec()))
|
.body(aws_sdk_s3::primitives::ByteStream::from(test_data.to_vec()))
|
||||||
.sse_customer_algorithm("AES256")
|
.sse_customer_algorithm("AES256")
|
||||||
.sse_customer_key(&valid_key_b64)
|
.sse_customer_key(&valid_key_b64)
|
||||||
.sse_customer_key_md5(wrong_md5)
|
.sse_customer_key_md5(&wrong_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_md5_result.is_err(), "Should reject mismatched MD5");
|
assert_s3_error(
|
||||||
|
wrong_md5_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"The calculated MD5 hash of the key did not match the hash that was provided.",
|
||||||
|
"mismatched SSE-C key MD5 must be rejected",
|
||||||
|
);
|
||||||
|
assert_s3_error(
|
||||||
|
s3_client.get_object().bucket(TEST_BUCKET).key("test-wrong-md5").send().await,
|
||||||
|
404,
|
||||||
|
"NoSuchKey",
|
||||||
|
"The specified key does not exist.",
|
||||||
|
"rejected mismatched-MD5 PUT must not create an object",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected mismatched MD5");
|
info!("✅ Correctly rejected mismatched MD5");
|
||||||
|
|
||||||
// Test 3: Try to access SSE-C object without providing key
|
// Test 3: Try to access SSE-C object without providing key
|
||||||
@@ -355,7 +379,28 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(no_key_result.is_err(), "Should require SSE-C key for access");
|
assert_s3_error(
|
||||||
|
no_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.",
|
||||||
|
"SSE-C object GET without a customer key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key("test-sse-c-no-key-access")
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&valid_key_b64)
|
||||||
|
.sse_customer_key_md5(&valid_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(recovered.as_ref(), test_data, "failed GET must not corrupt the SSE-C object");
|
||||||
info!("✅ Correctly required SSE-C key for access");
|
info!("✅ Correctly required SSE-C key for access");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -563,7 +608,13 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt with wrong key");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"SSE-C object GET with the wrong customer key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Key isolation verified - wrong key cannot decrypt data");
|
info!("✅ Key isolation verified - wrong key cannot decrypt data");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
use super::common::LocalKMSTestEnvironment;
|
use super::common::LocalKMSTestEnvironment;
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -77,12 +78,25 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// This should fail, but the server should still be responsive
|
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||||
if put_result2.is_err() {
|
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||||
info!("✅ Upload correctly failed when key directory unavailable");
|
assert_eq!(
|
||||||
} else {
|
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
warn!("⚠️ Upload succeeded despite unavailable key directory (may be using cached keys)");
|
Some("InternalError")
|
||||||
}
|
);
|
||||||
|
let unavailable_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key2)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by unavailable KMS must not publish an object");
|
||||||
|
assert_eq!(unavailable_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
unavailable_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
|
);
|
||||||
|
info!("✅ Upload correctly failed when key directory unavailable");
|
||||||
|
|
||||||
// Restore the key directory
|
// Restore the key directory
|
||||||
info!("🔧 Restoring key directory");
|
info!("🔧 Restoring key directory");
|
||||||
@@ -107,6 +121,11 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||||
|
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||||
|
|
||||||
// Verify we can still access the original file
|
// Verify we can still access the original file
|
||||||
info!("📥 Verifying access to original encrypted file");
|
info!("📥 Verifying access to original encrypted file");
|
||||||
let get_response = s3_client.get_object().bucket(TEST_BUCKET).key(object_key).send().await?;
|
let get_response = s3_client.get_object().bucket(TEST_BUCKET).key(object_key).send().await?;
|
||||||
@@ -174,12 +193,22 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// This might succeed if KMS uses cached keys, but should eventually fail
|
let corrupt_error = put_result2.expect_err("corrupt Local KMS key material must reject encrypted writes");
|
||||||
if put_result2.is_err() {
|
assert_eq!(corrupt_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||||
info!("✅ Upload correctly failed with corrupted key");
|
assert_eq!(
|
||||||
} else {
|
corrupt_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
warn!("⚠️ Upload succeeded despite corrupted key (likely using cached key)");
|
Some("InternalError")
|
||||||
}
|
);
|
||||||
|
let corrupt_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key2)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by corrupt KMS material must not publish an object");
|
||||||
|
assert_eq!(corrupt_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(corrupt_absence.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
info!("✅ Upload correctly failed with corrupted key");
|
||||||
|
|
||||||
// Restore the original key file
|
// Restore the original key file
|
||||||
info!("🔧 Restoring original key file");
|
info!("🔧 Restoring original key file");
|
||||||
@@ -205,6 +234,11 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
|||||||
|
|
||||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||||
|
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
info!("✅ Corrupted key files test completed successfully");
|
info!("✅ Corrupted key files test completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -280,18 +314,14 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
|||||||
info!("🔧 Simulating upload interruption");
|
info!("🔧 Simulating upload interruption");
|
||||||
|
|
||||||
// Abort the multipart upload
|
// Abort the multipart upload
|
||||||
let abort_result = s3_client
|
s3_client
|
||||||
.abort_multipart_upload()
|
.abort_multipart_upload()
|
||||||
.bucket(TEST_BUCKET)
|
.bucket(TEST_BUCKET)
|
||||||
.key(object_key)
|
.key(object_key)
|
||||||
.upload_id(upload_id)
|
.upload_id(upload_id)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?;
|
||||||
|
info!("✅ Multipart upload aborted successfully");
|
||||||
match abort_result {
|
|
||||||
Ok(_) => info!("✅ Multipart upload aborted successfully"),
|
|
||||||
Err(e) => warn!("⚠️ Failed to abort multipart upload: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to complete the aborted upload - this should fail
|
// Try to complete the aborted upload - this should fail
|
||||||
info!("🔍 Attempting to complete aborted upload");
|
info!("🔍 Attempting to complete aborted upload");
|
||||||
@@ -310,18 +340,38 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
|||||||
.set_parts(Some(completed_parts))
|
.set_parts(Some(completed_parts))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let complete_result = s3_client
|
let complete_error = s3_client
|
||||||
.complete_multipart_upload()
|
.complete_multipart_upload()
|
||||||
.bucket(TEST_BUCKET)
|
.bucket(TEST_BUCKET)
|
||||||
.key(object_key)
|
.key(object_key)
|
||||||
.upload_id(upload_id)
|
.upload_id(upload_id)
|
||||||
.multipart_upload(completed_multipart_upload)
|
.multipart_upload(completed_multipart_upload)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.expect_err("an aborted multipart upload must not be completable");
|
||||||
assert!(complete_result.is_err(), "Should not be able to complete aborted upload");
|
assert_eq!(complete_error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
complete_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchUpload")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
complete_error.as_service_error().and_then(ProvideErrorMetadata::message),
|
||||||
|
Some(
|
||||||
|
"The specified multipart upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
|
||||||
|
)
|
||||||
|
);
|
||||||
info!("✅ Correctly failed to complete aborted upload");
|
info!("✅ Correctly failed to complete aborted upload");
|
||||||
|
|
||||||
|
let missing_object = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("aborting a multipart upload must not publish an object");
|
||||||
|
assert_eq!(missing_object.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(missing_object.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
|
||||||
// Start a new multipart upload and complete it successfully
|
// Start a new multipart upload and complete it successfully
|
||||||
info!("📤 Starting new multipart upload");
|
info!("📤 Starting new multipart upload");
|
||||||
let create_multipart_output2 = s3_client
|
let create_multipart_output2 = s3_client
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
//! - Complete encryption/decryption lifecycle
|
//! - Complete encryption/decryption lifecycle
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
LocalKMSTestEnvironment, get_kms_status, sse_customer_key_md5_base64, test_kms_key_management, test_sse_c_encryption,
|
LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, get_kms_status, sse_customer_key_md5_base64,
|
||||||
|
test_kms_key_management, test_sse_c_encryption,
|
||||||
};
|
};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -196,7 +197,13 @@ async fn test_local_kms_key_isolation() {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt object1 with key2");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"local SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
kms_env
|
kms_env
|
||||||
.base_env
|
.base_env
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ use crate::common::{TEST_BUCKET, init_logging};
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, sse_customer_key_md5_base64, start_kms,
|
SSE_C_KEY_MISMATCH_MESSAGE, VAULT_KEY_NAME, VaultTestEnvironment, assert_s3_error, get_kms_status,
|
||||||
test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management, test_sse_c_encryption,
|
sse_customer_key_md5_base64, start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management,
|
||||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
|
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
|
||||||
@@ -182,7 +182,13 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
|||||||
.sse_customer_key_md5(&key2_md5)
|
.sse_customer_key_md5(&key2_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(wrong_key.is_err(), "Object1 should not decrypt with key2");
|
assert_s3_error(
|
||||||
|
wrong_key,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"Vault-backed SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
context
|
context
|
||||||
.base_env()
|
.base_env()
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ mod get_codec_streaming_compat_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod version_id_regression_test;
|
mod version_id_regression_test;
|
||||||
|
|
||||||
|
// Pinned previous-release -> current-build on-disk compatibility.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod upgrade_compatibility_test;
|
||||||
|
|
||||||
// Receiver-side replication LWW (rustfs/backlog#1953): stale inbound
|
// Receiver-side replication LWW (rustfs/backlog#1953): stale inbound
|
||||||
// replication metadata must not overwrite a newer local category state.
|
// replication metadata must not overwrite a newer local category state.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
//! rejected header-SigV4 requests.
|
//! rejected header-SigV4 requests.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
||||||
@@ -280,7 +281,8 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
|||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
setup(&mut env).await?;
|
setup(&mut env).await?;
|
||||||
|
|
||||||
let path = format!("/{BUCKET}/tampered-payload.txt");
|
let key = "tampered-payload.txt";
|
||||||
|
let path = format!("/{BUCKET}/{key}");
|
||||||
let claimed_body = b"the-body-i-claim-to-send";
|
let claimed_body = b"the-body-i-claim-to-send";
|
||||||
let actual_body = b"the-body-i-really-send!!";
|
let actual_body = b"the-body-i-really-send!!";
|
||||||
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||||
@@ -295,17 +297,31 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
|||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
assert_ne!(status.as_u16(), 200, "payload mismatch must not succeed, body:\n{body}");
|
|
||||||
assert!(
|
assert!(
|
||||||
status.is_client_error() || status.is_server_error(),
|
status.is_client_error(),
|
||||||
"payload mismatch must be an error status, got {status}, body:\n{body}"
|
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
|
||||||
);
|
);
|
||||||
info!(%status, "tampered payload rejected with error status");
|
info!(%status, "tampered payload rejected with error status");
|
||||||
}
|
}
|
||||||
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
||||||
// also a valid rejection (definitely not a 200 success).
|
// also a valid rejection (definitely not a 200 success).
|
||||||
Err(err) => info!(%err, "tampered payload rejected via transport error"),
|
Err(err) => {
|
||||||
|
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
|
||||||
|
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
|
||||||
|
info!(%err, "tampered payload rejected via mid-stream transport error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let absent = env
|
||||||
|
.create_s3_client()
|
||||||
|
.get_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a tampered payload must not publish an object");
|
||||||
|
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::SdkError;
|
||||||
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||||
|
use aws_sdk_s3::operation::put_object_retention::PutObjectRetentionError;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||||
@@ -182,11 +183,8 @@ pub async fn put_object_retention(
|
|||||||
mode: ObjectLockRetentionMode,
|
mode: ObjectLockRetentionMode,
|
||||||
retain_until: DateTime<Utc>,
|
retain_until: DateTime<Utc>,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<SdkError<PutObjectRetentionError>>> {
|
||||||
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
|
let retain_until_datetime = aws_sdk_s3::primitives::DateTime::from_secs(retain_until.timestamp());
|
||||||
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()
|
let retention = ObjectLockRetention::builder()
|
||||||
.mode(mode.clone())
|
.mode(mode.clone())
|
||||||
@@ -204,7 +202,7 @@ pub async fn put_object_retention(
|
|||||||
request = request.version_id(vid);
|
request = request.version_id(vid);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.send().await?;
|
request.send().await.map_err(Box::new)?;
|
||||||
info!("Put object retention on {} with mode {:?}", key, mode);
|
info!("Put object retention on {} with mode {:?}", key, mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1475,7 +1475,7 @@ async fn test_put_retention_compliance_cannot_shorten() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
|
assert_access_denied(shorten_result, "Shortening COMPLIANCE retention should fail");
|
||||||
|
|
||||||
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
||||||
}
|
}
|
||||||
@@ -1598,10 +1598,7 @@ async fn test_put_retention_governance_shorten_requires_bypass() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert_access_denied(shorten_without_bypass, "Shortening GOVERNANCE retention without bypass should fail");
|
||||||
shorten_without_bypass.is_err(),
|
|
||||||
"Shortening GOVERNANCE retention without bypass should fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shorten with bypass - should succeed
|
// Shorten with bypass - should succeed
|
||||||
let shorten_with_bypass = put_object_retention(
|
let shorten_with_bypass = put_object_retention(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use http::{Method, StatusCode};
|
use http::{Method, StatusCode};
|
||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
@@ -132,19 +133,13 @@ impl QuotaTestEnv {
|
|||||||
pub async fn object_exists(&self, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
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 {
|
match self.client.head_object().bucket(&self.bucket_name).key(key).send().await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => {
|
Err(error) => {
|
||||||
// Check for any 404-related errors and return false instead of propagating
|
let status = error.raw_response().map(|response| response.status().as_u16());
|
||||||
let error_str = e.to_string();
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
if error_str.contains("404") || error_str.contains("Not Found") || error_str.contains("NotFound") {
|
if status == Some(404) && matches!(code, Some("NotFound" | "NoSuchKey")) {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
} else {
|
} else {
|
||||||
// Also check the error code directly
|
Err(error.into())
|
||||||
if let Some(service_err) = e.as_service_error()
|
|
||||||
&& service_err.is_not_found()
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
Err(e.into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +273,46 @@ impl QuotaTestEnv {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod integration_tests {
|
mod integration_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
|
||||||
|
fn assert_error_response(status: StatusCode, body: &str, expected_status: StatusCode, expected_code: &str) {
|
||||||
|
assert_eq!(status, expected_status, "unexpected error status: {status} {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("<Code>{expected_code}</Code>")),
|
||||||
|
"expected {expected_code}, got: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_quota_rejection<E>(status: Option<u16>, service_error: Option<&E>, error: &impl std::fmt::Debug)
|
||||||
|
where
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
assert_eq!(status, Some(400), "quota rejection must return HTTP 400: {error:?}");
|
||||||
|
let service_error = service_error.expect("quota rejection must be an S3 service error");
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
|
||||||
|
assert!(
|
||||||
|
service_error
|
||||||
|
.message()
|
||||||
|
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
|
||||||
|
"operation must fail specifically at quota admission: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_put_rejected_by_quota(env: &QuotaTestEnv, key: &str, size_bytes: usize) {
|
||||||
|
let error = env
|
||||||
|
.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&env.bucket_name)
|
||||||
|
.key(key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(vec![0u8; size_bytes]))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("PUT above quota must be rejected");
|
||||||
|
assert_quota_rejection(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
error.as_service_error(),
|
||||||
|
&error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -304,8 +338,7 @@ mod integration_tests {
|
|||||||
assert!(env.object_exists("test2.txt").await?);
|
assert!(env.object_exists("test2.txt").await?);
|
||||||
|
|
||||||
// Try to upload 1KB more (should fail due to quota)
|
// Try to upload 1KB more (should fail due to quota)
|
||||||
let upload_result = env.upload_object("test3.txt", 1024).await;
|
assert_put_rejected_by_quota(&env, "test3.txt", 1024).await;
|
||||||
assert!(upload_result.is_err());
|
|
||||||
assert!(!env.object_exists("test3.txt").await?);
|
assert!(!env.object_exists("test3.txt").await?);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
@@ -356,10 +389,10 @@ mod integration_tests {
|
|||||||
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
|
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
|
||||||
.await
|
.await
|
||||||
.expect_err("declared aws-chunked PUT over quota must be rejected");
|
.expect_err("declared aws-chunked PUT over quota must be rejected");
|
||||||
let err_debug = format!("{err:?}");
|
assert_quota_rejection(
|
||||||
assert!(
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
!err_debug.contains("UnexpectedContent"),
|
err.as_service_error(),
|
||||||
"over-quota rejection must be the quota error, not UnexpectedContent: {err_debug}"
|
&err,
|
||||||
);
|
);
|
||||||
assert!(!env.object_exists("over-quota.bin").await?);
|
assert!(!env.object_exists("over-quota.bin").await?);
|
||||||
|
|
||||||
@@ -581,24 +614,35 @@ mod integration_tests {
|
|||||||
env.create_bucket().await?;
|
env.create_bucket().await?;
|
||||||
|
|
||||||
// Test invalid quota type
|
// Test invalid quota type
|
||||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
let quota_path = format!("/rustfs/admin/v3/quota/{}", env.bucket_name);
|
||||||
|
|
||||||
let invalid_config = serde_json::json!({
|
let invalid_config = serde_json::json!({
|
||||||
"quota": 1024,
|
"quota": 1024,
|
||||||
"quota_type": "SOFT" // Invalid type
|
"quota_type": "SOFT" // Invalid type
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
let (status, body) = admin_request(
|
||||||
assert!(response.is_err());
|
&env.env.url,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
Method::PUT,
|
||||||
assert!(error_msg.contains("InvalidArgument"));
|
"a_path,
|
||||||
|
Some(invalid_config.to_string()),
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::BAD_REQUEST, "InvalidArgument");
|
||||||
|
|
||||||
// Test operations on non-existent bucket
|
// Test operations on non-existent bucket
|
||||||
let url = format!("{}/rustfs/admin/v3/quota/non-existent-bucket", env.env.url);
|
let (status, body) = admin_request(
|
||||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await;
|
&env.env.url,
|
||||||
assert!(response.is_err());
|
Method::GET,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
"/rustfs/admin/v3/quota/non-existent-bucket",
|
||||||
assert!(error_msg.contains("NoSuchBucket"));
|
None,
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::NOT_FOUND, "NoSuchBucket");
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
|
|
||||||
@@ -652,10 +696,16 @@ mod integration_tests {
|
|||||||
"quota": 1024,
|
"quota": 1024,
|
||||||
"quota_type": "SOFT"
|
"quota_type": "SOFT"
|
||||||
});
|
});
|
||||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
let (status, body) = admin_request(
|
||||||
assert!(response.is_err());
|
&env.env.url,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
Method::PUT,
|
||||||
assert!(error_msg.contains("InvalidArgument"));
|
&format!("/rustfs/admin/v3/quota/{}", env.bucket_name),
|
||||||
|
Some(invalid_config.to_string()),
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::BAD_REQUEST, "InvalidArgument");
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
|
|
||||||
@@ -698,26 +748,21 @@ mod integration_tests {
|
|||||||
assert!(resp.contains("quota_limit"));
|
assert!(resp.contains("quota_limit"));
|
||||||
|
|
||||||
// Normal user sets quota — should be denied
|
// Normal user sets quota — should be denied
|
||||||
let set_error = awscurl_put(
|
let quota_path = format!("/rustfs/admin/v3/quota/{}", env.bucket_name);
|
||||||
&get_url,
|
let (status, body) = admin_request(
|
||||||
&serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string(),
|
&env.env.url,
|
||||||
|
Method::PUT,
|
||||||
|
"a_path,
|
||||||
|
Some(serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string()),
|
||||||
normal_ak,
|
normal_ak,
|
||||||
normal_sk,
|
normal_sk,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
.expect_err("normal user should not be able to set quota")
|
assert_error_response(status, &body, StatusCode::FORBIDDEN, "AccessDenied");
|
||||||
.to_string();
|
|
||||||
assert!(set_error.contains("AccessDenied"), "quota denial must return AccessDenied: {set_error}");
|
|
||||||
|
|
||||||
// Normal user clears quota — should be denied
|
// Normal user clears quota — should be denied
|
||||||
let delete_error = awscurl_delete(&get_url, normal_ak, normal_sk)
|
let (status, body) = admin_request(&env.env.url, Method::DELETE, "a_path, None, normal_ak, normal_sk).await?;
|
||||||
.await
|
assert_error_response(status, &body, StatusCode::FORBIDDEN, "AccessDenied");
|
||||||
.expect_err("normal user should not be able to clear quota")
|
|
||||||
.to_string();
|
|
||||||
assert!(
|
|
||||||
delete_error.contains("AccessDenied"),
|
|
||||||
"quota deletion denial must return AccessDenied: {delete_error}"
|
|
||||||
);
|
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -757,7 +802,12 @@ mod integration_tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(copy_result.is_err());
|
let copy_error = copy_result.expect_err("copy above quota must be rejected");
|
||||||
|
assert_quota_rejection(
|
||||||
|
copy_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
copy_error.as_service_error(),
|
||||||
|
©_error,
|
||||||
|
);
|
||||||
assert!(!env.object_exists("copy2.txt").await?);
|
assert!(!env.object_exists("copy2.txt").await?);
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
@@ -780,8 +830,7 @@ mod integration_tests {
|
|||||||
env.upload_object("file2.txt", 1024 * 1024).await?;
|
env.upload_object("file2.txt", 1024 * 1024).await?;
|
||||||
|
|
||||||
// Verify quota is full
|
// Verify quota is full
|
||||||
let upload_result = env.upload_object("file3.txt", 1024).await;
|
assert_put_rejected_by_quota(&env, "file3.txt", 1024).await;
|
||||||
assert!(upload_result.is_err());
|
|
||||||
|
|
||||||
// Delete multiple objects using batch delete
|
// Delete multiple objects using batch delete
|
||||||
let objects = vec![
|
let objects = vec![
|
||||||
@@ -881,9 +930,7 @@ mod integration_tests {
|
|||||||
|
|
||||||
// Test 2: Multipart upload exceeds quota (should fail)
|
// Test 2: Multipart upload exceeds quota (should fail)
|
||||||
// Upload 6MB filler (total now: 5MB + 6MB = 11MB > 10MB quota)
|
// Upload 6MB filler (total now: 5MB + 6MB = 11MB > 10MB quota)
|
||||||
let upload_filler = env.upload_object("filler.txt", 6 * 1024 * 1024).await;
|
assert_put_rejected_by_quota(&env, "filler.txt", 6 * 1024 * 1024).await;
|
||||||
// This should fail due to quota
|
|
||||||
assert!(upload_filler.is_err());
|
|
||||||
|
|
||||||
// Verify filler doesn't exist
|
// Verify filler doesn't exist
|
||||||
assert!(!env.object_exists("filler.txt").await?);
|
assert!(!env.object_exists("filler.txt").await?);
|
||||||
@@ -939,7 +986,11 @@ mod integration_tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let complete_error = complete_result.expect_err("multipart completion above quota must be rejected");
|
let complete_error = complete_result.expect_err("multipart completion above quota must be rejected");
|
||||||
assert_eq!(complete_error.as_service_error().and_then(|error| error.code()), Some("InvalidRequest"));
|
assert_quota_rejection(
|
||||||
|
complete_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
complete_error.as_service_error(),
|
||||||
|
&complete_error,
|
||||||
|
);
|
||||||
assert!(!env.object_exists("over_quota.txt").await?);
|
assert!(!env.object_exists("over_quota.txt").await?);
|
||||||
|
|
||||||
let staged_parts = env
|
let staged_parts = env
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ use crate::fake_s3_target::{
|
|||||||
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
|
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
|
||||||
RequestRecord,
|
RequestRecord,
|
||||||
};
|
};
|
||||||
use crate::kms::common::{create_key_with_specific_id, sse_customer_key_md5_base64};
|
use crate::kms::common::{
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE, SSE_C_MISSING_PARAMETERS_MESSAGE, assert_s3_error, create_key_with_specific_id,
|
||||||
|
sse_customer_key_md5_base64,
|
||||||
|
};
|
||||||
use crate::storage_api::replication_extension::BucketTargetSys;
|
use crate::storage_api::replication_extension::BucketTargetSys;
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
@@ -4375,7 +4378,13 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
|||||||
// Without the customer key the replica must not be readable — the direct
|
// Without the customer key the replica must not be readable — the direct
|
||||||
// detection point for a silent-plaintext replica (backlog#1291).
|
// detection point for a silent-plaintext replica (backlog#1291).
|
||||||
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
||||||
assert!(plain_read.is_err(), "SSE-C replica must not be readable without the customer key");
|
assert_s3_error(
|
||||||
|
plain_read,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
|
"SSE-C replica must not be readable without the customer key",
|
||||||
|
);
|
||||||
|
|
||||||
// A wrong customer key must fail too.
|
// A wrong customer key must fail too.
|
||||||
let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999");
|
let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999");
|
||||||
@@ -4389,7 +4398,13 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
|||||||
.sse_customer_key_md5(&wrong_key_md5)
|
.sse_customer_key_md5(&wrong_key_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(wrong_read.is_err(), "SSE-C replica must reject a wrong customer key");
|
assert_s3_error(
|
||||||
|
wrong_read,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"SSE-C replica must reject a wrong customer key",
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -4486,9 +4501,12 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
|||||||
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), payload.as_slice());
|
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), payload.as_slice());
|
||||||
|
|
||||||
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
||||||
assert!(
|
assert_s3_error(
|
||||||
plain_read.is_err(),
|
plain_read,
|
||||||
"SSE-C multipart replica must not be readable without the customer key"
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
|
"SSE-C multipart replica must not be readable without the customer key",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stability across scanner cycles: convergence must hold for passthrough.
|
// Stability across scanner cycles: convergence must hold for passthrough.
|
||||||
@@ -4892,15 +4910,12 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
|
|||||||
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body.as_slice());
|
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body.as_slice());
|
||||||
|
|
||||||
// No plaintext leak: the replica stays unreadable without the key.
|
// No plaintext leak: the replica stays unreadable without the key.
|
||||||
assert!(
|
assert_s3_error(
|
||||||
target_client
|
target_client.get_object().bucket(target_bucket).key(key).send().await,
|
||||||
.get_object()
|
400,
|
||||||
.bucket(target_bucket)
|
"InvalidRequest",
|
||||||
.key(key)
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
.send()
|
"SSE-C resynced replica must not be readable without the customer key",
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"SSE-C replica must not be readable without the customer key"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use http::header::HOST;
|
use http::header::HOST;
|
||||||
@@ -731,12 +732,7 @@ mod tests {
|
|||||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||||
|
|
||||||
// Test that control characters are rejected
|
// Test that control characters are rejected
|
||||||
let invalid_keys = vec![
|
let invalid_keys = ["file\0with\0null.txt", "file\nwith\nnewline.txt", "file\rwith\rcarriage.txt"];
|
||||||
"file\0with\0null.txt",
|
|
||||||
"file\nwith\nnewline.txt",
|
|
||||||
"file\rwith\rcarriage.txt",
|
|
||||||
"file\twith\ttab.txt", // Tab might be allowed, but let's test
|
|
||||||
];
|
|
||||||
|
|
||||||
for key in invalid_keys {
|
for key in invalid_keys {
|
||||||
info!("Testing rejection of control character in key: {:?}", key);
|
info!("Testing rejection of control character in key: {:?}", key);
|
||||||
@@ -747,18 +743,28 @@ mod tests {
|
|||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from_static(b"test"))
|
.body(ByteStream::from_static(b"test"))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.expect_err("invalid control characters must be rejected by the server");
|
||||||
// Note: The validation happens on the server side, so we expect an error
|
assert_eq!(
|
||||||
// For null byte, newline, and carriage return
|
result.raw_response().map(|response| response.status().as_u16()),
|
||||||
if key.contains('\0') || key.contains('\n') || key.contains('\r') {
|
Some(400),
|
||||||
assert!(result.is_err(), "Control character should be rejected for key: {key:?}");
|
"control character must return HTTP 400 for key {key:?}: {result:?}"
|
||||||
if let Err(e) = result {
|
);
|
||||||
info!("✅ Control character correctly rejected: {:?}", e);
|
assert_eq!(
|
||||||
}
|
result.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
}
|
Some("InvalidArgument"),
|
||||||
|
"control character must return InvalidArgument for key {key:?}: {result:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listed = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("server must remain healthy after rejected requests");
|
||||||
|
assert!(listed.contents().is_empty(), "rejected requests must not create objects");
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
info!("Test completed successfully");
|
info!("Test completed successfully");
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// 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, init_logging};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
|
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||||
|
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||||
|
|
||||||
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or("RUSTFS_UPGRADE_SOURCE_BINARY must point to the pinned previous release binary")?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let configuration = VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build();
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_object(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: Option<&str>,
|
||||||
|
) -> Result<(Option<ServerSideEncryption>, Vec<u8>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let mut request = client.get_object().bucket(bucket).key(key);
|
||||||
|
if let Some(version_id) = version_id {
|
||||||
|
request = request.version_id(version_id);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
let encryption = response.server_side_encryption().cloned();
|
||||||
|
let body = response.body.collect().await?.into_bytes().to_vec();
|
||||||
|
Ok((encryption, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||||
|
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||||
|
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||||
|
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||||
|
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
let part_number = i32::try_from(index + 1)?;
|
||||||
|
let uploaded = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(part_number)
|
||||||
|
.body(ByteStream::from(part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
completed_parts.push(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(part_number)
|
||||||
|
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = [(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY)];
|
||||||
|
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
env.create_test_bucket(PLAIN_BUCKET).await?;
|
||||||
|
env.create_test_bucket(VERSIONED_BUCKET).await?;
|
||||||
|
enable_versioning(&old_client, VERSIONED_BUCKET).await?;
|
||||||
|
|
||||||
|
let plain_key = "plain-object";
|
||||||
|
let plain_bytes = b"written by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(plain_key)
|
||||||
|
.body(ByteStream::from_static(plain_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let encrypted_key = "sse-s3-object";
|
||||||
|
let encrypted_bytes = b"encrypted by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(encrypted_key)
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let multipart_key = "multipart-object";
|
||||||
|
let multipart_parts = vec![vec![b'a'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&old_client, PLAIN_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned-object";
|
||||||
|
let version1_bytes = b"version one from the previous release";
|
||||||
|
let version1 = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(version1_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version2_bytes = b"version two from the previous release";
|
||||||
|
let version2 = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(version2_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let deleted = old_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let delete_marker = deleted
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned DELETE omitted delete marker version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let current_client = env.create_s3_client();
|
||||||
|
|
||||||
|
assert_eq!(read_object(¤t_client, PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
|
||||||
|
|
||||||
|
let (encryption, upgraded_encrypted_bytes) = read_object(¤t_client, PLAIN_BUCKET, encrypted_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(upgraded_encrypted_bytes, encrypted_bytes);
|
||||||
|
|
||||||
|
assert_eq!(read_object(¤t_client, PLAIN_BUCKET, multipart_key, None).await?.1, multipart_bytes);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, VERSIONED_BUCKET, versioned_key, Some(&version1))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
version1_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, VERSIONED_BUCKET, versioned_key, Some(&version2))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
version2_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
let current_read = current_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the previous release's delete marker must remain current after upgrade");
|
||||||
|
assert_eq!(current_read.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(current_read.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
|
||||||
|
let listed = current_client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.prefix(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(listed.versions().len(), 2);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(version1.as_str()))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(version2.as_str()))
|
||||||
|
);
|
||||||
|
assert_eq!(listed.delete_markers().len(), 1);
|
||||||
|
assert_eq!(listed.delete_markers()[0].version_id(), Some(delete_marker.as_str()));
|
||||||
|
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
|
||||||
|
|
||||||
|
let post_upgrade_key = "written-after-upgrade";
|
||||||
|
let post_upgrade_bytes = b"written by the current RustFS build";
|
||||||
|
current_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(post_upgrade_key)
|
||||||
|
.body(ByteStream::from_static(post_upgrade_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, PLAIN_BUCKET, post_upgrade_key, None).await?.1,
|
||||||
|
post_upgrade_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -285,7 +286,10 @@ mod tests {
|
|||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
|
|
||||||
info!("📥 PutObject response - version_id: {:?}", output.version_id);
|
info!("📥 PutObject response - version_id: {:?}", output.version_id);
|
||||||
// version_id can be None or Some("null") for non-versioned buckets
|
assert!(
|
||||||
|
output.version_id().is_none() || output.version_id() == Some("null"),
|
||||||
|
"non-versioned PUT must omit version ID or return the S3 null version"
|
||||||
|
);
|
||||||
info!("✅ PASSED: PutObject works correctly without versioning");
|
info!("✅ PASSED: PutObject works correctly without versioning");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +321,11 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_result.is_ok(), "PUT operation failed");
|
assert!(put_result.is_ok(), "PUT operation failed");
|
||||||
let _version_id = put_result.unwrap().version_id;
|
let version_id = put_result
|
||||||
|
.unwrap()
|
||||||
|
.version_id()
|
||||||
|
.expect("versioned PUT should return a version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Test GET
|
// Test GET
|
||||||
info!("📥 Testing GET operation");
|
info!("📥 Testing GET operation");
|
||||||
@@ -341,16 +349,46 @@ mod tests {
|
|||||||
|
|
||||||
// Test DELETE
|
// Test DELETE
|
||||||
info!("🗑️ Testing DELETE operation");
|
info!("🗑️ Testing DELETE operation");
|
||||||
let delete_result = client.delete_object().bucket(bucket).key(key).send().await;
|
let delete_result = client
|
||||||
assert!(delete_result.is_ok(), "DELETE operation failed");
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("DELETE operation failed");
|
||||||
|
assert_eq!(delete_result.delete_marker(), Some(true));
|
||||||
|
let delete_marker_version_id = delete_result
|
||||||
|
.version_id()
|
||||||
|
.expect("versioned DELETE should return a delete marker version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Verify object is deleted (should return NoSuchKey or version marker)
|
let get_after_delete = client
|
||||||
let get_after_delete = client.get_object().bucket(bucket).key(key).send().await;
|
.get_object()
|
||||||
assert!(
|
.bucket(bucket)
|
||||||
get_after_delete.is_err() || get_after_delete.unwrap().delete_marker == Some(true),
|
.key(key)
|
||||||
"Object should be deleted or have delete marker"
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the current delete marker must hide the object");
|
||||||
|
assert_eq!(get_after_delete.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
get_after_delete.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let versions = client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("ListObjectVersions failed after DELETE");
|
||||||
|
assert_eq!(versions.versions().len(), 1);
|
||||||
|
assert_eq!(versions.versions()[0].version_id(), Some(version_id.as_str()));
|
||||||
|
assert_eq!(versions.versions()[0].is_latest(), Some(false));
|
||||||
|
assert_eq!(versions.delete_markers().len(), 1);
|
||||||
|
assert_eq!(versions.delete_markers()[0].version_id(), Some(delete_marker_version_id.as_str()));
|
||||||
|
assert_eq!(versions.delete_markers()[0].is_latest(), Some(true));
|
||||||
|
|
||||||
info!("✅ PASSED: All basic S3 operations work correctly");
|
info!("✅ PASSED: All basic S3 operations work correctly");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,31 +455,59 @@ mod tests {
|
|||||||
|
|
||||||
let client = env.create_s3_client();
|
let client = env.create_s3_client();
|
||||||
env.create_test_bucket(bucket).await?;
|
env.create_test_bucket(bucket).await?;
|
||||||
|
enable_versioning(&client, bucket).await?;
|
||||||
|
|
||||||
let key = "terraform.tfstate";
|
let key = "terraform.tfstate";
|
||||||
let response = client
|
let first_version = client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(b"v1".to_vec()))
|
.body(ByteStream::from(b"v1".to_vec()))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?
|
||||||
assert!(response.is_ok());
|
.version_id()
|
||||||
|
.ok_or("first Terraform state PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
client.delete_object().bucket(bucket).key(key).send().await?;
|
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let delete_marker = deleted
|
||||||
|
.version_id()
|
||||||
|
.ok_or("Terraform state DELETE omitted delete marker version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let response = client
|
let second_version = client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(b"v1".to_vec()))
|
.body(ByteStream::from(b"v2".to_vec()))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second Terraform state PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
assert!(response.is_ok());
|
let get_response = client.get_object().bucket(bucket).key(key).send().await?;
|
||||||
|
let current_body = get_response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(current_body.as_ref(), b"v2");
|
||||||
|
|
||||||
let get_response = client.get_object().bucket(bucket).key(key).send().await;
|
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||||
assert!(get_response.is_ok(), "Object should exist after PUT");
|
assert_eq!(listed.versions().len(), 2);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(first_version.as_str()) && version.is_latest() == Some(false))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(second_version.as_str()) && version.is_latest() == Some(true))
|
||||||
|
);
|
||||||
|
assert_eq!(listed.delete_markers().len(), 1);
|
||||||
|
assert_eq!(listed.delete_markers()[0].version_id(), Some(delete_marker.as_str()));
|
||||||
|
assert_eq!(listed.delete_markers()[0].is_latest(), Some(false));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ mod serial_tests {
|
|||||||
create_versioned_bucket(&ecstore, bucket).await;
|
create_versioned_bucket(&ecstore, bucket).await;
|
||||||
|
|
||||||
let v1 = put_versioned(&ecstore, bucket, object, &versioned_test_data(1)).await;
|
let v1 = put_versioned(&ecstore, bucket, object, &versioned_test_data(1)).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket, object).await;
|
||||||
|
|
||||||
// Wipe the object entirely on disks 1..4, leaving it on ONLY disk[0]
|
// Wipe the object entirely on disks 1..4, leaving it on ONLY disk[0]
|
||||||
// (1/4 disks < read-quorum 2). effective listing_quorum for 4 drives is
|
// (1/4 disks < read-quorum 2). effective listing_quorum for 4 drives is
|
||||||
@@ -387,6 +388,7 @@ mod serial_tests {
|
|||||||
|
|
||||||
let data_v1 = versioned_test_data(9);
|
let data_v1 = versioned_test_data(9);
|
||||||
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket, object).await;
|
||||||
|
|
||||||
// EC4+4: wipe the object ENTIRELY on 4 disks, leaving full copies on the
|
// EC4+4: wipe the object ENTIRELY on 4 disks, leaving full copies on the
|
||||||
// other 4 (== data_blocks). Meta quorum (4) still holds, so this heals via
|
// other 4 (== data_blocks). Meta quorum (4) still holds, so this heals via
|
||||||
@@ -500,6 +502,7 @@ mod serial_tests {
|
|||||||
|
|
||||||
let data_v1 = versioned_test_data(11);
|
let data_v1 = versioned_test_data(11);
|
||||||
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket, object).await;
|
||||||
|
|
||||||
// Drop the object entirely on ONE disk (its shard + meta gone), leaving it
|
// Drop the object entirely on ONE disk (its shard + meta gone), leaving it
|
||||||
// on 3/4 (>= data_blocks 2). The union must still enumerate it.
|
// on 3/4 (>= data_blocks 2). The union must still enumerate it.
|
||||||
@@ -545,6 +548,7 @@ mod serial_tests {
|
|||||||
|
|
||||||
let data_v1 = versioned_test_data(12);
|
let data_v1 = versioned_test_data(12);
|
||||||
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket, object).await;
|
||||||
|
|
||||||
std::fs::remove_dir_all(object_dir(&disk_paths[3], bucket, object)).expect("wipe object on disk3");
|
std::fs::remove_dir_all(object_dir(&disk_paths[3], bucket, object)).expect("wipe object on disk3");
|
||||||
for disk in &disk_paths[..3] {
|
for disk in &disk_paths[..3] {
|
||||||
|
|||||||
@@ -56,6 +56,32 @@ async fn wait_for_path_exists(path: &Path, timeout: Duration, interval: Duration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_part_file(obj_dir: &Path) -> Option<PathBuf> {
|
||||||
|
WalkDir::new(obj_dir)
|
||||||
|
.min_depth(2)
|
||||||
|
.max_depth(2)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.find(|entry| entry.file_type().is_file() && entry.file_name().to_str().is_some_and(|name| name.starts_with("part.")))
|
||||||
|
.map(|entry| entry.into_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_object_copies(disks: &[PathBuf], bucket: &str, object: &str) {
|
||||||
|
tokio::time::timeout(HEAL_FORMAT_WAIT_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
if disks.iter().all(|disk| {
|
||||||
|
let obj_dir = disk.join(bucket).join(object);
|
||||||
|
obj_dir.join("xl.meta").exists() && find_part_file(&obj_dir).is_some()
|
||||||
|
}) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(HEAL_FORMAT_WAIT_INTERVAL).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("PUT rename tails must converge before corrupting the disk fixture");
|
||||||
|
}
|
||||||
|
|
||||||
/// Test helper: build the shared 4-disk temp-dir ECStore environment
|
/// Test helper: build the shared 4-disk temp-dir ECStore environment
|
||||||
/// (rustfs-test-utils, backlog#1153 infra-1) and wrap it in the heal storage
|
/// (rustfs-test-utils, backlog#1153 infra-1) and wrap it in the heal storage
|
||||||
/// layer. Port 0 + uuid temp dirs keep this parallel-safe under nextest.
|
/// layer. Port 0 + uuid temp dirs keep this parallel-safe under nextest.
|
||||||
@@ -103,18 +129,10 @@ mod serial_tests {
|
|||||||
|
|
||||||
create_test_bucket(&ecstore, bucket_name).await;
|
create_test_bucket(&ecstore, bucket_name).await;
|
||||||
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
||||||
let _obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
wait_for_object_copies(&disk_paths, bucket_name, object_name).await;
|
||||||
// ─── 1️⃣ delete single data shard file ─────────────────────────────────────
|
// ─── 1️⃣ delete single data shard file ─────────────────────────────────────
|
||||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||||
// find part file at depth 2, e.g. .../<uuid>/part.1
|
let target_part = find_part_file(&obj_dir).expect("converged fixture must contain a part file");
|
||||||
let target_part = WalkDir::new(&obj_dir)
|
|
||||||
.min_depth(2)
|
|
||||||
.max_depth(2)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
|
||||||
.map(|e| e.into_path())
|
|
||||||
.expect("Failed to locate part file to delete");
|
|
||||||
|
|
||||||
std::fs::remove_file(&target_part).expect("failed to delete part file");
|
std::fs::remove_file(&target_part).expect("failed to delete part file");
|
||||||
assert!(!target_part.exists());
|
assert!(!target_part.exists());
|
||||||
@@ -171,6 +189,7 @@ mod serial_tests {
|
|||||||
|
|
||||||
create_test_bucket(&ecstore, bucket_name).await;
|
create_test_bucket(&ecstore, bucket_name).await;
|
||||||
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket_name, object_name).await;
|
||||||
|
|
||||||
// Plant a leaked, unreferenced UUID data dir under the object on every disk
|
// Plant a leaked, unreferenced UUID data dir under the object on every disk
|
||||||
// that actually holds the object (i.e. has an `xl.meta`). Planting on a disk
|
// that actually holds the object (i.e. has an `xl.meta`). Planting on a disk
|
||||||
@@ -381,15 +400,9 @@ mod serial_tests {
|
|||||||
|
|
||||||
create_test_bucket(&ecstore, bucket_name).await;
|
create_test_bucket(&ecstore, bucket_name).await;
|
||||||
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
upload_test_object(&ecstore, bucket_name, object_name, &test_data).await;
|
||||||
|
wait_for_object_copies(&disk_paths, bucket_name, object_name).await;
|
||||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||||
let target_part = WalkDir::new(&obj_dir)
|
let target_part = find_part_file(&obj_dir).expect("converged fixture must contain a part file");
|
||||||
.min_depth(2)
|
|
||||||
.max_depth(2)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
|
||||||
.map(|e| e.into_path())
|
|
||||||
.expect("Failed to locate part file to delete");
|
|
||||||
|
|
||||||
// ─── 1️⃣ delete format.json on one disk ──────────────
|
// ─── 1️⃣ delete format.json on one disk ──────────────
|
||||||
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
||||||
|
|||||||
+100
-4
@@ -2273,6 +2273,7 @@ pub(crate) fn test_config(id: &str) -> OidcProviderConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||||
use rustfs_utils::egress::OutboundDnsPolicyRejection;
|
use rustfs_utils::egress::OutboundDnsPolicyRejection;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
@@ -2641,12 +2642,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_mock_oidc_discovery_server<F>(
|
fn start_mock_oidc_discovery_server_with_jwks<F, J>(
|
||||||
build_discovery_issuer: F,
|
build_discovery_issuer: F,
|
||||||
max_requests: usize,
|
max_requests: usize,
|
||||||
|
signing_alg: &'static str,
|
||||||
|
jwks_response: J,
|
||||||
) -> Option<(String, std::thread::JoinHandle<()>)>
|
) -> Option<(String, std::thread::JoinHandle<()>)>
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> (String, String, String) + Send + 'static,
|
F: Fn(&str) -> (String, String, String) + Send + 'static,
|
||||||
|
J: Fn(usize) -> String + Send + 'static,
|
||||||
{
|
{
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::{Shutdown, TcpListener};
|
use std::net::{Shutdown, TcpListener};
|
||||||
@@ -2674,10 +2678,9 @@ mod tests {
|
|||||||
"response_types_supported": ["code"],
|
"response_types_supported": ["code"],
|
||||||
"response_modes_supported": ["query"],
|
"response_modes_supported": ["query"],
|
||||||
"subject_types_supported": ["public"],
|
"subject_types_supported": ["public"],
|
||||||
"id_token_signing_alg_values_supported": ["RS256"],
|
"id_token_signing_alg_values_supported": [signing_alg],
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let jwks_body = r#"{"keys":[]}"#;
|
|
||||||
let (ready_tx, ready_rx) = mpsc::channel();
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
|
|
||||||
let handle = std::thread::spawn(move || {
|
let handle = std::thread::spawn(move || {
|
||||||
@@ -2687,6 +2690,7 @@ mod tests {
|
|||||||
let _ = ready_tx.send(());
|
let _ = ready_tx.send(());
|
||||||
|
|
||||||
let mut seen = 0usize;
|
let mut seen = 0usize;
|
||||||
|
let mut jwks_fetches = 0usize;
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut last_completed = Instant::now();
|
let mut last_completed = Instant::now();
|
||||||
|
|
||||||
@@ -2719,7 +2723,11 @@ mod tests {
|
|||||||
.expect("failed to set discovery mock read timeout");
|
.expect("failed to set discovery mock read timeout");
|
||||||
|
|
||||||
let path = read_mock_oidc_request_path(&mut stream);
|
let path = read_mock_oidc_request_path(&mut stream);
|
||||||
let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, jwks_body);
|
let jwks_body = jwks_response(jwks_fetches);
|
||||||
|
if path == expected_jwks_path {
|
||||||
|
jwks_fetches += 1;
|
||||||
|
}
|
||||||
|
let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, &jwks_body);
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.flush();
|
let _ = stream.flush();
|
||||||
let _ = stream.shutdown(Shutdown::Both);
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
@@ -2737,6 +2745,94 @@ mod tests {
|
|||||||
Some((base, handle))
|
Some((base, handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_mock_oidc_discovery_server<F>(
|
||||||
|
build_discovery_issuer: F,
|
||||||
|
max_requests: usize,
|
||||||
|
) -> Option<(String, std::thread::JoinHandle<()>)>
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> (String, String, String) + Send + 'static,
|
||||||
|
{
|
||||||
|
start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", |_| {
|
||||||
|
r#"{"keys":[]}"#.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oidc_es256_key_and_jwk(kid: &str) -> (EncodingKey, serde_json::Value) {
|
||||||
|
let certified =
|
||||||
|
rcgen::generate_simple_self_signed(vec![format!("{kid}.invalid")]).expect("OIDC signing key should generate");
|
||||||
|
let encoding_key =
|
||||||
|
EncodingKey::from_ec_pem(certified.signing_key.serialize_pem().as_bytes()).expect("OIDC signing key should encode");
|
||||||
|
let mut jwk = jsonwebtoken::jwk::Jwk::from_encoding_key(&encoding_key, Algorithm::ES256)
|
||||||
|
.expect("OIDC public JWK should derive from signing key");
|
||||||
|
jwk.common.key_id = Some(kid.to_string());
|
||||||
|
jwk.common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Signature);
|
||||||
|
(encoding_key, serde_json::to_value(jwk).expect("OIDC JWK should serialize"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn web_identity_verification_refreshes_rotated_jwks() {
|
||||||
|
let (_, initial_jwk) = oidc_es256_key_and_jwk("initial");
|
||||||
|
let (rotated_key, rotated_jwk) = oidc_es256_key_and_jwk("rotated");
|
||||||
|
let initial_jwks = serde_json::json!({ "keys": [initial_jwk] }).to_string();
|
||||||
|
let rotated_jwks = serde_json::json!({ "keys": [rotated_jwk] }).to_string();
|
||||||
|
let Some((base, handle)) = start_mock_oidc_discovery_server_with_jwks(
|
||||||
|
|base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()),
|
||||||
|
4,
|
||||||
|
"ES256",
|
||||||
|
move |fetch| {
|
||||||
|
if fetch == 0 {
|
||||||
|
initial_jwks.clone()
|
||||||
|
} else {
|
||||||
|
rotated_jwks.clone()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = build_mocked_oidc_provider_config("rotating", &base);
|
||||||
|
let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback origin should be allowed");
|
||||||
|
let http_client = ReqwestHttpClient::with_policy(policy);
|
||||||
|
let state = OidcSys::discover_provider(&config, &http_client)
|
||||||
|
.await
|
||||||
|
.expect("initial OIDC discovery should succeed");
|
||||||
|
let sys = OidcSys {
|
||||||
|
configs: HashMap::from([(config.id.clone(), config.clone())]),
|
||||||
|
provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])),
|
||||||
|
state_store: OidcStateStore::new(),
|
||||||
|
http_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("system clock should be after Unix epoch")
|
||||||
|
.as_secs();
|
||||||
|
let mut header = Header::new(Algorithm::ES256);
|
||||||
|
header.kid = Some("rotated".to_string());
|
||||||
|
let token = jsonwebtoken::encode(
|
||||||
|
&header,
|
||||||
|
&serde_json::json!({
|
||||||
|
"iss": base,
|
||||||
|
"sub": "rotated-user",
|
||||||
|
"aud": config.client_id,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 300,
|
||||||
|
"groups": ["readwrite"],
|
||||||
|
}),
|
||||||
|
&rotated_key,
|
||||||
|
)
|
||||||
|
.expect("rotated OIDC token should sign");
|
||||||
|
|
||||||
|
let (claims, provider_id) = sys
|
||||||
|
.verify_web_identity_token(&token)
|
||||||
|
.await
|
||||||
|
.expect("verification should refresh JWKS and accept the rotated key");
|
||||||
|
assert_eq!(provider_id, "rotating");
|
||||||
|
assert_eq!(claims.sub, "rotated-user");
|
||||||
|
assert_eq!(claims.groups, vec!["readwrite"]);
|
||||||
|
handle.join().expect("rotating JWKS mock server should exit cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
fn start_mock_oidc_tls_discovery_server<F>(
|
fn start_mock_oidc_tls_discovery_server<F>(
|
||||||
build_discovery_issuer: F,
|
build_discovery_issuer: F,
|
||||||
max_requests: usize,
|
max_requests: usize,
|
||||||
|
|||||||
@@ -68,10 +68,18 @@ FUZZ_TARGET=path_containment ./scripts/fuzz/run.sh
|
|||||||
# Nightly-style: 300s per target
|
# Nightly-style: 300s per target
|
||||||
MAX_TOTAL_TIME=300 ./scripts/fuzz/run.sh
|
MAX_TOTAL_TIME=300 ./scripts/fuzz/run.sh
|
||||||
|
|
||||||
|
# Replay a recorded libFuzzer seed
|
||||||
|
FUZZ_TARGET=path_containment FUZZ_SEED=123456789 ./scripts/fuzz/run.sh
|
||||||
|
|
||||||
# Skip build (use pre-built harness)
|
# Skip build (use pre-built harness)
|
||||||
SKIP_BUILD=1 FUZZ_TARGET=local_metadata ./scripts/fuzz/run.sh
|
SKIP_BUILD=1 FUZZ_TARGET=local_metadata ./scripts/fuzz/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each run writes `fuzz/artifacts/<target>/run-manifest.txt` with the target,
|
||||||
|
libFuzzer seed, time budget, Git revision and dirty state, and runner mode. CI
|
||||||
|
uploads that manifest with the corpus and any crash input so the exact run can
|
||||||
|
be replayed.
|
||||||
|
|
||||||
## CI Workflow
|
## CI Workflow
|
||||||
|
|
||||||
The GitHub Actions workflow (`.github/workflows/fuzz.yml`) uses a **build/run separation** pattern:
|
The GitHub Actions workflow (`.github/workflows/fuzz.yml`) uses a **build/run separation** pattern:
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ pub struct ContainerResources {
|
|||||||
pub cgroup_detected: bool,
|
pub cgroup_detected: bool,
|
||||||
/// Whether values were overridden by environment variables.
|
/// Whether values were overridden by environment variables.
|
||||||
pub overridden: bool,
|
pub overridden: bool,
|
||||||
|
/// Pre-computed basis string for metrics ("cgroup" or "host").
|
||||||
|
pub basis: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ContainerResources {
|
impl Default for ContainerResources {
|
||||||
@@ -71,6 +73,7 @@ impl Default for ContainerResources {
|
|||||||
memory_bytes: 0,
|
memory_bytes: 0,
|
||||||
cgroup_detected: false,
|
cgroup_detected: false,
|
||||||
overridden: false,
|
overridden: false,
|
||||||
|
basis: "host",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +208,7 @@ mod cgroup {
|
|||||||
/// Detection priority:
|
/// Detection priority:
|
||||||
/// 1. Environment variable overrides
|
/// 1. Environment variable overrides
|
||||||
/// 2. cgroup v1/v2 limits (Linux only)
|
/// 2. cgroup v1/v2 limits (Linux only)
|
||||||
/// 3. Host values from sysinfo
|
/// 3. Host values from sysinfo (single System instance for both CPU and memory)
|
||||||
fn detect_container_resources() -> ContainerResources {
|
fn detect_container_resources() -> ContainerResources {
|
||||||
// Check if cgroup detection is disabled
|
// Check if cgroup detection is disabled
|
||||||
let cgroup_disabled = std::env::var(ENV_DISABLE_CGROUP_DETECTION)
|
let cgroup_disabled = std::env::var(ENV_DISABLE_CGROUP_DETECTION)
|
||||||
@@ -234,30 +237,25 @@ fn detect_container_resources() -> ContainerResources {
|
|||||||
|
|
||||||
let overridden = override_cores.is_some() || override_memory.is_some();
|
let overridden = override_cores.is_some() || override_memory.is_some();
|
||||||
|
|
||||||
// Get host values for fallback
|
// Get host values from a single sysinfo::System instance (avoids double init)
|
||||||
let host_cores = {
|
let (host_cores, host_memory) = {
|
||||||
let mut sys =
|
let mut sys = sysinfo::System::new_with_specifics(sysinfo::RefreshKind::everything().without_processes());
|
||||||
sysinfo::System::new_with_specifics(sysinfo::RefreshKind::everything().without_memory().without_processes());
|
|
||||||
sys.refresh_cpu_all();
|
sys.refresh_cpu_all();
|
||||||
sys.cpus().len().max(1)
|
|
||||||
};
|
|
||||||
|
|
||||||
let host_memory = {
|
|
||||||
let mut sys = sysinfo::System::new();
|
|
||||||
sys.refresh_memory();
|
sys.refresh_memory();
|
||||||
sys.total_memory()
|
(sys.cpus().len().max(1), sys.total_memory())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine effective values: override > cgroup > host
|
// Determine effective values: override > cgroup > host
|
||||||
let cpu_cores = override_cores.or(cgroup_cpus).unwrap_or(host_cores).max(1);
|
let cpu_cores = override_cores.or(cgroup_cpus).unwrap_or(host_cores).max(1);
|
||||||
|
|
||||||
let memory_bytes = override_memory.or(cgroup_memory).unwrap_or(host_memory);
|
let memory_bytes = override_memory.or(cgroup_memory).unwrap_or(host_memory);
|
||||||
|
let basis = if cgroup_detected { "cgroup" } else { "host" };
|
||||||
|
|
||||||
ContainerResources {
|
ContainerResources {
|
||||||
cpu_cores,
|
cpu_cores,
|
||||||
memory_bytes,
|
memory_bytes,
|
||||||
cgroup_detected,
|
cgroup_detected,
|
||||||
overridden,
|
overridden,
|
||||||
|
basis,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,12 +271,13 @@ pub fn container_resources() -> &'static ContainerResources {
|
|||||||
/// Should be called once during startup to help operators verify detection.
|
/// Should be called once during startup to help operators verify detection.
|
||||||
pub fn log_container_resources() {
|
pub fn log_container_resources() {
|
||||||
let res = container_resources();
|
let res = container_resources();
|
||||||
|
let memory_mib = res.memory_bytes / (1024 * 1024);
|
||||||
|
|
||||||
if res.overridden {
|
if res.overridden {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
cpu_cores = res.cpu_cores,
|
cpu_cores = res.cpu_cores,
|
||||||
memory_bytes = res.memory_bytes,
|
memory_bytes = res.memory_bytes,
|
||||||
memory_mib = res.memory_bytes / (1024 * 1024),
|
memory_mib,
|
||||||
cgroup_detected = res.cgroup_detected,
|
cgroup_detected = res.cgroup_detected,
|
||||||
"container resources (overridden by environment variables)"
|
"container resources (overridden by environment variables)"
|
||||||
);
|
);
|
||||||
@@ -286,7 +285,7 @@ pub fn log_container_resources() {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
cpu_cores = res.cpu_cores,
|
cpu_cores = res.cpu_cores,
|
||||||
memory_bytes = res.memory_bytes,
|
memory_bytes = res.memory_bytes,
|
||||||
memory_mib = res.memory_bytes / (1024 * 1024),
|
memory_mib,
|
||||||
"container resources (detected from cgroup)"
|
"container resources (detected from cgroup)"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -298,12 +297,6 @@ pub fn log_container_resources() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the memory basis string for metrics.
|
|
||||||
pub fn memory_basis() -> &'static str {
|
|
||||||
let res = container_resources();
|
|
||||||
if res.cgroup_detected { "cgroup" } else { "host" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -319,6 +312,7 @@ mod tests {
|
|||||||
assert_eq!(resources.memory_bytes, 0);
|
assert_eq!(resources.memory_bytes, 0);
|
||||||
assert!(!resources.cgroup_detected);
|
assert!(!resources.cgroup_detected);
|
||||||
assert!(!resources.overridden);
|
assert!(!resources.overridden);
|
||||||
|
assert_eq!(resources.basis, "host");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -63,16 +64,37 @@ impl HeartbeatConfig {
|
|||||||
credential_store: CredentialStore,
|
credential_store: CredentialStore,
|
||||||
state_path: impl Into<PathBuf>,
|
state_path: impl Into<PathBuf>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let state_path = state_path.into();
|
||||||
Self {
|
Self {
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
root_ca_pem: root_ca_pem.into(),
|
root_ca_pem: root_ca_pem.into(),
|
||||||
identity_store,
|
identity_store,
|
||||||
credential_store,
|
credential_store,
|
||||||
state_path: state_path.into(),
|
state_path,
|
||||||
schedule: HeartbeatSchedule::default(),
|
schedule: HeartbeatSchedule::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
pub(crate) fn state_only(state_root: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
endpoint: String::new(),
|
||||||
|
root_ca_pem: Vec::new(),
|
||||||
|
identity_store: IdentityStore::new(state_root.join("identity")),
|
||||||
|
credential_store: CredentialStore::new(state_root.join("credential")),
|
||||||
|
state_path: state_root.join("heartbeat/state.json"),
|
||||||
|
schedule: HeartbeatSchedule::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transport_enabled(&self) -> bool {
|
||||||
|
!self.endpoint.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn state_root(&self) -> Option<&std::path::Path> {
|
||||||
|
self.state_path.parent().and_then(std::path::Path::parent)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Result<Option<Self>, HeartbeatConfigError> {
|
pub fn from_env() -> Result<Option<Self>, HeartbeatConfigError> {
|
||||||
Self::from_env_values(
|
Self::from_env_values(
|
||||||
env::var_os(ENV_CONNECT_ENDPOINT),
|
env::var_os(ENV_CONNECT_ENDPOINT),
|
||||||
@@ -90,19 +112,33 @@ impl HeartbeatConfig {
|
|||||||
if !configured {
|
if !configured {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let (Some(endpoint), Some(root_ca_file), Some(state_dir)) = (endpoint, root_ca_file, state_dir) else {
|
let Some(state_dir) = state_dir else {
|
||||||
return Err(HeartbeatConfigError::Partial);
|
return Err(HeartbeatConfigError::Partial);
|
||||||
};
|
};
|
||||||
let endpoint = endpoint.into_string().map_err(|_| HeartbeatConfigError::EndpointEncoding)?;
|
|
||||||
let root_ca_file = PathBuf::from(root_ca_file);
|
|
||||||
let state_dir = PathBuf::from(state_dir);
|
let state_dir = PathBuf::from(state_dir);
|
||||||
if endpoint.is_empty() || root_ca_file.as_os_str().is_empty() || state_dir.as_os_str().is_empty() {
|
if state_dir.as_os_str().is_empty() || endpoint.is_some() != root_ca_file.is_some() {
|
||||||
return Err(HeartbeatConfigError::Partial);
|
return Err(HeartbeatConfigError::Partial);
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
return Err(HeartbeatConfigError::PlatformSecurity);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let (Some(endpoint), Some(root_ca_file)) = (endpoint, root_ca_file) else {
|
||||||
|
return Ok(Some(Self::state_only(state_dir)));
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let endpoint = endpoint.into_string().map_err(|_| HeartbeatConfigError::EndpointEncoding)?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let root_ca_file = PathBuf::from(root_ca_file);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if endpoint.is_empty() || root_ca_file.as_os_str().is_empty() {
|
||||||
|
return Err(HeartbeatConfigError::Partial);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
let root_ca_pem = fs::read(&root_ca_file).map_err(|source| HeartbeatConfigError::RootCertificate {
|
let root_ca_pem = fs::read(&root_ca_file).map_err(|source| HeartbeatConfigError::RootCertificate {
|
||||||
path: root_ca_file,
|
path: root_ca_file,
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
Ok(Some(Self::new(
|
Ok(Some(Self::new(
|
||||||
endpoint,
|
endpoint,
|
||||||
root_ca_pem,
|
root_ca_pem,
|
||||||
@@ -116,17 +152,19 @@ impl HeartbeatConfig {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum HeartbeatConfigError {
|
pub enum HeartbeatConfigError {
|
||||||
#[error(
|
#[error(
|
||||||
"Connect heartbeat configuration requires RUSTFS_CONNECT_ENDPOINT, RUSTFS_CONNECT_ROOT_CA_FILE, and RUSTFS_CONNECT_STATE_DIR"
|
"Connect requires RUSTFS_CONNECT_STATE_DIR and either both or neither of RUSTFS_CONNECT_ENDPOINT and RUSTFS_CONNECT_ROOT_CA_FILE"
|
||||||
)]
|
)]
|
||||||
Partial,
|
Partial,
|
||||||
#[error("RUSTFS_CONNECT_ENDPOINT is not valid UTF-8")]
|
#[error("RUSTFS_CONNECT_ENDPOINT is not valid UTF-8")]
|
||||||
EndpointEncoding,
|
EndpointEncoding,
|
||||||
#[error("failed to read the Connect root CA at {path}: {source}")]
|
#[error("Connect root CA could not be read")]
|
||||||
RootCertificate {
|
RootCertificate {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
#[source]
|
#[source]
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
#[error("Connect inventory persistence requires Linux filesystem security guarantees")]
|
||||||
|
PlatformSecurity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -149,9 +187,34 @@ mod tests {
|
|||||||
HeartbeatConfig::from_env_values(Some(OsString::from("https://connect.example/agent/")), None, None),
|
HeartbeatConfig::from_env_values(Some(OsString::from("https://connect.example/agent/")), None, None),
|
||||||
Err(HeartbeatConfigError::Partial)
|
Err(HeartbeatConfigError::Partial)
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
HeartbeatConfig::from_env_values(
|
||||||
|
Some(OsString::from("https://connect.example/agent/")),
|
||||||
|
Some(OsString::from("root.pem")),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Err(HeartbeatConfigError::Partial)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
HeartbeatConfig::from_env_values(None, Some(OsString::from("root.pem")), Some(OsString::from("state"))),
|
||||||
|
Err(HeartbeatConfigError::Partial)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn state_directory_alone_enables_local_inventory_without_transport() {
|
||||||
|
let state = tempfile::tempdir().expect("tempdir").keep();
|
||||||
|
let config = HeartbeatConfig::from_env_values(None, None, Some(state.clone().into_os_string()))
|
||||||
|
.expect("state-only config")
|
||||||
|
.expect("enabled config");
|
||||||
|
|
||||||
|
assert_eq!(config.state_root(), Some(state.as_path()));
|
||||||
|
assert!(!config.transport_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn complete_environment_builds_the_durable_paths() {
|
fn complete_environment_builds_the_durable_paths() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
let root = temp.path().join("root.pem");
|
let root = temp.path().join("root.pem");
|
||||||
@@ -168,6 +231,24 @@ mod tests {
|
|||||||
assert_eq!(config.endpoint, "https://connect.example/agent/");
|
assert_eq!(config.endpoint, "https://connect.example/agent/");
|
||||||
assert_eq!(config.root_ca_pem, b"root certificate");
|
assert_eq!(config.root_ca_pem, b"root certificate");
|
||||||
assert_eq!(config.state_path, state.join("heartbeat/state.json"));
|
assert_eq!(config.state_path, state.join("heartbeat/state.json"));
|
||||||
|
assert_eq!(config.state_root(), Some(state.as_path()));
|
||||||
assert!(!state.exists(), "parsing configuration must not create state");
|
assert!(!state.exists(), "parsing configuration must not create state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn configured_inventory_fails_without_linux_filesystem_guarantees() {
|
||||||
|
assert!(matches!(
|
||||||
|
HeartbeatConfig::from_env_values(None, None, Some(OsString::from("state"))),
|
||||||
|
Err(HeartbeatConfigError::PlatformSecurity)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
HeartbeatConfig::from_env_values(
|
||||||
|
Some(OsString::from("https://connect.example/agent/")),
|
||||||
|
Some(OsString::from("missing-root.pem")),
|
||||||
|
Some(OsString::from("state")),
|
||||||
|
),
|
||||||
|
Err(HeartbeatConfigError::PlatformSecurity)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1319
-273
File diff suppressed because it is too large
Load Diff
+163
-31
@@ -32,7 +32,6 @@ pub struct HeartbeatRuntime {
|
|||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
status: watch::Receiver<HeartbeatStatus>,
|
status: watch::Receiver<HeartbeatStatus>,
|
||||||
task: Option<JoinHandle<()>>,
|
task: Option<JoinHandle<()>>,
|
||||||
inventory: Option<InventoryRuntime>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HeartbeatRuntime {
|
impl HeartbeatRuntime {
|
||||||
@@ -40,22 +39,11 @@ impl HeartbeatRuntime {
|
|||||||
self.status.clone()
|
self.status.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_inventory(mut self, inventory: Option<InventoryRuntime>) -> Self {
|
|
||||||
self.inventory = inventory;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(mut self) {
|
pub async fn shutdown(mut self) {
|
||||||
self.shutdown.cancel();
|
self.shutdown.cancel();
|
||||||
if let Some(inventory) = self.inventory.as_ref() {
|
|
||||||
inventory.shutdown.cancel();
|
|
||||||
}
|
|
||||||
if let Some(task) = self.task.take() {
|
if let Some(task) = self.task.take() {
|
||||||
let _ = task.await;
|
let _ = task.await;
|
||||||
}
|
}
|
||||||
if let Some(inventory) = self.inventory.take() {
|
|
||||||
inventory.shutdown().await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +78,20 @@ impl Drop for InventoryRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn shutdown_connect_runtimes(heartbeat: Option<HeartbeatRuntime>, inventory: Option<InventoryRuntime>) {
|
||||||
|
let heartbeat = async move {
|
||||||
|
if let Some(runtime) = heartbeat {
|
||||||
|
runtime.shutdown().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let inventory = async move {
|
||||||
|
if let Some(runtime) = inventory {
|
||||||
|
runtime.shutdown().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::join!(heartbeat, inventory);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_heartbeat_runtime<F>(
|
pub fn spawn_heartbeat_runtime<F>(
|
||||||
config: Option<HeartbeatConfig>,
|
config: Option<HeartbeatConfig>,
|
||||||
parent_shutdown: &CancellationToken,
|
parent_shutdown: &CancellationToken,
|
||||||
@@ -101,6 +103,9 @@ where
|
|||||||
let Some(config) = config else {
|
let Some(config) = config else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
if !config.transport_enabled() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let sender = HeartbeatSender::new(config.clone())?;
|
let sender = HeartbeatSender::new(config.clone())?;
|
||||||
let store = HeartbeatStateStore::new(config.state_path.clone());
|
let store = HeartbeatStateStore::new(config.state_path.clone());
|
||||||
let lock = store.try_runtime_lock()?;
|
let lock = store.try_runtime_lock()?;
|
||||||
@@ -163,7 +168,6 @@ where
|
|||||||
shutdown,
|
shutdown,
|
||||||
status: status_rx,
|
status: status_rx,
|
||||||
task: Some(task),
|
task: Some(task),
|
||||||
inventory: None,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +188,14 @@ where
|
|||||||
return Err(InventoryError::Schedule);
|
return Err(InventoryError::Schedule);
|
||||||
}
|
}
|
||||||
let retry_schedule = config.schedule;
|
let retry_schedule = config.schedule;
|
||||||
let store = InventoryStateStore::from_heartbeat_path(&config.state_path)?;
|
let state_root = config.state_root().ok_or(InventoryError::StatePath)?;
|
||||||
|
let store = InventoryStateStore::from_state_root(state_root)?;
|
||||||
let lock = store.try_runtime_lock()?;
|
let lock = store.try_runtime_lock()?;
|
||||||
let sender = InventorySender::new(config)?;
|
let sender = if config.transport_enabled() {
|
||||||
|
Some(InventorySender::new(config)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let shutdown = parent_shutdown.child_token();
|
let shutdown = parent_shutdown.child_token();
|
||||||
let task_shutdown = shutdown.clone();
|
let task_shutdown = shutdown.clone();
|
||||||
let (status_tx, status_rx) = watch::channel(InventoryStatus::Starting);
|
let (status_tx, status_rx) = watch::channel(InventoryStatus::Starting);
|
||||||
@@ -197,8 +206,19 @@ where
|
|||||||
if task_shutdown.is_cancelled() {
|
if task_shutdown.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let pending = match store.pending().await {
|
let pending = match if sender.is_some() { store.pending().await } else { Ok(None) } {
|
||||||
Ok(Some(pending)) => pending,
|
Ok(Some((pending, captured_at))) => {
|
||||||
|
if let Err(error) = store
|
||||||
|
.ensure_latest(pending.snapshot().clone(), captured_at, task_shutdown.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if matches!(error, InventoryError::Cancelled) && task_shutdown.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return failed_inventory(&status_tx, error);
|
||||||
|
}
|
||||||
|
pending
|
||||||
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let snapshot = match cancellable(&task_shutdown, sample()).await {
|
let snapshot = match cancellable(&task_shutdown, sample()).await {
|
||||||
Some(Ok(snapshot)) => snapshot,
|
Some(Ok(snapshot)) => snapshot,
|
||||||
@@ -218,6 +238,27 @@ where
|
|||||||
Ok(content_hash) => content_hash,
|
Ok(content_hash) => content_hash,
|
||||||
Err(error) => return failed_inventory(&status_tx, error),
|
Err(error) => return failed_inventory(&status_tx, error),
|
||||||
};
|
};
|
||||||
|
let captured_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
if let Err(error) = store
|
||||||
|
.publish_latest(snapshot.clone(), captured_at, task_shutdown.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if matches!(error, InventoryError::Cancelled) && task_shutdown.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return failed_inventory(&status_tx, error);
|
||||||
|
}
|
||||||
|
if task_shutdown.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if sender.is_none() {
|
||||||
|
backoff = retry_schedule.initial_backoff;
|
||||||
|
let _ = status_tx.send(InventoryStatus::Unchanged { content_hash });
|
||||||
|
if sleep_or_cancel(&task_shutdown, schedule.cadence.saturating_add(jitter(schedule.jitter))).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match store.prepare(snapshot).await {
|
match store.prepare(snapshot).await {
|
||||||
Ok(Some(pending)) => pending,
|
Ok(Some(pending)) => pending,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
@@ -233,7 +274,15 @@ where
|
|||||||
}
|
}
|
||||||
Err(error) => return failed_inventory(&status_tx, error),
|
Err(error) => return failed_inventory(&status_tx, error),
|
||||||
};
|
};
|
||||||
let delivery = match cancellable(&task_shutdown, sender.send(&pending)).await {
|
let delivery = match cancellable(
|
||||||
|
&task_shutdown,
|
||||||
|
sender
|
||||||
|
.as_ref()
|
||||||
|
.expect("sender exists when delivery state is prepared")
|
||||||
|
.send(&pending),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Some(Ok(delivery)) => delivery,
|
Some(Ok(delivery)) => delivery,
|
||||||
Some(Err(error)) => return failed_inventory(&status_tx, error),
|
Some(Err(error)) => return failed_inventory(&status_tx, error),
|
||||||
None => break,
|
None => break,
|
||||||
@@ -261,14 +310,13 @@ where
|
|||||||
let _ = status_tx.send(InventoryStatus::BackingOff { delay });
|
let _ = status_tx.send(InventoryStatus::BackingOff { delay });
|
||||||
delay
|
delay
|
||||||
}
|
}
|
||||||
InventoryDelivery::AuthenticationStopped { status, reason } => {
|
InventoryDelivery::AuthenticationStopped { status } => {
|
||||||
let _ = status_tx.send(InventoryStatus::AuthenticationStopped { status, reason });
|
let _ = status_tx.send(InventoryStatus::AuthenticationStopped { status, reason: None });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
InventoryDelivery::Rejected { status, reason } => {
|
InventoryDelivery::Rejected { status } => {
|
||||||
let suffix = reason.map_or_else(String::new, |reason| format!("; reason={reason}"));
|
|
||||||
let _ = status_tx.send(InventoryStatus::Failed {
|
let _ = status_tx.send(InventoryStatus::Failed {
|
||||||
reason: format!("Connect rejected inventory with HTTP {status}{suffix}"),
|
reason: format!("connect_inventory_rejected_http_{status}"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -292,6 +340,46 @@ fn failed(status: &watch::Sender<HeartbeatStatus>, error: HeartbeatError) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn heartbeat_failure_reason(error: &HeartbeatError) -> &'static str {
|
||||||
|
use super::registration::CredentialValidationError;
|
||||||
|
|
||||||
|
match error {
|
||||||
|
HeartbeatError::Endpoint => "connect_heartbeat_endpoint",
|
||||||
|
HeartbeatError::RootCertificate => "connect_heartbeat_root_certificate",
|
||||||
|
HeartbeatError::Schedule => "connect_heartbeat_schedule",
|
||||||
|
HeartbeatError::NotRegistered => "connect_heartbeat_not_registered",
|
||||||
|
HeartbeatError::IdentityMissing => "connect_heartbeat_identity_missing",
|
||||||
|
HeartbeatError::IdentityCertificate => "connect_heartbeat_identity_certificate",
|
||||||
|
HeartbeatError::CredentialName => "connect_heartbeat_credential_name",
|
||||||
|
HeartbeatError::CredentialExpired => "connect_heartbeat_credential_expired",
|
||||||
|
HeartbeatError::NodeSummary => "connect_heartbeat_node_summary",
|
||||||
|
HeartbeatError::SequenceExhausted => "connect_heartbeat_sequence_exhausted",
|
||||||
|
HeartbeatError::AlreadyRunning => "connect_heartbeat_already_running",
|
||||||
|
HeartbeatError::StateConflict => "connect_heartbeat_state_conflict",
|
||||||
|
HeartbeatError::StateIo { .. } => "connect_heartbeat_state_io",
|
||||||
|
HeartbeatError::StateInvalid { .. } => "connect_heartbeat_state_invalid",
|
||||||
|
HeartbeatError::StateCorrupt { .. } => "connect_heartbeat_state_corrupt",
|
||||||
|
#[cfg(unix)]
|
||||||
|
HeartbeatError::StatePermissions { .. } => "connect_heartbeat_state_permissions",
|
||||||
|
HeartbeatError::ResponseTooLarge => "connect_heartbeat_response_too_large",
|
||||||
|
HeartbeatError::Response => "connect_heartbeat_response",
|
||||||
|
HeartbeatError::Url(_) => "connect_heartbeat_url",
|
||||||
|
HeartbeatError::Transport(_) => "connect_heartbeat_transport",
|
||||||
|
HeartbeatError::Identity(_) => "connect_heartbeat_identity",
|
||||||
|
HeartbeatError::IdentityStore(_) => "connect_heartbeat_identity_store",
|
||||||
|
HeartbeatError::CredentialStore(_) => "connect_heartbeat_credential_store",
|
||||||
|
HeartbeatError::CredentialValidation(error) => match error {
|
||||||
|
CredentialValidationError::Certificate => "connect_heartbeat_credential_certificate",
|
||||||
|
CredentialValidationError::Chain => "connect_heartbeat_credential_chain",
|
||||||
|
CredentialValidationError::Identity => "connect_heartbeat_credential_identity",
|
||||||
|
CredentialValidationError::Key => "connect_heartbeat_credential_key",
|
||||||
|
CredentialValidationError::Validity => "connect_heartbeat_credential_validity",
|
||||||
|
CredentialValidationError::CertificateRequest => "connect_heartbeat_credential_request",
|
||||||
|
CredentialValidationError::RotationTranscript => "connect_heartbeat_credential_rotation_transcript",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn failed_inventory(status: &watch::Sender<InventoryStatus>, error: InventoryError) {
|
fn failed_inventory(status: &watch::Sender<InventoryStatus>, error: InventoryError) {
|
||||||
let _ = status.send(InventoryStatus::Failed {
|
let _ = status.send(InventoryStatus::Failed {
|
||||||
reason: error.to_string(),
|
reason: error.to_string(),
|
||||||
@@ -327,7 +415,51 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn heartbeat_shutdown_cancels_inventory_before_waiting_for_heartbeat() {
|
async fn state_only_configuration_does_not_start_heartbeat() {
|
||||||
|
let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("safe temporary directory");
|
||||||
|
let config = HeartbeatConfig::state_only(temp.path().to_path_buf());
|
||||||
|
let shutdown = CancellationToken::new();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
spawn_heartbeat_runtime(Some(config), &shutdown, || { CoarseNodeSummary::new(1, 0, 0).expect("summary") })
|
||||||
|
.expect("disabled transport")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtimes_expose_only_stable_machine_reasons() {
|
||||||
|
let error = HeartbeatError::StateIo {
|
||||||
|
path: std::path::PathBuf::from("/private/connect/state.json"),
|
||||||
|
source: std::io::Error::other("transport.internal"),
|
||||||
|
};
|
||||||
|
assert_eq!(heartbeat_failure_reason(&error), "connect_heartbeat_state_io");
|
||||||
|
assert_eq!(
|
||||||
|
heartbeat_failure_reason(&HeartbeatError::StateCorrupt {
|
||||||
|
path: std::path::PathBuf::from("/private/connect/state.json"),
|
||||||
|
}),
|
||||||
|
"connect_heartbeat_state_corrupt"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
heartbeat_failure_reason(&HeartbeatError::CredentialExpired),
|
||||||
|
"connect_heartbeat_credential_expired"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
heartbeat_failure_reason(&HeartbeatError::CredentialValidation(
|
||||||
|
super::super::registration::CredentialValidationError::Identity
|
||||||
|
)),
|
||||||
|
"connect_heartbeat_credential_identity"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
heartbeat_failure_reason(&HeartbeatError::CredentialValidation(
|
||||||
|
super::super::registration::CredentialValidationError::Key
|
||||||
|
)),
|
||||||
|
"connect_heartbeat_credential_key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unified_shutdown_cancels_inventory_before_waiting_for_heartbeat() {
|
||||||
let heartbeat_shutdown = CancellationToken::new();
|
let heartbeat_shutdown = CancellationToken::new();
|
||||||
let inventory_shutdown = CancellationToken::new();
|
let inventory_shutdown = CancellationToken::new();
|
||||||
let task_inventory_shutdown = inventory_shutdown.clone();
|
let task_inventory_shutdown = inventory_shutdown.clone();
|
||||||
@@ -342,18 +474,18 @@ mod tests {
|
|||||||
task_inventory_shutdown.cancelled().await;
|
task_inventory_shutdown.cancelled().await;
|
||||||
let _ = inventory_stopped.send(());
|
let _ = inventory_stopped.send(());
|
||||||
});
|
});
|
||||||
let runtime = HeartbeatRuntime {
|
let heartbeat = HeartbeatRuntime {
|
||||||
shutdown: heartbeat_shutdown,
|
shutdown: heartbeat_shutdown,
|
||||||
status: heartbeat_status,
|
status: heartbeat_status,
|
||||||
task: Some(heartbeat_task),
|
task: Some(heartbeat_task),
|
||||||
inventory: Some(InventoryRuntime {
|
};
|
||||||
shutdown: inventory_shutdown,
|
let inventory = InventoryRuntime {
|
||||||
status: inventory_status,
|
shutdown: inventory_shutdown,
|
||||||
task: Some(inventory_task),
|
status: inventory_status,
|
||||||
}),
|
task: Some(inventory_task),
|
||||||
};
|
};
|
||||||
|
|
||||||
let shutdown = tokio::spawn(runtime.shutdown());
|
let shutdown = tokio::spawn(shutdown_connect_runtimes(Some(heartbeat), Some(inventory)));
|
||||||
tokio::time::timeout(Duration::from_millis(250), stopped)
|
tokio::time::timeout(Duration::from_millis(250), stopped)
|
||||||
.await
|
.await
|
||||||
.expect("inventory cancellation must not wait for heartbeat")
|
.expect("inventory cancellation must not wait for heartbeat")
|
||||||
|
|||||||
@@ -391,8 +391,8 @@ pub fn memory_observability_controller_snapshot(ctx: &CancellationToken) -> Memo
|
|||||||
|
|
||||||
/// Record the effective memory total and its basis (host or cgroup).
|
/// Record the effective memory total and its basis (host or cgroup).
|
||||||
fn record_effective_memory(total_bytes: u64) {
|
fn record_effective_memory(total_bytes: u64) {
|
||||||
let basis = crate::cgroup_resources::memory_basis();
|
let basis = crate::cgroup_resources::container_resources().basis;
|
||||||
metrics::gauge!("rustfs_memory_effective_total_bytes", "basis" => basis.to_string()).set(total_bytes as f64);
|
metrics::gauge!("rustfs_memory_effective_total_bytes", "basis" => basis).set(total_bytes as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record container resource detection results.
|
/// Record container resource detection results.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::storage_api::startup::lifecycle::ECStore;
|
use crate::storage_api::startup::lifecycle::ECStore;
|
||||||
use crate::{
|
use crate::{
|
||||||
|
connect::runtime::shutdown_connect_runtimes,
|
||||||
server::{ServiceStateManager, ShutdownHandle, start_persisted_event_notifier_reconciler, wait_for_shutdown},
|
server::{ServiceStateManager, ShutdownHandle, start_persisted_event_notifier_reconciler, wait_for_shutdown},
|
||||||
startup_iam::{IamBootstrapDisposition, publish_ready_for_iam_bootstrap},
|
startup_iam::{IamBootstrapDisposition, publish_ready_for_iam_bootstrap},
|
||||||
startup_runtime_sources,
|
startup_runtime_sources,
|
||||||
@@ -129,6 +130,7 @@ pub(crate) async fn run_startup_runtime_lifecycle(lifecycle: StartupRuntimeLifec
|
|||||||
let StartupServiceRuntime {
|
let StartupServiceRuntime {
|
||||||
optional_runtimes,
|
optional_runtimes,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
inventory,
|
||||||
iam_bootstrap,
|
iam_bootstrap,
|
||||||
enable_scanner,
|
enable_scanner,
|
||||||
} = service_runtime;
|
} = service_runtime;
|
||||||
@@ -163,9 +165,7 @@ pub(crate) async fn run_startup_runtime_lifecycle(lifecycle: StartupRuntimeLifec
|
|||||||
shutdown_token,
|
shutdown_token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Some(heartbeat) = heartbeat {
|
shutdown_connect_runtimes(heartbeat, inventory).await;
|
||||||
heartbeat.shutdown().await;
|
|
||||||
}
|
|
||||||
if let Err(err) = event_notifier_reconciler.await {
|
if let Err(err) = event_notifier_reconciler.await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "rustfs::main::run",
|
target: "rustfs::main::run",
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ use crate::storage_api::startup::services::{ECStore, EndpointServerPools, Server
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
connect::{
|
connect::{
|
||||||
CoarseNodeSummary, HeartbeatConfig, HeartbeatRuntime, InventoryError, InventoryFlag, InventoryRuntime, InventorySchedule,
|
CoarseNodeSummary, HeartbeatConfig, HeartbeatError, HeartbeatRuntime, InventoryError, InventoryFlag, InventoryRuntime,
|
||||||
InventorySnapshot, spawn_heartbeat_runtime, spawn_inventory_runtime,
|
InventorySchedule, InventorySnapshot, runtime::heartbeat_failure_reason, spawn_heartbeat_runtime,
|
||||||
|
spawn_inventory_runtime,
|
||||||
},
|
},
|
||||||
init::{init_buffer_profile_system, init_kms_system},
|
init::{init_buffer_profile_system, init_kms_system},
|
||||||
server::ServiceStateManager,
|
server::ServiceStateManager,
|
||||||
@@ -40,6 +41,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
pub(crate) struct StartupServiceRuntime {
|
pub(crate) struct StartupServiceRuntime {
|
||||||
pub(crate) optional_runtimes: OptionalRuntimeServices,
|
pub(crate) optional_runtimes: OptionalRuntimeServices,
|
||||||
pub(crate) heartbeat: Option<HeartbeatRuntime>,
|
pub(crate) heartbeat: Option<HeartbeatRuntime>,
|
||||||
|
pub(crate) inventory: Option<InventoryRuntime>,
|
||||||
pub(crate) iam_bootstrap: IamBootstrapDisposition,
|
pub(crate) iam_bootstrap: IamBootstrapDisposition,
|
||||||
pub(crate) enable_scanner: bool,
|
pub(crate) enable_scanner: bool,
|
||||||
}
|
}
|
||||||
@@ -104,11 +106,11 @@ pub(crate) async fn init_startup_runtime_services(
|
|||||||
init_observability_runtime(store.clone(), ctx.clone()).await;
|
init_observability_runtime(store.clone(), ctx.clone()).await;
|
||||||
let heartbeat = start_heartbeat_runtime(heartbeat_config.clone(), heartbeat_nodes, &ctx)?;
|
let heartbeat = start_heartbeat_runtime(heartbeat_config.clone(), heartbeat_nodes, &ctx)?;
|
||||||
let inventory = start_inventory_runtime(heartbeat_config, heartbeat_nodes, inventory_drives, store, &ctx)?;
|
let inventory = start_inventory_runtime(heartbeat_config, heartbeat_nodes, inventory_drives, store, &ctx)?;
|
||||||
let heartbeat = heartbeat.map(|heartbeat| heartbeat.with_inventory(inventory));
|
|
||||||
|
|
||||||
Ok(StartupServiceRuntime {
|
Ok(StartupServiceRuntime {
|
||||||
optional_runtimes,
|
optional_runtimes,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
inventory,
|
||||||
iam_bootstrap,
|
iam_bootstrap,
|
||||||
enable_scanner,
|
enable_scanner,
|
||||||
})
|
})
|
||||||
@@ -122,11 +124,18 @@ fn start_heartbeat_runtime(
|
|||||||
let Some(config) = config else {
|
let Some(config) = config else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
if !config.transport_enabled() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let summary = u16::try_from(node_count.unwrap_or_default())
|
let summary = u16::try_from(node_count.unwrap_or_default())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|total| CoarseNodeSummary::new(total, 0, 0).ok())
|
.and_then(|total| CoarseNodeSummary::new(total, 0, 0).ok())
|
||||||
.ok_or_else(|| std::io::Error::other("Connect heartbeat node count is outside protocol bounds"))?;
|
.ok_or_else(|| std::io::Error::other("Connect heartbeat node count is outside protocol bounds"))?;
|
||||||
spawn_heartbeat_runtime(Some(config), shutdown, move || summary).map_err(std::io::Error::other)
|
spawn_heartbeat_runtime(Some(config), shutdown, move || summary).map_err(startup_heartbeat_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn startup_heartbeat_error(error: HeartbeatError) -> std::io::Error {
|
||||||
|
std::io::Error::other(heartbeat_failure_reason(&error))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_inventory_runtime(
|
fn start_inventory_runtime(
|
||||||
@@ -318,6 +327,16 @@ fn aggregate_inventory_capacity(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heartbeat_startup_errors_expose_only_stable_codes() {
|
||||||
|
let error = startup_heartbeat_error(HeartbeatError::StateIo {
|
||||||
|
path: std::path::PathBuf::from("/private/connect/canary/state.json"),
|
||||||
|
source: std::io::Error::other("private-source-canary"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(error.to_string(), "connect_heartbeat_state_io");
|
||||||
|
}
|
||||||
|
|
||||||
fn disk(state: &str, runtime_state: Option<&str>, disk_index: i32) -> rustfs_madmin::Disk {
|
fn disk(state: &str, runtime_state: Option<&str>, disk_index: i32) -> rustfs_madmin::Disk {
|
||||||
rustfs_madmin::Disk {
|
rustfs_madmin::Disk {
|
||||||
state: state.to_owned(),
|
state: state.to_owned(),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
@@ -46,6 +48,10 @@ const CLUSTER_UID: &str = "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81";
|
|||||||
const DEVICE_UID: &str = "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92";
|
const DEVICE_UID: &str = "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92";
|
||||||
const SNAPSHOT_UID: &str = "0198f4b0-4d00-7f40-9051-5b6c7d8e9fa3";
|
const SNAPSHOT_UID: &str = "0198f4b0-4d00-7f40-9051-5b6c7d8e9fa3";
|
||||||
|
|
||||||
|
fn safe_tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("safe temporary directory")
|
||||||
|
}
|
||||||
|
|
||||||
struct TestPki {
|
struct TestPki {
|
||||||
root_params: CertificateParams,
|
root_params: CertificateParams,
|
||||||
root_key: KeyPair,
|
root_key: KeyPair,
|
||||||
@@ -244,6 +250,7 @@ fn config(temp: &tempfile::TempDir, pki: &TestPki, server: &TestServer) -> Heart
|
|||||||
if let Err(error) = fs::create_dir(temp.path().join("private-config-secret")) {
|
if let Err(error) = fs::create_dir(temp.path().join("private-config-secret")) {
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists, "Connect state root");
|
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists, "Connect state root");
|
||||||
}
|
}
|
||||||
|
private_directory_mode(&temp.path().join("private-config-secret"));
|
||||||
HeartbeatConfig {
|
HeartbeatConfig {
|
||||||
endpoint: server.endpoint.clone(),
|
endpoint: server.endpoint.clone(),
|
||||||
root_ca_pem: pki.root_pem.as_bytes().to_vec(),
|
root_ca_pem: pki.root_pem.as_bytes().to_vec(),
|
||||||
@@ -394,6 +401,38 @@ fn connect_inventory_bounds_fail_instead_of_truncating_or_inventing_values() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_inventory_state_only_persists_without_constructing_transport() {
|
||||||
|
let temp = safe_tempdir();
|
||||||
|
let state = temp.path().join("state");
|
||||||
|
fs::create_dir(&state).expect("state root");
|
||||||
|
private_directory_mode(&state);
|
||||||
|
let config = HeartbeatConfig::new(
|
||||||
|
"",
|
||||||
|
Vec::new(),
|
||||||
|
IdentityStore::new(state.join("identity")),
|
||||||
|
CredentialStore::new(state.join("credential")),
|
||||||
|
state.join("heartbeat/state.json"),
|
||||||
|
);
|
||||||
|
let shutdown = CancellationToken::new();
|
||||||
|
let runtime = spawn_inventory_runtime(Some(config), schedule(), &shutdown, || std::future::ready(Ok(snapshot())))
|
||||||
|
.expect("state-only inventory")
|
||||||
|
.expect("configured inventory");
|
||||||
|
let mut status = runtime.status();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::Unchanged { .. })).await,
|
||||||
|
InventoryStatus::Unchanged { .. }
|
||||||
|
));
|
||||||
|
let envelope: Value = serde_json::from_slice(&fs::read(state.join("inventory/latest.json")).expect("latest inventory"))
|
||||||
|
.expect("latest envelope");
|
||||||
|
assert_eq!(envelope["formatVersion"], "v1");
|
||||||
|
assert_eq!(envelope["snapshot"], serde_json::to_value(snapshot()).expect("snapshot JSON"));
|
||||||
|
assert_eq!(envelope.as_object().expect("envelope object").len(), 4);
|
||||||
|
runtime.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connect_inventory_rejects_deserialized_invalid_snapshots_before_state_or_network() {
|
async fn connect_inventory_rejects_deserialized_invalid_snapshots_before_state_or_network() {
|
||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
@@ -409,7 +448,7 @@ async fn connect_inventory_rejects_deserialized_invalid_snapshots_before_state_o
|
|||||||
"coarseFlags": []
|
"coarseFlags": []
|
||||||
}))
|
}))
|
||||||
.expect("serde should not bypass the runtime validation boundary");
|
.expect("serde should not bypass the runtime validation boundary");
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let runtime = spawn_inventory_runtime(Some(config(&temp, &pki, &server)), schedule(), &shutdown, move || {
|
let runtime = spawn_inventory_runtime(Some(config(&temp, &pki, &server)), schedule(), &shutdown, move || {
|
||||||
std::future::ready(Ok(invalid.clone()))
|
std::future::ready(Ok(invalid.clone()))
|
||||||
@@ -420,7 +459,7 @@ async fn connect_inventory_rejects_deserialized_invalid_snapshots_before_state_o
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
wait_for(&mut status, |status| matches!(status, InventoryStatus::Failed { .. })).await,
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::Failed { .. })).await,
|
||||||
InventoryStatus::Failed { reason } if reason.contains("version is outside protocol bounds")
|
InventoryStatus::Failed { reason } if reason == "connect_inventory_snapshot_version"
|
||||||
));
|
));
|
||||||
assert!(!temp.path().join("private-config-secret/inventory/state.json").exists());
|
assert!(!temp.path().join("private-config-secret/inventory/state.json").exists());
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
@@ -433,7 +472,7 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let content_hash = snapshot().content_hash().expect("content hash");
|
let content_hash = snapshot().content_hash().expect("content hash");
|
||||||
let first_server = server(&pki, vec![Reply::error(StatusCode::SERVICE_UNAVAILABLE, "UNAVAILABLE")]).await;
|
let first_server = server(&pki, vec![Reply::error(StatusCode::SERVICE_UNAVAILABLE, "UNAVAILABLE")]).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let samples = Arc::new(AtomicUsize::new(0));
|
let samples = Arc::new(AtomicUsize::new(0));
|
||||||
let sampled = samples.clone();
|
let sampled = samples.clone();
|
||||||
@@ -451,7 +490,40 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
));
|
));
|
||||||
assert_eq!(samples.load(Ordering::Relaxed), 1);
|
assert_eq!(samples.load(Ordering::Relaxed), 1);
|
||||||
let original = first_server.seen.lock().expect("seen lock")[0].clone();
|
let original = first_server.seen.lock().expect("seen lock")[0].clone();
|
||||||
|
let latest: Value = serde_json::from_slice(
|
||||||
|
&fs::read(temp.path().join("private-config-secret/inventory/latest.json")).expect("latest inventory"),
|
||||||
|
)
|
||||||
|
.expect("latest envelope");
|
||||||
|
assert_eq!(latest["snapshot"], serde_json::to_value(snapshot()).expect("snapshot JSON"));
|
||||||
|
for field in [
|
||||||
|
"rustfsVersion",
|
||||||
|
"osVersion",
|
||||||
|
"nodeCount",
|
||||||
|
"driveCount",
|
||||||
|
"capacityTotalBytes",
|
||||||
|
"capacityUsedBytes",
|
||||||
|
"coarseFlags",
|
||||||
|
] {
|
||||||
|
assert_eq!(latest["snapshot"][field], original[field]);
|
||||||
|
}
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
|
let state = temp.path().join("private-config-secret/inventory/state.json");
|
||||||
|
let legacy_persisted_at = std::time::SystemTime::now() - Duration::from_secs(60 * 60);
|
||||||
|
fs::File::options()
|
||||||
|
.write(true)
|
||||||
|
.open(&state)
|
||||||
|
.expect("pending state")
|
||||||
|
.set_times(std::fs::FileTimes::new().set_modified(legacy_persisted_at))
|
||||||
|
.expect("legacy pending timestamp");
|
||||||
|
let legacy_persisted_at = chrono::DateTime::<chrono::Utc>::from(
|
||||||
|
fs::metadata(&state)
|
||||||
|
.and_then(|metadata| metadata.modified())
|
||||||
|
.expect("persisted pending timestamp"),
|
||||||
|
)
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string();
|
||||||
|
fs::remove_file(temp.path().join("private-config-secret/inventory/latest.json"))
|
||||||
|
.expect("simulate a pending snapshot created before local persistence");
|
||||||
|
|
||||||
let mut limited = Reply::error(StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED");
|
let mut limited = Reply::error(StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED");
|
||||||
limited.retry_after = Some("0");
|
limited.retry_after = Some("0");
|
||||||
@@ -473,10 +545,17 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
if accepted == content_hash && received_at == "2026-08-22T01:02:03Z"
|
if accepted == content_hash && received_at == "2026-08-22T01:02:03Z"
|
||||||
));
|
));
|
||||||
assert_eq!(restart_samples.load(Ordering::Relaxed), 0);
|
assert_eq!(restart_samples.load(Ordering::Relaxed), 0);
|
||||||
|
let restored_latest: Value = serde_json::from_slice(
|
||||||
|
&fs::read(temp.path().join("private-config-secret/inventory/latest.json")).expect("restored latest inventory"),
|
||||||
|
)
|
||||||
|
.expect("restored latest envelope");
|
||||||
|
assert_eq!(restored_latest["snapshot"], serde_json::to_value(snapshot()).expect("snapshot JSON"));
|
||||||
|
assert_eq!(restored_latest["capturedAt"], legacy_persisted_at);
|
||||||
let delivered = restart_server.seen.lock().expect("seen lock").clone();
|
let delivered = restart_server.seen.lock().expect("seen lock").clone();
|
||||||
assert_eq!(delivered, vec![original.clone(), original.clone()]);
|
assert_eq!(delivered, vec![original.clone(), original.clone()]);
|
||||||
assert_eq!(original["sequence"], 0);
|
assert_eq!(original["sequence"], 0);
|
||||||
let encoded = serde_json::to_string(&original).expect("request JSON");
|
let encoded = serde_json::to_string(&original).expect("request JSON");
|
||||||
|
let persisted = serde_json::to_string(&latest).expect("persisted JSON");
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"private-config-secret",
|
"private-config-secret",
|
||||||
"BEGIN CERTIFICATE",
|
"BEGIN CERTIFICATE",
|
||||||
@@ -486,9 +565,17 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
"path",
|
"path",
|
||||||
] {
|
] {
|
||||||
assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
|
assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
|
||||||
|
assert!(!persisted.contains(forbidden), "persisted inventory exposed {forbidden}");
|
||||||
}
|
}
|
||||||
assert_eq!(original.as_object().expect("request object").len(), 10);
|
assert_eq!(original.as_object().expect("request object").len(), 10);
|
||||||
restart.shutdown().await;
|
restart.shutdown().await;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let latest_before_unchanged = {
|
||||||
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
fs::metadata(temp.path().join("private-config-secret/inventory/latest.json"))
|
||||||
|
.expect("latest metadata")
|
||||||
|
.ino()
|
||||||
|
};
|
||||||
|
|
||||||
let unchanged_samples = Arc::new(AtomicUsize::new(0));
|
let unchanged_samples = Arc::new(AtomicUsize::new(0));
|
||||||
let sampled = unchanged_samples.clone();
|
let sampled = unchanged_samples.clone();
|
||||||
@@ -505,6 +592,17 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
));
|
));
|
||||||
assert_eq!(unchanged_samples.load(Ordering::Relaxed), 1);
|
assert_eq!(unchanged_samples.load(Ordering::Relaxed), 1);
|
||||||
assert_eq!(restart_server.seen.lock().expect("seen lock").len(), 2);
|
assert_eq!(restart_server.seen.lock().expect("seen lock").len(), 2);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
assert_ne!(
|
||||||
|
fs::metadata(temp.path().join("private-config-secret/inventory/latest.json"))
|
||||||
|
.expect("refreshed latest metadata")
|
||||||
|
.ino(),
|
||||||
|
latest_before_unchanged,
|
||||||
|
"a complete unchanged sample must refresh the local envelope"
|
||||||
|
);
|
||||||
|
}
|
||||||
unchanged.shutdown().await;
|
unchanged.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +610,7 @@ async fn connect_inventory_restart_replays_the_pending_request_and_then_skips_un
|
|||||||
async fn connect_inventory_disconnect_retries_without_resampling() {
|
async fn connect_inventory_disconnect_retries_without_resampling() {
|
||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let unavailable = server(&pki, Vec::new()).await;
|
let unavailable = server(&pki, Vec::new()).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let config = config(&temp, &pki, &unavailable);
|
let config = config(&temp, &pki, &unavailable);
|
||||||
drop(unavailable);
|
drop(unavailable);
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
@@ -542,7 +640,7 @@ async fn connect_inventory_retries_an_incomplete_sample_before_delivery() {
|
|||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let content_hash = snapshot().content_hash().expect("content hash");
|
let content_hash = snapshot().content_hash().expect("content hash");
|
||||||
let server = server(&pki, vec![Reply::ok(&content_hash)]).await;
|
let server = server(&pki, vec![Reply::ok(&content_hash)]).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let samples = Arc::new(AtomicUsize::new(0));
|
let samples = Arc::new(AtomicUsize::new(0));
|
||||||
let sampled = samples.clone();
|
let sampled = samples.clone();
|
||||||
@@ -561,6 +659,11 @@ async fn connect_inventory_retries_an_incomplete_sample_before_delivery() {
|
|||||||
.expect("configured inventory");
|
.expect("configured inventory");
|
||||||
let mut status = runtime.status();
|
let mut status = runtime.status();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::BackingOff { .. })).await,
|
||||||
|
InventoryStatus::BackingOff { .. }
|
||||||
|
));
|
||||||
|
assert!(!temp.path().join("private-config-secret/inventory/latest.json").exists());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
wait_for(&mut status, |status| matches!(status, InventoryStatus::Online { .. })).await,
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::Online { .. })).await,
|
||||||
InventoryStatus::Online { content_hash: accepted, .. } if accepted == content_hash
|
InventoryStatus::Online { content_hash: accepted, .. } if accepted == content_hash
|
||||||
@@ -575,7 +678,7 @@ async fn connect_inventory_unchanged_sample_resets_incomplete_backoff() {
|
|||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let content_hash = snapshot().content_hash().expect("content hash");
|
let content_hash = snapshot().content_hash().expect("content hash");
|
||||||
let server = server(&pki, vec![Reply::ok(&content_hash)]).await;
|
let server = server(&pki, vec![Reply::ok(&content_hash)]).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let config = config(&temp, &pki, &server);
|
let config = config(&temp, &pki, &server);
|
||||||
let seed = spawn_inventory_runtime(Some(config.clone()), schedule(), &shutdown, || std::future::ready(Ok(snapshot())))
|
let seed = spawn_inventory_runtime(Some(config.clone()), schedule(), &shutdown, || std::future::ready(Ok(snapshot())))
|
||||||
@@ -645,7 +748,7 @@ async fn connect_inventory_unchanged_sample_resets_incomplete_backoff() {
|
|||||||
async fn connect_inventory_revoked_device_stops_without_retrying() {
|
async fn connect_inventory_revoked_device_stops_without_retrying() {
|
||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let server = server(&pki, vec![Reply::error(StatusCode::UNAUTHORIZED, "DEVICE_REVOKED")]).await;
|
let server = server(&pki, vec![Reply::error(StatusCode::UNAUTHORIZED, "DEVICE_REVOKED")]).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let runtime = spawn_inventory_runtime(Some(config(&temp, &pki, &server)), schedule(), &shutdown, || {
|
let runtime = spawn_inventory_runtime(Some(config(&temp, &pki, &server)), schedule(), &shutdown, || {
|
||||||
std::future::ready(Ok(snapshot()))
|
std::future::ready(Ok(snapshot()))
|
||||||
@@ -656,7 +759,10 @@ async fn connect_inventory_revoked_device_stops_without_retrying() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
wait_for(&mut status, |status| matches!(status, InventoryStatus::AuthenticationStopped { .. })).await,
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::AuthenticationStopped { .. })).await,
|
||||||
InventoryStatus::AuthenticationStopped { status: 401, reason: Some(reason) } if reason == "DEVICE_REVOKED"
|
InventoryStatus::AuthenticationStopped {
|
||||||
|
status: 401,
|
||||||
|
reason: None
|
||||||
|
}
|
||||||
));
|
));
|
||||||
assert_eq!(server.seen.lock().expect("seen lock").len(), 1);
|
assert_eq!(server.seen.lock().expect("seen lock").len(), 1);
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
@@ -666,10 +772,11 @@ async fn connect_inventory_revoked_device_stops_without_retrying() {
|
|||||||
async fn connect_inventory_sequence_overflow_fails_before_sampling_or_network_delivery() {
|
async fn connect_inventory_sequence_overflow_fails_before_sampling_or_network_delivery() {
|
||||||
let pki = TestPki::new();
|
let pki = TestPki::new();
|
||||||
let server = server(&pki, Vec::new()).await;
|
let server = server(&pki, Vec::new()).await;
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let config = config(&temp, &pki, &server);
|
let config = config(&temp, &pki, &server);
|
||||||
let state = temp.path().join("private-config-secret/inventory/state.json");
|
let state = temp.path().join("private-config-secret/inventory/state.json");
|
||||||
fs::create_dir_all(state.parent().expect("state directory")).expect("create state directory");
|
fs::create_dir_all(state.parent().expect("state directory")).expect("create state directory");
|
||||||
|
private_directory_mode(state.parent().expect("state directory"));
|
||||||
fs::write(
|
fs::write(
|
||||||
&state,
|
&state,
|
||||||
br#"{"nextSequence":9007199254740992,"pending":null,"lastAcceptedContentHash":null}"#,
|
br#"{"nextSequence":9007199254740992,"pending":null,"lastAcceptedContentHash":null}"#,
|
||||||
@@ -689,7 +796,7 @@ async fn connect_inventory_sequence_overflow_fails_before_sampling_or_network_de
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
wait_for(&mut status, |status| matches!(status, InventoryStatus::Failed { .. })).await,
|
wait_for(&mut status, |status| matches!(status, InventoryStatus::Failed { .. })).await,
|
||||||
InventoryStatus::Failed { reason } if reason.contains("sequence is exhausted")
|
InventoryStatus::Failed { reason } if reason == "connect_inventory_sequence_exhausted"
|
||||||
));
|
));
|
||||||
assert_eq!(samples.load(Ordering::Relaxed), 0);
|
assert_eq!(samples.load(Ordering::Relaxed), 0);
|
||||||
assert!(server.seen.lock().expect("seen lock").is_empty());
|
assert!(server.seen.lock().expect("seen lock").is_empty());
|
||||||
@@ -702,10 +809,11 @@ async fn connect_inventory_rejects_noncanonical_persisted_snapshots_before_deliv
|
|||||||
let server = server(&pki, Vec::new()).await;
|
let server = server(&pki, Vec::new()).await;
|
||||||
|
|
||||||
for invalid_case in ["flags", "os-version"] {
|
for invalid_case in ["flags", "os-version"] {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = safe_tempdir();
|
||||||
let config = config(&temp, &pki, &server);
|
let config = config(&temp, &pki, &server);
|
||||||
let state = temp.path().join("private-config-secret/inventory/state.json");
|
let state = temp.path().join("private-config-secret/inventory/state.json");
|
||||||
fs::create_dir_all(state.parent().expect("state directory")).expect("create state directory");
|
fs::create_dir_all(state.parent().expect("state directory")).expect("create state directory");
|
||||||
|
private_directory_mode(state.parent().expect("state directory"));
|
||||||
let mut pending = json!({
|
let mut pending = json!({
|
||||||
"protocolVersion": "v1",
|
"protocolVersion": "v1",
|
||||||
"requestId": "00000000-0000-4000-8000-000000000001",
|
"requestId": "00000000-0000-4000-8000-000000000001",
|
||||||
@@ -753,7 +861,7 @@ async fn connect_inventory_rejects_noncanonical_persisted_snapshots_before_deliv
|
|||||||
matches!(status, InventoryStatus::Failed { .. } | InventoryStatus::BackingOff { .. })
|
matches!(status, InventoryStatus::Failed { .. } | InventoryStatus::BackingOff { .. })
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
InventoryStatus::Failed { reason } if reason.contains("violates the protocol invariants")
|
InventoryStatus::Failed { reason } if reason == "connect_inventory_state_corrupt"
|
||||||
));
|
));
|
||||||
assert_eq!(samples.load(Ordering::Relaxed), 0);
|
assert_eq!(samples.load(Ordering::Relaxed), 0);
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
@@ -762,11 +870,20 @@ async fn connect_inventory_rejects_noncanonical_persisted_snapshots_before_deliv
|
|||||||
assert!(server.seen.lock().expect("seen lock").is_empty());
|
assert!(server.seen.lock().expect("seen lock").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(target_os = "linux")]
|
||||||
fn private_mode(path: &std::path::Path) {
|
fn private_mode(path: &std::path::Path) {
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("private permissions");
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("private permissions");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(target_os = "linux")]
|
||||||
|
fn private_directory_mode(path: &std::path::Path) {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).expect("private directory permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn private_mode(_path: &std::path::Path) {}
|
fn private_mode(_path: &std::path::Path) {}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn private_directory_mode(_path: &std::path::Path) {}
|
||||||
|
|||||||
+44
-4
@@ -25,6 +25,7 @@
|
|||||||
# Environment variables:
|
# Environment variables:
|
||||||
# FUZZ_TARGET — run only this target (default: all smoke targets)
|
# FUZZ_TARGET — run only this target (default: all smoke targets)
|
||||||
# MAX_TOTAL_TIME — seconds to fuzz per target (default: 60)
|
# MAX_TOTAL_TIME — seconds to fuzz per target (default: 60)
|
||||||
|
# FUZZ_SEED — replay one libFuzzer seed (default: generate and record one per target)
|
||||||
# ARTIFACT_ROOT — artifact output directory (default: artifacts)
|
# ARTIFACT_ROOT — artifact output directory (default: artifacts)
|
||||||
# BUILD_ONLY — set to 1 to skip fuzz runs (default: 0)
|
# BUILD_ONLY — set to 1 to skip fuzz runs (default: 0)
|
||||||
# SKIP_BUILD — set to 1 to skip build phase (default: 0)
|
# SKIP_BUILD — set to 1 to skip build phase (default: 0)
|
||||||
@@ -37,6 +38,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||||
FUZZ_DIR="$REPO_ROOT/fuzz"
|
FUZZ_DIR="$REPO_ROOT/fuzz"
|
||||||
MAX_TOTAL_TIME=${MAX_TOTAL_TIME:-60}
|
MAX_TOTAL_TIME=${MAX_TOTAL_TIME:-60}
|
||||||
|
FUZZ_SEED=${FUZZ_SEED:-}
|
||||||
ARTIFACT_ROOT=${ARTIFACT_ROOT:-artifacts}
|
ARTIFACT_ROOT=${ARTIFACT_ROOT:-artifacts}
|
||||||
FUZZ_TARGET=${FUZZ_TARGET:-}
|
FUZZ_TARGET=${FUZZ_TARGET:-}
|
||||||
BUILD_ONLY=${BUILD_ONLY:-0}
|
BUILD_ONLY=${BUILD_ONLY:-0}
|
||||||
@@ -44,6 +46,15 @@ SKIP_BUILD=${SKIP_BUILD:-0}
|
|||||||
USE_PREBUILT_BINARY=${USE_PREBUILT_BINARY:-0}
|
USE_PREBUILT_BINARY=${USE_PREBUILT_BINARY:-0}
|
||||||
PREBUILT_BINARY_DIR=${PREBUILT_BINARY_DIR:-}
|
PREBUILT_BINARY_DIR=${PREBUILT_BINARY_DIR:-}
|
||||||
|
|
||||||
|
if [ -n "$FUZZ_SEED" ]; then
|
||||||
|
case "$FUZZ_SEED" in
|
||||||
|
0*|*[!0-9]*)
|
||||||
|
echo "FUZZ_SEED must be a positive decimal integer without leading zeroes: $FUZZ_SEED" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$FUZZ_DIR"
|
cd "$FUZZ_DIR"
|
||||||
mkdir -p "$ARTIFACT_ROOT"
|
mkdir -p "$ARTIFACT_ROOT"
|
||||||
|
|
||||||
@@ -75,6 +86,35 @@ for target in $targets; do
|
|||||||
mkdir -p "$artifact_dir"
|
mkdir -p "$artifact_dir"
|
||||||
mkdir -p "$corpus_dir"
|
mkdir -p "$corpus_dir"
|
||||||
|
|
||||||
|
seed="$FUZZ_SEED"
|
||||||
|
if [ -z "$seed" ]; then
|
||||||
|
seed=$(printf '%s\n' "${GITHUB_RUN_ID:-local}:${GITHUB_RUN_ATTEMPT:-0}:$target:$(date +%s):$$" | cksum | awk '{print $1}')
|
||||||
|
if [ "$seed" = "0" ]; then
|
||||||
|
seed=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
revision=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')
|
||||||
|
if [ "$revision" = "unknown" ]; then
|
||||||
|
git_dirty="unknown"
|
||||||
|
elif [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=normal 2>/dev/null)" ]; then
|
||||||
|
git_dirty="true"
|
||||||
|
else
|
||||||
|
git_dirty="false"
|
||||||
|
fi
|
||||||
|
if [ "$USE_PREBUILT_BINARY" = "1" ]; then
|
||||||
|
runner_mode="prebuilt"
|
||||||
|
else
|
||||||
|
runner_mode="cargo-fuzz"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf 'target=%s\n' "$target"
|
||||||
|
printf 'seed=%s\n' "$seed"
|
||||||
|
printf 'max_total_time=%s\n' "$MAX_TOTAL_TIME"
|
||||||
|
printf 'git_revision=%s\n' "$revision"
|
||||||
|
printf 'git_dirty=%s\n' "$git_dirty"
|
||||||
|
printf 'runner_mode=%s\n' "$runner_mode"
|
||||||
|
} > "$artifact_dir/run-manifest.txt"
|
||||||
|
|
||||||
if [ "$USE_PREBUILT_BINARY" = "1" ]; then
|
if [ "$USE_PREBUILT_BINARY" = "1" ]; then
|
||||||
binary_dir="$PREBUILT_BINARY_DIR"
|
binary_dir="$PREBUILT_BINARY_DIR"
|
||||||
if [ -z "$binary_dir" ]; then
|
if [ -z "$binary_dir" ]; then
|
||||||
@@ -89,11 +129,11 @@ for target in $targets; do
|
|||||||
echo "Missing executable prebuilt fuzz binary: $binary_path" >&2
|
echo "Missing executable prebuilt fuzz binary: $binary_path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "==> $binary_path (-max_total_time=$MAX_TOTAL_TIME, -artifact_prefix=$artifact_dir/, corpus=$corpus_dir)"
|
echo "==> $binary_path (-max_total_time=$MAX_TOTAL_TIME, -seed=$seed, -artifact_prefix=$artifact_dir/, corpus=$corpus_dir)"
|
||||||
"$binary_path" -max_total_time="$MAX_TOTAL_TIME" -artifact_prefix="$artifact_dir/" "$corpus_dir"
|
"$binary_path" -max_total_time="$MAX_TOTAL_TIME" -seed="$seed" -artifact_prefix="$artifact_dir/" "$corpus_dir"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> cargo +nightly fuzz run $target (-max_total_time=$MAX_TOTAL_TIME, -artifact_prefix=$artifact_dir/)"
|
echo "==> cargo +nightly fuzz run $target (-max_total_time=$MAX_TOTAL_TIME, -seed=$seed, -artifact_prefix=$artifact_dir/)"
|
||||||
cargo +nightly fuzz run "$target" -- -max_total_time="$MAX_TOTAL_TIME" -artifact_prefix="$artifact_dir/"
|
cargo +nightly fuzz run "$target" -- -max_total_time="$MAX_TOTAL_TIME" -seed="$seed" -artifact_prefix="$artifact_dir/"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
# where it stays for the rest of the job. On this repository that matters more
|
# where it stays for the rest of the job. On this repository that matters more
|
||||||
# than usual: pull_request jobs run on self-hosted runners and execute the PR's
|
# than usual: pull_request jobs run on self-hosted runners and execute the PR's
|
||||||
# own build.rs, proc-macros and tests, any of which can read that file. The
|
# own build.rs, proc-macros and tests, any of which can read that file. The
|
||||||
# Test and Lint job additionally holds actions: write, so its token can cancel
|
# post-failure cancellation job holds actions: write but never checks out
|
||||||
# runs and delete the Actions caches the whole pipeline depends on.
|
# repository code, so untrusted build scripts cannot read that token from Git.
|
||||||
#
|
#
|
||||||
# A checkout is exempt only when the token IS the credential the job needs —
|
# A checkout is exempt only when the token IS the credential the job needs —
|
||||||
# helm-package pushes to rustfs/helm with it. Mark those with a comment
|
# helm-package pushes to rustfs/helm with it. Mark those with a comment
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"realm": "rustfs-ci",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "rustfs-ci",
|
||||||
|
"secret": "rustfs-ci-secret",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "wrong-audience",
|
||||||
|
"secret": "wrong-audience-secret",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.test",
|
||||||
|
"emailVerified": true,
|
||||||
|
"enabled": true,
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "alice-password",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
RUSTFS_BINARY="${1:-${ROOT_DIR}/target/debug/rustfs}"
|
||||||
|
REALM_FIXTURE="${ROOT_DIR}/scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||||
|
KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak@sha256:6a7217a100bd3e5de4063a27a538ef999a3c5a88c4b4ec0ffc0a642aee7b2597}"
|
||||||
|
WORK_DIR="${RUNNER_TEMP:-/tmp}/rustfs-keycloak-live-${$}"
|
||||||
|
KEYCLOAK_CONTAINER="rustfs-keycloak-live-${$}"
|
||||||
|
RUSTFS_PID=""
|
||||||
|
mkdir -p "${WORK_DIR}"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
if [[ -n "${RUSTFS_PID}" ]] && kill -0 "${RUSTFS_PID}" 2>/dev/null; then
|
||||||
|
kill "${RUSTFS_PID}" 2>/dev/null || true
|
||||||
|
wait "${RUSTFS_PID}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "${status}" -ne 0 ]]; then
|
||||||
|
docker logs "${KEYCLOAK_CONTAINER}" >"${WORK_DIR}/keycloak.log" 2>&1 || true
|
||||||
|
echo "live-gate logs retained in ${WORK_DIR}" >&2
|
||||||
|
fi
|
||||||
|
docker rm -f "${KEYCLOAK_CONTAINER}" >/dev/null 2>&1 || true
|
||||||
|
if [[ "${status}" -eq 0 ]]; then
|
||||||
|
rm -rf "${WORK_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
for command in curl docker python3 awscurl; do
|
||||||
|
command -v "${command}" >/dev/null || {
|
||||||
|
echo "missing required command: ${command}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
[[ -x "${RUSTFS_BINARY}" ]] || {
|
||||||
|
echo "RustFS binary is not executable: ${RUSTFS_BINARY}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -f "${REALM_FIXTURE}" ]] || {
|
||||||
|
echo "Keycloak realm fixture is missing: ${REALM_FIXTURE}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import socket
|
||||||
|
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
print(sock.getsockname()[1])
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
KEYCLOAK_PORT="$(free_port)"
|
||||||
|
RUSTFS_PORT="$(free_port)"
|
||||||
|
KEYCLOAK_ORIGIN="http://127.0.0.1:${KEYCLOAK_PORT}"
|
||||||
|
ISSUER="${KEYCLOAK_ORIGIN}/realms/rustfs-ci"
|
||||||
|
DISCOVERY_URL="${ISSUER}/.well-known/openid-configuration"
|
||||||
|
RUSTFS_ORIGIN="http://127.0.0.1:${RUSTFS_PORT}"
|
||||||
|
|
||||||
|
docker run --detach --rm \
|
||||||
|
--name "${KEYCLOAK_CONTAINER}" \
|
||||||
|
--memory 1g \
|
||||||
|
--publish "127.0.0.1:${KEYCLOAK_PORT}:8080" \
|
||||||
|
--env KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||||
|
--env KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||||
|
--env KC_HEALTH_ENABLED=true \
|
||||||
|
--env "KC_HOSTNAME=${KEYCLOAK_ORIGIN}" \
|
||||||
|
--volume "${REALM_FIXTURE}:/opt/keycloak/data/import/rustfs-ci-realm.json:ro" \
|
||||||
|
"${KEYCLOAK_IMAGE}" start-dev --import-realm >/dev/null
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if curl --noproxy '*' -fsS "${DISCOVERY_URL}" >"${WORK_DIR}/discovery.json" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "$(docker inspect -f '{{.State.Running}}' "${KEYCLOAK_CONTAINER}" 2>/dev/null || true)" != "true" ]]; then
|
||||||
|
docker logs "${KEYCLOAK_CONTAINER}" >&2 || true
|
||||||
|
echo "Keycloak exited before discovery became ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl --noproxy '*' -fsS "${DISCOVERY_URL}" >"${WORK_DIR}/discovery.json"
|
||||||
|
python3 - "${WORK_DIR}/discovery.json" "${ISSUER}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as source:
|
||||||
|
discovery = json.load(source)
|
||||||
|
expected_issuer = sys.argv[2]
|
||||||
|
assert discovery.get("issuer") == expected_issuer, discovery
|
||||||
|
for field in ("token_endpoint", "jwks_uri"):
|
||||||
|
assert discovery.get(field), discovery
|
||||||
|
PY
|
||||||
|
|
||||||
|
mkdir -p "${WORK_DIR}/rustfs-data"
|
||||||
|
env \
|
||||||
|
NO_PROXY=127.0.0.1,localhost \
|
||||||
|
RUSTFS_ACCESS_KEY=rustfsadmin \
|
||||||
|
RUSTFS_SECRET_KEY=rustfsadmin \
|
||||||
|
RUSTFS_OUTBOUND_ALLOW_ORIGINS="${KEYCLOAK_ORIGIN}" \
|
||||||
|
RUSTFS_IDENTITY_OPENID_ENABLE=on \
|
||||||
|
RUSTFS_IDENTITY_OPENID_CONFIG_URL="${DISCOVERY_URL}" \
|
||||||
|
RUSTFS_IDENTITY_OPENID_ISSUER="${ISSUER}" \
|
||||||
|
RUSTFS_IDENTITY_OPENID_CLIENT_ID=rustfs-ci \
|
||||||
|
RUSTFS_IDENTITY_OPENID_CLIENT_SECRET=rustfs-ci-secret \
|
||||||
|
RUSTFS_IDENTITY_OPENID_SCOPES=openid,profile,email \
|
||||||
|
RUSTFS_IDENTITY_OPENID_ROLE_POLICY=consoleAdmin \
|
||||||
|
"${RUSTFS_BINARY}" --address "127.0.0.1:${RUSTFS_PORT}" "${WORK_DIR}/rustfs-data" \
|
||||||
|
>"${WORK_DIR}/rustfs.log" 2>&1 &
|
||||||
|
RUSTFS_PID=$!
|
||||||
|
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if curl --noproxy '*' -fsS "${RUSTFS_ORIGIN}/health/ready" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "${RUSTFS_PID}" 2>/dev/null; then
|
||||||
|
cat "${WORK_DIR}/rustfs.log" >&2
|
||||||
|
echo "RustFS exited before becoming ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl --noproxy '*' -fsS "${RUSTFS_ORIGIN}/health/ready" >/dev/null
|
||||||
|
|
||||||
|
token_for_client() {
|
||||||
|
local client_id="$1"
|
||||||
|
local client_secret="$2"
|
||||||
|
local response_file="${WORK_DIR}/token-${client_id}.json"
|
||||||
|
curl --noproxy '*' -fsS "${ISSUER}/protocol/openid-connect/token" \
|
||||||
|
--data-urlencode grant_type=password \
|
||||||
|
--data-urlencode "client_id=${client_id}" \
|
||||||
|
--data-urlencode "client_secret=${client_secret}" \
|
||||||
|
--data-urlencode username=alice \
|
||||||
|
--data-urlencode password=alice-password \
|
||||||
|
--data-urlencode scope=openid \
|
||||||
|
>"${response_file}"
|
||||||
|
python3 - "${response_file}" "${ISSUER}" "${client_id}" <<'PY'
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as source:
|
||||||
|
token = json.load(source)["id_token"]
|
||||||
|
payload = token.split(".")[1]
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
claims = json.loads(base64.urlsafe_b64decode(payload))
|
||||||
|
assert claims["iss"] == sys.argv[2], claims
|
||||||
|
audience = claims["aud"]
|
||||||
|
if isinstance(audience, str):
|
||||||
|
audience = [audience]
|
||||||
|
assert sys.argv[3] in audience, claims
|
||||||
|
print(token)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
GOOD_TOKEN="$(token_for_client rustfs-ci rustfs-ci-secret)"
|
||||||
|
GOOD_STATUS="$(curl --noproxy '*' -sS \
|
||||||
|
-D "${WORK_DIR}/sts-good.headers" \
|
||||||
|
-o "${WORK_DIR}/sts-good.xml" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
-X POST "${RUSTFS_ORIGIN}/" \
|
||||||
|
--data-urlencode Action=AssumeRoleWithWebIdentity \
|
||||||
|
--data-urlencode Version=2011-06-15 \
|
||||||
|
--data-urlencode DurationSeconds=900 \
|
||||||
|
--data-urlencode "WebIdentityToken=${GOOD_TOKEN}")"
|
||||||
|
[[ "${GOOD_STATUS}" == 200 ]] || {
|
||||||
|
cat "${WORK_DIR}/sts-good.xml" >&2
|
||||||
|
cat "${WORK_DIR}/rustfs.log" >&2
|
||||||
|
echo "expected valid Keycloak token to return HTTP 200, got ${GOOD_STATUS}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -Eiq '^content-type: application/xml' "${WORK_DIR}/sts-good.headers"
|
||||||
|
|
||||||
|
IFS=$'\t' read -r STS_ACCESS_KEY STS_SECRET_KEY STS_SESSION_TOKEN < <(
|
||||||
|
python3 - "${WORK_DIR}/sts-good.xml" <<'PY'
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
root = ET.parse(sys.argv[1]).getroot()
|
||||||
|
values = {}
|
||||||
|
for element in root.iter():
|
||||||
|
values[element.tag.rsplit("}", 1)[-1]] = element.text or ""
|
||||||
|
for field in ("AccessKeyId", "SecretAccessKey", "SessionToken", "Expiration", "SubjectFromWebIdentityToken"):
|
||||||
|
assert values.get(field), values
|
||||||
|
print("\t".join(values[field] for field in ("AccessKeyId", "SecretAccessKey", "SessionToken")))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
awscurl --fail-with-body --service s3 --region us-east-1 \
|
||||||
|
--access_key "${STS_ACCESS_KEY}" \
|
||||||
|
--secret_key "${STS_SECRET_KEY}" \
|
||||||
|
--security_token "${STS_SESSION_TOKEN}" \
|
||||||
|
"${RUSTFS_ORIGIN}/" >"${WORK_DIR}/list-buckets.xml"
|
||||||
|
grep -q '<ListAllMyBucketsResult' "${WORK_DIR}/list-buckets.xml"
|
||||||
|
|
||||||
|
TAMPERED_TOKEN="$(python3 - "${GOOD_TOKEN}" <<'PY'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
parts = sys.argv[1].split(".")
|
||||||
|
assert len(parts) == 3 and parts[2]
|
||||||
|
parts[2] = ("A" if parts[2][0] != "A" else "B") + parts[2][1:]
|
||||||
|
print(".".join(parts))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
TAMPERED_STATUS="$(curl --noproxy '*' -sS \
|
||||||
|
-o "${WORK_DIR}/sts-tampered.xml" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
-X POST "${RUSTFS_ORIGIN}/" \
|
||||||
|
--data-urlencode Action=AssumeRoleWithWebIdentity \
|
||||||
|
--data-urlencode Version=2011-06-15 \
|
||||||
|
--data-urlencode DurationSeconds=900 \
|
||||||
|
--data-urlencode "WebIdentityToken=${TAMPERED_TOKEN}")"
|
||||||
|
[[ "${TAMPERED_STATUS}" == 403 ]] || {
|
||||||
|
cat "${WORK_DIR}/sts-tampered.xml" >&2
|
||||||
|
echo "expected tampered token to return HTTP 403, got ${TAMPERED_STATUS}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -q '<Code>AccessDenied</Code>' "${WORK_DIR}/sts-tampered.xml"
|
||||||
|
|
||||||
|
BAD_TOKEN="$(token_for_client wrong-audience wrong-audience-secret)"
|
||||||
|
BAD_STATUS="$(curl --noproxy '*' -sS \
|
||||||
|
-o "${WORK_DIR}/sts-bad.xml" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
-X POST "${RUSTFS_ORIGIN}/" \
|
||||||
|
--data-urlencode Action=AssumeRoleWithWebIdentity \
|
||||||
|
--data-urlencode Version=2011-06-15 \
|
||||||
|
--data-urlencode DurationSeconds=900 \
|
||||||
|
--data-urlencode "WebIdentityToken=${BAD_TOKEN}")"
|
||||||
|
[[ "${BAD_STATUS}" == 403 ]] || {
|
||||||
|
cat "${WORK_DIR}/sts-bad.xml" >&2
|
||||||
|
echo "expected wrong-audience token to return HTTP 403, got ${BAD_STATUS}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -q '<Code>AccessDenied</Code>' "${WORK_DIR}/sts-bad.xml"
|
||||||
|
|
||||||
|
echo "OIDC Keycloak live gate passed"
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
|
TMP_ROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
mkdir -p "$TMP_ROOT/bin"
|
||||||
|
cat > "$TMP_ROOT/bin/path_containment" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' "$@" > "$FAKE_ARGS_FILE"
|
||||||
|
EOF
|
||||||
|
chmod +x "$TMP_ROOT/bin/path_containment"
|
||||||
|
|
||||||
|
EXPLICIT_ARTIFACTS="$TMP_ROOT/explicit-artifacts"
|
||||||
|
FAKE_ARGS_FILE="$TMP_ROOT/explicit-args" \
|
||||||
|
FUZZ_TARGET=path_containment \
|
||||||
|
FUZZ_SEED=123456789 \
|
||||||
|
MAX_TOTAL_TIME=7 \
|
||||||
|
ARTIFACT_ROOT="$EXPLICIT_ARTIFACTS" \
|
||||||
|
SKIP_BUILD=1 \
|
||||||
|
USE_PREBUILT_BINARY=1 \
|
||||||
|
PREBUILT_BINARY_DIR="$TMP_ROOT/bin" \
|
||||||
|
"$REPO_ROOT/scripts/fuzz/run.sh"
|
||||||
|
|
||||||
|
EXPLICIT_MANIFEST="$EXPLICIT_ARTIFACTS/path_containment/run-manifest.txt"
|
||||||
|
test -f "$EXPLICIT_MANIFEST"
|
||||||
|
grep -Fx -- '-seed=123456789' "$TMP_ROOT/explicit-args"
|
||||||
|
grep -Fx 'target=path_containment' "$EXPLICIT_MANIFEST"
|
||||||
|
grep -Fx 'seed=123456789' "$EXPLICIT_MANIFEST"
|
||||||
|
grep -Fx 'max_total_time=7' "$EXPLICIT_MANIFEST"
|
||||||
|
grep -Fx "git_revision=$(git -C "$REPO_ROOT" rev-parse HEAD)" "$EXPLICIT_MANIFEST"
|
||||||
|
grep -E '^git_dirty=(true|false)$' "$EXPLICIT_MANIFEST"
|
||||||
|
grep -Fx 'runner_mode=prebuilt' "$EXPLICIT_MANIFEST"
|
||||||
|
|
||||||
|
AUTO_ARTIFACTS="$TMP_ROOT/auto-artifacts"
|
||||||
|
FAKE_ARGS_FILE="$TMP_ROOT/auto-args" \
|
||||||
|
FUZZ_TARGET=path_containment \
|
||||||
|
MAX_TOTAL_TIME=1 \
|
||||||
|
ARTIFACT_ROOT="$AUTO_ARTIFACTS" \
|
||||||
|
SKIP_BUILD=1 \
|
||||||
|
USE_PREBUILT_BINARY=1 \
|
||||||
|
PREBUILT_BINARY_DIR="$TMP_ROOT/bin" \
|
||||||
|
"$REPO_ROOT/scripts/fuzz/run.sh"
|
||||||
|
|
||||||
|
AUTO_MANIFEST="$AUTO_ARTIFACTS/path_containment/run-manifest.txt"
|
||||||
|
auto_seed=$(sed -n 's/^seed=//p' "$AUTO_MANIFEST")
|
||||||
|
case "$auto_seed" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "automatic seed was not recorded as an unsigned decimal integer: $auto_seed" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
grep -Fx -- "-seed=$auto_seed" "$TMP_ROOT/auto-args"
|
||||||
|
|
||||||
|
cat > "$TMP_ROOT/bin/cargo" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' "$@" > "$FAKE_CARGO_ARGS_FILE"
|
||||||
|
EOF
|
||||||
|
chmod +x "$TMP_ROOT/bin/cargo"
|
||||||
|
|
||||||
|
CARGO_ARTIFACTS="$TMP_ROOT/cargo-artifacts"
|
||||||
|
PATH="$TMP_ROOT/bin:$PATH" \
|
||||||
|
FAKE_CARGO_ARGS_FILE="$TMP_ROOT/cargo-args" \
|
||||||
|
FUZZ_TARGET=path_containment \
|
||||||
|
FUZZ_SEED=987654321 \
|
||||||
|
MAX_TOTAL_TIME=9 \
|
||||||
|
ARTIFACT_ROOT="$CARGO_ARTIFACTS" \
|
||||||
|
SKIP_BUILD=1 \
|
||||||
|
"$REPO_ROOT/scripts/fuzz/run.sh"
|
||||||
|
|
||||||
|
CARGO_MANIFEST="$CARGO_ARTIFACTS/path_containment/run-manifest.txt"
|
||||||
|
grep -Fx '+nightly' "$TMP_ROOT/cargo-args"
|
||||||
|
grep -Fx 'fuzz' "$TMP_ROOT/cargo-args"
|
||||||
|
grep -Fx 'run' "$TMP_ROOT/cargo-args"
|
||||||
|
grep -Fx 'path_containment' "$TMP_ROOT/cargo-args"
|
||||||
|
grep -Fx -- '-seed=987654321' "$TMP_ROOT/cargo-args"
|
||||||
|
grep -Fx 'seed=987654321' "$CARGO_MANIFEST"
|
||||||
|
grep -Fx 'runner_mode=cargo-fuzz' "$CARGO_MANIFEST"
|
||||||
|
|
||||||
|
for invalid_seed in 0 0123 not-a-number; do
|
||||||
|
if FUZZ_TARGET=path_containment FUZZ_SEED="$invalid_seed" SKIP_BUILD=1 "$REPO_ROOT/scripts/fuzz/run.sh" >/dev/null 2>&1; then
|
||||||
|
echo "invalid FUZZ_SEED was accepted: $invalid_seed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "fuzz runner seed manifest tests passed"
|
||||||
Reference in New Issue
Block a user