mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-12 05:04:18 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbd192242f |
@@ -66,8 +66,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_split_guard_rules.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_bucket_usecase.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_ecfs_legacy_edge.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
|
||||
@@ -137,7 +137,7 @@ filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::complete::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
# These handler/store fixtures and the relocated-pool GET compose deep storage
|
||||
@@ -455,7 +455,7 @@ filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::complete::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
|
||||
@@ -96,7 +96,7 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: workspace-lint-and-build, workspace-tests (workspace and ILM), build-rustfs-debug-binary,
|
||||
# Readers: workspace-clippy, workspace-lint-and-build, workspace-tests (workspace and ILM), build-rustfs-debug-binary,
|
||||
# e2e-tests, e2e-full.
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
|
||||
+67
-32
@@ -95,7 +95,6 @@ jobs:
|
||||
packages: ${{ steps.scope.outputs.packages }}
|
||||
full_validation: ${{ steps.scope.outputs.full_validation || 'true' }}
|
||||
rehearse_shards: ${{ steps.scope.outputs.rehearse_shards || 'false' }}
|
||||
gateway_smoke: ${{ steps.scope.outputs.gateway_smoke || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
@@ -116,8 +115,8 @@ jobs:
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
echo "Base CI policy unavailable; running the full matrix."
|
||||
fi
|
||||
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=3$' "$GITHUB_OUTPUT"; then
|
||||
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'gateway_smoke=true' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
|
||||
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=4$' "$GITHUB_OUTPUT"; then
|
||||
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
|
||||
echo "Base policy does not cover this workflow contract; retaining the full matrix."
|
||||
fi
|
||||
- name: Retain verification plan
|
||||
@@ -171,6 +170,67 @@ jobs:
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
# Clippy runs beside the test-binary build instead of in front of it. The two
|
||||
# share no compiler output: Clippy writes check-mode metadata, the archive
|
||||
# needs codegen and linked test binaries, and both restore the third-party
|
||||
# artifacts of their own mode from ci-dev. As one job the lint held the
|
||||
# archive, and every test that consumes it, back by its whole duration.
|
||||
# scripts/ci_gate.py keeps this job in the Test and Lint aggregate and out
|
||||
# of the archive consumer's dependency chain.
|
||||
workspace-clippy:
|
||||
name: Workspace Clippy
|
||||
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
# Reads the ci-dev cache that cache-warm.yml writes on this image, and the
|
||||
# all-targets check of the workspace does not fit a hosted runner's disk.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
# Checkout otherwise writes the token into .git/config, where a PR's
|
||||
# own build.rs or proc-macro could read it back out.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
# Read-only, like every other ci-dev reader; cache-warm.yml warms the
|
||||
# check artifacts of this exact invocation.
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# Clippy gates every event, pull requests included. When it ran only after
|
||||
# merge, a lint regression merged freely and then failed every main run
|
||||
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
|
||||
# writes the same invocation's check artifacts into ci-dev, so a pull
|
||||
# request re-checks only the crates its diff touches. Main keeps the step
|
||||
# because pull requests can merge without being up to date with main.
|
||||
- name: Run clippy lints
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
|
||||
|
||||
- name: Upload lint diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: workspace-clippy-diagnostics-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: artifacts/workspace
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
# Keeps its job ID and check name: it is the archive producer that
|
||||
# workspace-tests and scripts/check_test_wiring.py bind to. The lint half
|
||||
# is workspace-clippy above; this job builds the test archive, then runs
|
||||
# the doctests and the log-analyzer anchor check on the same build output.
|
||||
workspace-lint-and-build:
|
||||
name: Workspace Lint and Build
|
||||
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
@@ -212,18 +272,6 @@ jobs:
|
||||
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||
} > artifacts/workspace/run-metadata.txt
|
||||
|
||||
# Clippy gates every event, pull requests included. When it ran only after
|
||||
# merge, a lint regression merged freely and then failed every main run
|
||||
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
|
||||
# writes the same invocation's check artifacts into ci-dev, so a pull
|
||||
# request re-checks only the crates its diff touches. Main keeps the step
|
||||
# because pull requests can merge without being up to date with main.
|
||||
- name: Run clippy lints
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
|
||||
|
||||
- name: Archive workspace test binaries
|
||||
env:
|
||||
# Workspace test compilation has high per-process memory use.
|
||||
@@ -274,7 +322,7 @@ jobs:
|
||||
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
|
||||
# verbatim in the source tree (log message drifted without updating the
|
||||
# rule). Placed here where the workspace — including the la-dump-anchors
|
||||
# bin — is already built by the clippy/test steps above.
|
||||
# bin's dependencies — is already built by the test archive step above.
|
||||
- name: Check log-analyzer rule anchors
|
||||
run: ./scripts/check_log_analyzer_rules.sh
|
||||
|
||||
@@ -1095,24 +1143,9 @@ jobs:
|
||||
# Test and Lint aggregate and becomes required at T2.10. The test-binary
|
||||
# download, identity, membership, run and completeness steps must stay
|
||||
# identical to e2e-tests; scripts/ci_gate.py enforces that.
|
||||
#
|
||||
# When it runs: every push to main or gateway/integration, merge group,
|
||||
# nightly schedule and manual dispatch, and every pull request that changes a
|
||||
# gateway-stack input (GATEWAY_SMOKE_PATHS and the rustfs-gateway pins in
|
||||
# scripts/ci_gate.py, published by Select CI scope as gateway_smoke).
|
||||
# Why it is skipped on every other pull request: until T2.10 the gateway
|
||||
# stack does not serve the whole smoke suite, so the lane fails on any
|
||||
# revision. A pull request that touches none of its inputs can neither cause
|
||||
# nor fix that failure, and a red check there says nothing about the change.
|
||||
# Skipped is the honest state; a failure must never be rendered green, so no
|
||||
# step here may carry continue-on-error. A selection that is missing or
|
||||
# unreadable runs the lane. At T2.10, when the lane becomes required, it
|
||||
# must run on every pull request again: delete the selection, do not extend
|
||||
# it.
|
||||
e2e-tests-gateway:
|
||||
name: e2e-smoke (gateway, report-only until T2.10)
|
||||
needs: [ classify-changes, build-rustfs-debug-binary ]
|
||||
if: github.event_name != 'pull_request' || needs.classify-changes.outputs.gateway_smoke != 'false'
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
@@ -1586,6 +1619,7 @@ jobs:
|
||||
- classify-changes
|
||||
- typos
|
||||
- quick-checks
|
||||
- workspace-clippy
|
||||
- workspace-lint-and-build
|
||||
- workspace-tests
|
||||
- workspace-shards
|
||||
@@ -1624,6 +1658,7 @@ jobs:
|
||||
- required-checks
|
||||
- typos
|
||||
- quick-checks
|
||||
- workspace-clippy
|
||||
- workspace-lint-and-build
|
||||
- workspace-tests
|
||||
- core-contract
|
||||
|
||||
@@ -43,7 +43,7 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
|
||||
|
||||
`CopyObject` with `MetadataDirective=REPLACE` clears standard metadata fields that the request omits, including `Content-Type`; it does not retain the source type or infer a default. Clients requiring a MIME type on the copied object must send `Content-Type` with the replacement metadata. This contract is covered by `crates/e2e_test/src/copy_object_metadata_test.rs`. A client test that expects an implicit `application/octet-stream` does not match this behavior.
|
||||
|
||||
The MinIO-style `metadata=true` listing extension returns user metadata names without the HTTP `x-amz-meta-` prefix. It is not the standard S3 `ListObjectsV2` response. Clients that expect canonical HTTP header names in `UserMetadata` must normalize the names at that boundary; ordinary HEAD/GET metadata is unaffected. See `rustfs/src/app/bucket_usecase/listing.rs` and its serialization tests.
|
||||
The MinIO-style `metadata=true` listing extension returns user metadata names without the HTTP `x-amz-meta-` prefix. It is not the standard S3 `ListObjectsV2` response. Clients that expect canonical HTTP header names in `UserMetadata` must normalize the names at that boundary; ordinary HEAD/GET metadata is unaffected. See `rustfs/src/app/bucket_usecase.rs` and its serialization tests.
|
||||
|
||||
## Conditional Bulk Deletion
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ PRs changing only Rust sources under `crates/e2e_test/src/` and known E2E select
|
||||
|
||||
Specialized boundary, hardware and alternate-feature lanes run only on the scheduled (nightly) and manual full-validation runs: `Connect Feature Boundaries`, `io_uring Integration (real)`, and the rio-v2, Swift, SFTP and HTTP/3 feature lanes. They do not gate a merge. The nightly run reports their regressions within a day, and repeating these builds on every PR revision and merge group bought no earlier signal worth the runner occupancy (Connect alone measured 53.5 median minutes per run and occasionally set the wall). PRs, merge groups and main pushes run the default workspace and retain ILM, S3, E2E smoke, the short core contract and the debug build. The aggregate requires the specialized lanes to be skipped on pull-request, merge-group and push events and successful on scheduled and manual events. For changes to these features, dispatch `ci.yml` against the candidate branch before merging when that signal is needed, including PRs targeting `gateway/integration`: its pushes run the default workspace like main's, not the specialized lanes. Scheduled results supplement the main baseline and do not replace it.
|
||||
|
||||
Workspace Clippy runs on every event with a selected workspace Rust job, pull requests included, in `Workspace Lint and Build`. That job also creates the exact test archive and runs doctests; `Workspace tests` consumes the archive without rebuilding it. `cache-warm.yml` warms Clippy's check artifacts under `ci-dev` with the same invocation to reuse compatible dependency artifacts. `make pre-commit` does not run Clippy. Select local Clippy targets, features, and documentation exemptions from the root `AGENTS.md` verification tier; the workspace command below reproduces the CI lane and is not a universal pre-push requirement.
|
||||
Workspace Clippy runs on every event with a selected workspace Rust job, pull requests included, in its own `Workspace Clippy` job. It runs beside `Workspace Lint and Build` instead of in front of it, because check-mode metadata and the linked test binaries share no compiler output; `scripts/ci_gate.py --check-workflow` rejects a workflow in which the archive producer or any archive consumer waits for the lint, or in which the lint leaves the aggregate. `Workspace Lint and Build` keeps its name as the archive producer: it creates the exact test archive and runs doctests; `Workspace tests` consumes the archive without rebuilding it. `cache-warm.yml` warms Clippy's check artifacts under `ci-dev` with the same invocation to reuse compatible dependency artifacts. `make pre-commit` does not run Clippy. Select local Clippy targets, features, and documentation exemptions from the root `AGENTS.md` verification tier; the workspace command below reproduces the CI lane and is not a universal pre-push requirement.
|
||||
|
||||
Serial ILM tests run at the end of `workspace-tests` for the `full` scope, consuming the same verified archive as ordinary workspace tests. The existing ignored-test filter and `-j1` remain; `--profile default` keeps the no-retry execution and preserves the separate core CI JUnit report. Any ILM failure fails the consumer and required aggregate. Logs remain in the workspace test evidence artifact. There is no separate ILM compilation or cache restore.
|
||||
|
||||
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Lint and Build` and `Workspace tests` are the ordinary Rust producer and consumer, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. A report-only lane is registered in `REPORT_ONLY_JOBS` instead: it stays out of the aggregate, must keep its distinguishing environment, and must keep the steps it reruns identical to the required job it mirrors. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
|
||||
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Clippy` is the workspace lint, `Workspace Lint and Build` and `Workspace tests` are the ordinary Rust producer and consumer, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. A report-only lane is registered in `REPORT_ONLY_JOBS` instead: it stays out of the aggregate, must keep its distinguishing environment, and must keep the steps it reruns identical to the required job it mirrors. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
|
||||
|
||||
The `gateway/integration` ruleset (`24663286`) requires the same three contexts with the same review rules for the s3s to gateway integration branch (rustfs/backlog#2734). It omits `required_linear_history` because `gateway-integration-sync.yml` lands `--no-ff` merge commits from `main` on that branch every weekday, and it names the `rustfs-cla-assistant` app as the bypass actor that pushes them. `ci.yml` runs on pull requests targeting either branch and on pushes to either branch; a push to `gateway/integration` also runs `e2e-full`, because the daily merge commit lands without a pull request and must be proven like a `main` push.
|
||||
|
||||
@@ -50,7 +50,7 @@ The four site-replication state-writer concurrency proofs reserve the available
|
||||
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
||||
|---|---|---|---|---|
|
||||
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
||||
| PR, non-doc change, and every non-PR event | `Workspace Lint and Build`, `Workspace tests` | `ci.yml` `workspace-lint-and-build` / `workspace-tests` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --exclude e2e_test --doc`; migration evidence check described below |
|
||||
| PR, non-doc change, and every non-PR event | `Workspace Clippy`, `Workspace Lint and Build`, `Workspace tests` | `ci.yml` `workspace-clippy` / `workspace-lint-and-build` / `workspace-tests` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --exclude e2e_test --doc`; migration evidence check described below |
|
||||
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
|
||||
| Full selection | `Workspace tests` (serial ILM step) | `ci.yml` `workspace-tests` | Via aggregate | exact `Run ignored ILM integration tests serially` command in the job |
|
||||
| Nightly schedule, manual dispatch | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)`, `Test and Lint (http3)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo nextest run` with the job's `--features` |
|
||||
@@ -59,7 +59,7 @@ The four site-replication state-writer concurrency proofs reserve the available
|
||||
| Nightly schedule, manual dispatch, full selection | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` under the same wrapper; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `Core contract` | `ci.yml` `core-contract` | Via aggregate | existing write/read, anonymous denial and restart-byte tests via `core-contract` profile and the E2E archive |
|
||||
| Push to `main` or `gateway/integration`, merge group, nightly schedule, manual dispatch; PR only when it changes a gateway-stack input (`GATEWAY_SMOKE_PATHS` or a `rustfs-gateway*` pin, `scripts/ci_gate.py`) | `e2e-smoke (gateway, report-only until T2.10)` | `ci.yml` `e2e-tests-gateway` | Report-only until rustfs/backlog#2734 T2.10 makes it required; `continue-on-error`, outside the aggregate; skipped, never green, on PRs that touch no gateway-stack input. At T2.10, when the lane becomes required, it must run on every pull request again — delete this selection, do not extend it | `RUSTFS_S3_STACK=gateway python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`; `scripts/ci_gate.py` keeps its archive and run steps identical to `e2e-tests`, which sets `RUSTFS_S3_STACK=legacy` on this branch |
|
||||
| PR targeting or push to `gateway/integration`, non-doc change | `e2e-smoke (gateway, report-only until T2.10)` | `ci.yml` `e2e-tests-gateway` | Report-only until rustfs/backlog#2734 T2.10 makes it required; `continue-on-error`, outside the aggregate | `RUSTFS_S3_STACK=gateway python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`; `scripts/ci_gate.py` keeps its archive and run steps identical to `e2e-tests`, which sets `RUSTFS_S3_STACK=legacy` on this branch |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
|
||||
+5386
-39
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket accelerate configuration use-case operations (reserved for the next batch).
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket ACL use-case operations (reserved for the next batch).
|
||||
@@ -1,488 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket create, delete, head and location use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Answer a `CreateBucket` for a bucket that already exists.
|
||||
///
|
||||
/// A deployment owns one bucket namespace, so every requester that passed
|
||||
/// authorization belongs to the same owner as the bucket's creator; there is no
|
||||
/// second account that could trigger `BucketAlreadyExists`. That leaves the AWS
|
||||
/// account-owner semantics: the default region returns the legacy idempotent
|
||||
/// `200 OK`, any other region returns `BucketAlreadyOwnedByYou`.
|
||||
fn create_bucket_exists_response(region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
|
||||
if region == RUSTFS_REGION {
|
||||
return Ok(S3Response::new(CreateBucketOutput::default()));
|
||||
}
|
||||
|
||||
Err(S3Error::with_message(
|
||||
S3ErrorCode::BucketAlreadyOwnedByYou,
|
||||
"The requested bucket already exists.",
|
||||
))
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
fn create_bucket_exists_result(&self) -> S3Result<S3Response<CreateBucketOutput>> {
|
||||
let region = self
|
||||
.global_region()
|
||||
.map(|region| region.to_string())
|
||||
.unwrap_or_else(|| RUSTFS_REGION.to_string());
|
||||
create_bucket_exists_response(®ion)
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
level = "debug",
|
||||
skip(self, req),
|
||||
fields(start_time=?time::OffsetDateTime::now_utc())
|
||||
)]
|
||||
pub async fn execute_create_bucket(&self, req: S3Request<CreateBucketInput>) -> S3Result<S3Response<CreateBucketOutput>> {
|
||||
rustfs_io_metrics::s3_http_metrics::observe_s3_http_operation(S3Operation::CreateBucket);
|
||||
if let Some(location) = req
|
||||
.input
|
||||
.create_bucket_configuration
|
||||
.as_ref()
|
||||
.and_then(|config| config.location_constraint.as_ref())
|
||||
.map(BucketLocationConstraint::as_str)
|
||||
.filter(|location| !location.is_empty())
|
||||
{
|
||||
let location = if location == "EU" { "eu-west-1" } else { location };
|
||||
let region = self.global_region();
|
||||
if location != region.as_ref().map_or(RUSTFS_REGION, Region::as_str) {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidLocationConstraint,
|
||||
"The specified location constraint does not match this server's region.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// An existing bucket needs no mutation. Answer before the mutation queue and the
|
||||
// bucket's exclusive locks, which wait for every in-flight object write.
|
||||
if req_info_ref(&req).is_ok()
|
||||
&& let Some(store) = self.object_store()
|
||||
&& store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
|
||||
let result = self.create_bucket_exists_result();
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
let usecase = self.clone();
|
||||
await_bucket_usecase_on_fresh_task("bucket creation", move |mutation_permit| async move {
|
||||
usecase.execute_create_bucket_inner(req, mutation_permit).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_create_bucket_inner(
|
||||
&self,
|
||||
req: S3Request<CreateBucketInput>,
|
||||
mutation_permit: OwnedSemaphorePermit,
|
||||
) -> S3Result<S3Response<CreateBucketOutput>> {
|
||||
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
if req_info_ref(&req).is_err() {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
|
||||
}
|
||||
let CreateBucketInput {
|
||||
bucket,
|
||||
object_lock_enabled_for_bucket,
|
||||
..
|
||||
} = req.input;
|
||||
let lock_enabled = object_lock_enabled_for_bucket.is_some_and(|v| v);
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
// Keep the local namespace mutation and its peer hook ordered across
|
||||
// every node in this site. Otherwise a delete waiting for repair
|
||||
// coordination can arrive after this create on remote sites.
|
||||
let operation_bucket = bucket.clone();
|
||||
let operation_store = store.clone();
|
||||
let make_result = with_site_replication_bucket_mutation_lock(store, &bucket, move || async move {
|
||||
let make_result = operation_store
|
||||
.make_bucket(
|
||||
&operation_bucket,
|
||||
&MakeBucketOptions {
|
||||
force_create: false,
|
||||
lock_enabled,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if make_result.is_ok() {
|
||||
crate::storage::invalidate_bucket_validation_cache(&operation_bucket);
|
||||
if let Err(err) = site_replication_make_bucket_hook(&operation_bucket, lock_enabled).await {
|
||||
warn!(bucket = %operation_bucket, error = ?err, "site replication make bucket hook failed");
|
||||
}
|
||||
}
|
||||
make_result
|
||||
})
|
||||
.await?;
|
||||
|
||||
// The storage/site mutation and its lock fences have finished. Keep
|
||||
// the operation's in-flight slot while peers reload, without holding
|
||||
// an execution slot needed by other creates and deletes.
|
||||
drop(mutation_permit);
|
||||
|
||||
match make_result {
|
||||
Ok(()) => {
|
||||
// A peer may have cached this bucket as missing before the
|
||||
// create. Refresh peer metadata after the namespace and
|
||||
// metadata commit fences are released so the peer reload can
|
||||
// acquire its bucket namespace read lock without an ABBA
|
||||
// interleaving with creation.
|
||||
notify_bucket_metadata_reload_with_context(
|
||||
bucket.clone(),
|
||||
"create bucket",
|
||||
request_context,
|
||||
false,
|
||||
self.context.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(StorageError::BucketExists(_)) => {
|
||||
let result = self.create_bucket_exists_result();
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
Err(e) => return Err(ApiError::from(e).into()),
|
||||
}
|
||||
|
||||
let output = CreateBucketOutput::default();
|
||||
counter!("rustfs_create_bucket_total").increment(1);
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_delete_bucket(&self, req: S3Request<DeleteBucketInput>) -> S3Result<S3Response<DeleteBucketOutput>> {
|
||||
rustfs_io_metrics::s3_http_metrics::observe_s3_http_operation(S3Operation::DeleteBucket);
|
||||
let usecase = self.clone();
|
||||
await_bucket_usecase_on_fresh_task("bucket deletion", move |mutation_permit| async move {
|
||||
let _mutation_permit = mutation_permit;
|
||||
usecase.execute_delete_bucket_inner(req).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_delete_bucket_inner(
|
||||
&self,
|
||||
mut req: S3Request<DeleteBucketInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketOutput>> {
|
||||
let helper = OperationHelper::new(&req, EventName::BucketRemoved, S3Operation::DeleteBucket);
|
||||
let input = req.input.clone();
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let force = match force_delete_header(&req.headers) {
|
||||
Ok(value) => value.unwrap_or(false),
|
||||
Err(_) => return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Invalid force-delete header value")),
|
||||
};
|
||||
|
||||
if force {
|
||||
authorize_request(&mut req, Action::S3Action(S3Action::ForceDeleteBucketAction)).await?;
|
||||
}
|
||||
|
||||
// Keep the local namespace mutation and its peer hook ordered across
|
||||
// every node in this site so an older delete cannot overtake a new
|
||||
// same-name make while it waits for repair coordination.
|
||||
let operation_bucket = input.bucket.clone();
|
||||
let operation_store = store.clone();
|
||||
with_site_replication_bucket_mutation_lock(store, &input.bucket, move || async move {
|
||||
let intent = prepare_site_replication_delete_bucket(&operation_bucket, force).await?;
|
||||
let delete_result = operation_store
|
||||
.delete_bucket(
|
||||
&operation_bucket,
|
||||
&DeleteBucketOptions {
|
||||
force,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match delete_result {
|
||||
Ok(()) => {
|
||||
crate::storage::invalidate_bucket_validation_cache(&operation_bucket);
|
||||
if let Some(intent) = intent
|
||||
&& let Err(err) = commit_site_replication_delete_bucket(&intent).await
|
||||
{
|
||||
warn!(bucket = %operation_bucket, error = ?err, "site replication delete bucket hook failed");
|
||||
}
|
||||
Ok::<(), S3Error>(())
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(intent) = intent {
|
||||
cancel_site_replication_delete_bucket(intent).await;
|
||||
}
|
||||
Err(S3Error::from(ApiError::from(err)))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Drop every cached object body for the now-deleted bucket so dead
|
||||
// bytes do not sit resident until TTL. Covers both the normal and the
|
||||
// force-delete path, which share this single delete_bucket call
|
||||
// (ODC-28, backlog#1133).
|
||||
let cache_adapter = current_object_data_cache_for_context(self.context.as_deref());
|
||||
let _ = invalidate_object_data_cache_bucket_after_delete(&cache_adapter, &input.bucket).await;
|
||||
|
||||
// Re-evaluate lifecycle and replication after bucket removal.
|
||||
rustfs_scanner::record_scanner_maintenance_change(&input.bucket);
|
||||
|
||||
// Notify peers to drop their cached metadata for the now-deleted bucket.
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
notify_bucket_metadata_delete(input.bucket.clone(), request_context);
|
||||
|
||||
let result = Ok(S3Response::new(DeleteBucketOutput {}));
|
||||
let _ = helper.complete(&result);
|
||||
result
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_head_bucket(&self, req: S3Request<HeadBucketInput>) -> S3Result<S3Response<HeadBucketOutput>> {
|
||||
record_s3_op(S3Operation::HeadBucket);
|
||||
let input = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(HeadBucketOutput::default()))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_bucket_location(
|
||||
&self,
|
||||
req: S3Request<GetBucketLocationInput>,
|
||||
) -> S3Result<S3Response<GetBucketLocationOutput>> {
|
||||
let input = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let location_constraint = self
|
||||
.global_region()
|
||||
.filter(|region| region.as_str() != RUSTFS_REGION)
|
||||
.map(|region| BucketLocationConstraint::from(region.to_string()));
|
||||
Ok(S3Response::new(GetBucketLocationOutput { location_constraint }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::{build_request, build_request_with_req_info, s3_op_total};
|
||||
use http::Method;
|
||||
|
||||
#[test]
|
||||
fn create_bucket_exists_response_returns_ok_in_default_region() {
|
||||
let response = create_bucket_exists_response(RUSTFS_REGION).expect("recreate in us-east-1 should succeed");
|
||||
assert_eq!(response.output.location, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bucket_exists_response_returns_owned_by_you_outside_default_region() {
|
||||
let err = create_bucket_exists_response("eu-west-1").expect_err("recreate outside us-east-1 should report ownership");
|
||||
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyOwnedByYou);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_create_bucket_returns_internal_error_when_store_uninitialized() {
|
||||
let input = CreateBucketInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("PUT");
|
||||
let mut call = Box::pin(usecase.execute_create_bucket(req));
|
||||
let err = std::future::poll_fn(|cx| request.in_scope(|| call.as_mut().poll(cx)))
|
||||
.await
|
||||
.expect_err("an uninitialized store must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(
|
||||
request.in_scope(rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name),
|
||||
Some("s3:CreateBucket")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn execute_create_bucket_on_existing_bucket_does_not_wait_for_bucket_lifecycle_lock() {
|
||||
crate::app::gating_test_env::run_large_stack_test("create-existing-bucket", || async {
|
||||
let context = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("create-existing-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
|
||||
// Object writes hold this lock shared; an exclusive guard exposes any lifecycle-lock wait.
|
||||
let _lifecycle_guard = store
|
||||
.lock_bucket_for_recursive_delete(&bucket)
|
||||
.await
|
||||
.expect("hold the bucket lifecycle lock");
|
||||
let usecase = DefaultBucketUsecase::with_context(Some(context));
|
||||
|
||||
// The requester's owner flag must not change the duplicate response:
|
||||
// one deployment has a single bucket owner.
|
||||
for (requester_is_owner, location, expected_error) in [
|
||||
(true, None, None),
|
||||
(false, None, None),
|
||||
(true, Some("ap-south-1"), Some(S3ErrorCode::InvalidLocationConstraint)),
|
||||
] {
|
||||
let input = CreateBucketInput {
|
||||
bucket: bucket.clone(),
|
||||
create_bucket_configuration: location.map(|location| s3s_crate::dto::CreateBucketConfiguration {
|
||||
location_constraint: Some(BucketLocationConstraint::from(location.to_string())),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let req = build_request_with_req_info(
|
||||
input,
|
||||
Method::PUT,
|
||||
ReqInfo {
|
||||
is_owner: requester_is_owner,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_create_bucket(req))
|
||||
.await
|
||||
.expect("CreateBucket on an existing bucket must not wait for the bucket lifecycle lock");
|
||||
match expected_error {
|
||||
Some(code) => assert_eq!(result.expect_err("invalid request must fail").code(), &code),
|
||||
None => {
|
||||
result.expect("recreating a bucket in us-east-1 returns the idempotent success");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let req = build_request(
|
||||
CreateBucketInput {
|
||||
bucket,
|
||||
..Default::default()
|
||||
},
|
||||
Method::PUT,
|
||||
);
|
||||
let err = tokio::time::timeout(Duration::from_secs(2), usecase.execute_create_bucket(req))
|
||||
.await
|
||||
.expect("missing request info must fail before storage mutation")
|
||||
.expect_err("an existing bucket must not bypass request-info validation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn execute_create_bucket_creates_missing_bucket() {
|
||||
crate::app::gating_test_env::run_large_stack_test("create-missing-bucket", || async {
|
||||
let context = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("create-missing-{}", uuid::Uuid::new_v4().simple());
|
||||
let input = CreateBucketInput::builder().bucket(bucket.clone()).build().unwrap();
|
||||
let req = build_request_with_req_info(
|
||||
input,
|
||||
Method::PUT,
|
||||
ReqInfo {
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let usecase = DefaultBucketUsecase::with_context(Some(context));
|
||||
usecase.execute_create_bucket(req).await.expect("create a missing bucket");
|
||||
let info = store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.expect("the missing-bucket lookup must fall through to real creation");
|
||||
assert_eq!(info.name, bucket);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_delete_bucket_returns_internal_error_when_store_uninitialized() {
|
||||
let input = DeleteBucketInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("DELETE");
|
||||
let mut call = Box::pin(usecase.execute_delete_bucket(req));
|
||||
let err = std::future::poll_fn(|cx| request.in_scope(|| call.as_mut().poll(cx)))
|
||||
.await
|
||||
.expect_err("an uninitialized store must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(
|
||||
request.in_scope(rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name),
|
||||
Some("s3:DeleteBucket")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_head_bucket_returns_internal_error_when_store_uninitialized() {
|
||||
let input = HeadBucketInput::builder().bucket("test-bucket".to_string()).build().unwrap();
|
||||
|
||||
let req = build_request(input, Method::HEAD);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
let before = s3_op_total(S3Operation::HeadBucket);
|
||||
|
||||
let err = usecase.execute_head_bucket(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(s3_op_total(S3Operation::HeadBucket), before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_location_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketLocationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_location(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket CORS configuration use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_delete_bucket_cors(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketCorsInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketCorsInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_CORS_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "cors-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket cors delete hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(DeleteBucketCorsOutput {}))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<PersistedCorsConfiguration> {
|
||||
let GetBucketCorsInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let cors_configuration = match metadata_sys::get_cors_config(&bucket).await {
|
||||
Ok((config, _)) => config,
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::NoSuchCORSConfiguration,
|
||||
"The CORS configuration does not exist".to_string(),
|
||||
));
|
||||
}
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_cors_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket CORS configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(cors_configuration)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_put_bucket_cors(
|
||||
&self,
|
||||
req: S3Request<PutBucketCorsInput>,
|
||||
prepared: S3Result<PersistedCorsConfiguration>,
|
||||
) -> S3Result<S3Response<PutBucketCorsOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketCorsInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let cors_configuration = prepared?;
|
||||
let data = serialize_config(&cors_configuration)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_CORS_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "cors-config");
|
||||
item.cors =
|
||||
Some(serialize_config(&cors_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket cors hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(PutBucketCorsOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_delete_bucket_cors_returns_internal_error_when_store_uninitialized() {
|
||||
let input = DeleteBucketCorsInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_delete_bucket_cors(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_cors_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketCorsInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_cors(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_cors_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutBucketCorsInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.cors_configuration(CORSConfiguration::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_put_bucket_cors(req, Ok(PersistedCorsConfiguration::default()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket default-encryption use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Refuse a default-encryption configuration the write path could not honour
|
||||
/// as written. `bucket_default_write_sse` falls back to AES256 for any
|
||||
/// algorithm it does not know, so storing one would make GetBucketEncryption
|
||||
/// advertise a scheme no object is encrypted under. s3s parses `SSEAlgorithm`
|
||||
/// as an open string, so the schema check has to happen here.
|
||||
fn validate_bucket_encryption_configuration(config: &PersistedBucketEncryptionConfiguration) -> S3Result<()> {
|
||||
if config.rules.is_empty() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::MalformedXML,
|
||||
"ServerSideEncryptionConfiguration must contain at least one Rule".to_string(),
|
||||
));
|
||||
}
|
||||
for rule in &config.rules {
|
||||
let Some(by_default) = rule.apply_server_side_encryption_by_default.as_ref() else {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::MalformedXML,
|
||||
"Rule must contain ApplyServerSideEncryptionByDefault".to_string(),
|
||||
));
|
||||
};
|
||||
let names_kms_key = by_default.kms_master_key_id.as_deref().is_some_and(|id| !id.is_empty());
|
||||
match by_default.sse_algorithm.as_str() {
|
||||
ServerSideEncryption::AWS_KMS => {}
|
||||
ServerSideEncryption::AES256 if names_kms_key => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"KMSMasterKeyID can only be specified when SSEAlgorithm is aws:kms".to_string(),
|
||||
));
|
||||
}
|
||||
ServerSideEncryption::AES256 => {}
|
||||
other => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::MalformedXML,
|
||||
format!("SSEAlgorithm {other} is not supported; expected AES256 or aws:kms"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
pub async fn execute_delete_bucket_encryption(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketEncryptionInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketEncryptionOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketEncryptionInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_SSECONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "sse-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket encryption delete hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::with_status(DeleteBucketEncryptionOutput::default(), StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub async fn execute_get_bucket_encryption(
|
||||
&self,
|
||||
req: S3Request<GetBucketEncryptionInput>,
|
||||
) -> S3Result<Option<PersistedBucketEncryptionConfiguration>> {
|
||||
let GetBucketEncryptionInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let server_side_encryption_configuration = match metadata_sys::get_sse_config(&bucket).await {
|
||||
Ok((cfg, _)) => Some(cfg),
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
return Err(s3_error!(ServerSideEncryptionConfigurationNotFoundError));
|
||||
}
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_sse_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket SSE configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(server_side_encryption_configuration)
|
||||
}
|
||||
|
||||
pub async fn execute_put_bucket_encryption(
|
||||
&self,
|
||||
req: S3Request<PutBucketEncryptionInput>,
|
||||
prepared: S3Result<PersistedBucketEncryptionConfiguration>,
|
||||
) -> S3Result<S3Response<PutBucketEncryptionOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketEncryptionInput { bucket, .. } = req.input;
|
||||
|
||||
let mut server_side_encryption_configuration = prepared?;
|
||||
validate_bucket_encryption_configuration(&server_side_encryption_configuration)?;
|
||||
|
||||
// When SSE-KMS is set without a specific key ID, populate the default
|
||||
// KMS key so that GetBucketEncryption responses include it. Clients like
|
||||
// mc rely on the presence of KMSMasterKeyID to distinguish SSE-KMS from
|
||||
// SSE-S3 in their display logic.
|
||||
if let Some(rule) = server_side_encryption_configuration.rules.first_mut()
|
||||
&& let Some(ref mut by_default) = rule.apply_server_side_encryption_by_default
|
||||
&& by_default.sse_algorithm.as_str() == ServerSideEncryption::AWS_KMS
|
||||
&& by_default.kms_master_key_id.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
let service = current_encryption_service()
|
||||
.await
|
||||
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "KMS service not initialized".to_string()))?;
|
||||
let default_key = service
|
||||
.get_default_key_id()
|
||||
.filter(|key| !key.is_empty())
|
||||
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "KMS default key not configured".to_string()))?;
|
||||
by_default.kms_master_key_id = Some(default_key.clone());
|
||||
}
|
||||
|
||||
info!("sse_config {:?}", &server_side_encryption_configuration);
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let data = serialize_config(&server_side_encryption_configuration)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_SSECONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "sse-config");
|
||||
item.sse_config = Some(
|
||||
serialize_config(&server_side_encryption_configuration)
|
||||
.and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
|
||||
);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket encryption hook failed");
|
||||
}
|
||||
Ok(S3Response::new(PutBucketEncryptionOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
|
||||
PersistedBucketEncryptionRule as ServerSideEncryptionRule, PersistedEncryptionByDefault as ServerSideEncryptionByDefault,
|
||||
};
|
||||
|
||||
fn sse_config(rules: Vec<ServerSideEncryptionRule>) -> ServerSideEncryptionConfiguration {
|
||||
ServerSideEncryptionConfiguration { rules }
|
||||
}
|
||||
|
||||
fn sse_rule(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionRule {
|
||||
ServerSideEncryptionRule {
|
||||
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
|
||||
sse_algorithm: algorithm.to_string(),
|
||||
kms_master_key_id: kms_key_id.map(|id| id.to_string()),
|
||||
}),
|
||||
blocked_encryption_types: None,
|
||||
bucket_key_enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored configuration must be one the write path honours as written:
|
||||
/// only AES256 and aws:kms exist, and a key id belongs to aws:kms alone.
|
||||
|
||||
#[test]
|
||||
fn put_bucket_encryption_refuses_configurations_the_write_path_cannot_honour() {
|
||||
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", None)])).expect("AES256 is valid");
|
||||
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("aws:kms", Some("bucket-key"))]))
|
||||
.expect("aws:kms with a key is valid");
|
||||
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("aws:kms", None)]))
|
||||
.expect("aws:kms without a key is valid (the default key is filled in)");
|
||||
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", Some(""))]))
|
||||
.expect("an empty key id on AES256 is how some clients spell 'none'");
|
||||
|
||||
let unknown = validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES128", None)]))
|
||||
.expect_err("AES128 is not an algorithm this server encrypts with");
|
||||
assert_eq!(*unknown.code(), S3ErrorCode::MalformedXML);
|
||||
|
||||
let misplaced = validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", Some("bucket-key"))]))
|
||||
.expect_err("a key id only makes sense for aws:kms");
|
||||
assert_eq!(*misplaced.code(), S3ErrorCode::InvalidArgument);
|
||||
|
||||
let empty = validate_bucket_encryption_configuration(&sse_config(Vec::new())).expect_err("no rule, no default");
|
||||
assert_eq!(*empty.code(), S3ErrorCode::MalformedXML);
|
||||
|
||||
let bare_rule = validate_bucket_encryption_configuration(&sse_config(vec![ServerSideEncryptionRule {
|
||||
apply_server_side_encryption_by_default: None,
|
||||
blocked_encryption_types: None,
|
||||
bucket_key_enabled: None,
|
||||
}]))
|
||||
.expect_err("a rule without ApplyServerSideEncryptionByDefault configures nothing");
|
||||
assert_eq!(*bare_rule.code(), S3ErrorCode::MalformedXML);
|
||||
|
||||
// A second rule is checked too, so a malformed one cannot hide behind a valid first rule.
|
||||
let second_bad =
|
||||
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", None), sse_rule("garbage", None)]))
|
||||
.expect_err("every rule is validated");
|
||||
assert_eq!(*second_bad.code(), S3ErrorCode::MalformedXML);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_encryption_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketEncryptionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_encryption(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_encryption_returns_internal_error_when_store_uninitialized() {
|
||||
// A well-formed rule, so the request reaches the store lookup instead
|
||||
// of being refused by configuration validation first.
|
||||
let input = PutBucketEncryptionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.server_side_encryption_configuration(s3s_crate::dto::ServerSideEncryptionConfiguration {
|
||||
rules: vec![s3s_crate::dto::ServerSideEncryptionRule {
|
||||
apply_server_side_encryption_by_default: Some(s3s_crate::dto::ServerSideEncryptionByDefault {
|
||||
sse_algorithm: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
||||
kms_master_key_id: None,
|
||||
}),
|
||||
blocked_encryption_types: None,
|
||||
bucket_key_enabled: None,
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let prepared = crate::storage::persisted_from_s3s(&req.input.server_side_encryption_configuration);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_put_bucket_encryption(req, prepared).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Source-text guards over the bucket use-case sources.
|
||||
|
||||
/// Production text of this file plus `bucket_usecase/`, where a split moves its methods.
|
||||
fn bucket_usecase_sources() -> crate::guard_sources::SourceSet {
|
||||
crate::guard_sources::SourceSet::production("src/app/bucket_usecase.rs", "src/app/bucket_usecase")
|
||||
}
|
||||
|
||||
/// The one `async fn method` of the set, through its own closing line, so a neighbour of any
|
||||
/// visibility or a test module can never satisfy a check about it.
|
||||
fn usecase_method_source<'a>(source: &'a crate::guard_sources::SourceSet, method: &str) -> &'a str {
|
||||
source.method_body(method)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_create_and_delete_use_admitted_fresh_tasks() {
|
||||
let sources = bucket_usecase_sources();
|
||||
let source = &sources;
|
||||
for (method, operation, inner_call) in [
|
||||
(
|
||||
"execute_create_bucket",
|
||||
"bucket creation",
|
||||
"execute_create_bucket_inner(req, mutation_permit).await",
|
||||
),
|
||||
("execute_delete_bucket", "bucket deletion", "execute_delete_bucket_inner(req).await"),
|
||||
] {
|
||||
let body = usecase_method_source(source, method);
|
||||
assert!(
|
||||
body.contains(&format!("await_bucket_usecase_on_fresh_task(\"{operation}\"")),
|
||||
"{method} must use bounded detached-task admission"
|
||||
);
|
||||
assert!(body.contains(inner_call), "{method} must run its mutation inside the admitted task");
|
||||
}
|
||||
let create_body = usecase_method_source(source, "execute_create_bucket_inner");
|
||||
assert!(
|
||||
create_body.contains("notify_bucket_metadata_reload_with_context("),
|
||||
"a successful bucket creation must refresh peer metadata caches"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_metadata_config_changes_notify_peer_metadata_reload() {
|
||||
let sources = bucket_usecase_sources();
|
||||
let source = &sources;
|
||||
for (method, operation, scanner_maintenance_change) in [
|
||||
("execute_delete_bucket_policy", "delete bucket policy", false),
|
||||
("execute_put_bucket_policy", "put bucket policy", false),
|
||||
("execute_delete_public_access_block", "delete public access block", false),
|
||||
("execute_put_public_access_block", "put public access block", false),
|
||||
("execute_delete_bucket_lifecycle", "delete bucket lifecycle", true),
|
||||
("execute_put_bucket_lifecycle_configuration", "put bucket lifecycle", true),
|
||||
("execute_put_bucket_versioning", "put bucket versioning", false),
|
||||
("execute_delete_bucket_tagging", "delete bucket tagging", false),
|
||||
("execute_put_bucket_tagging", "put bucket tagging", false),
|
||||
("execute_delete_bucket_replication", "delete bucket replication", true),
|
||||
("execute_put_bucket_replication", "put bucket replication", true),
|
||||
("execute_delete_bucket_cors", "delete bucket cors", false),
|
||||
("execute_put_bucket_cors", "put bucket cors", false),
|
||||
("execute_delete_bucket_encryption", "delete bucket encryption", false),
|
||||
("execute_put_bucket_encryption", "put bucket encryption", false),
|
||||
("execute_put_bucket_notification_configuration", "put bucket notification", false),
|
||||
] {
|
||||
let body = usecase_method_source(source, method);
|
||||
assert!(
|
||||
body.contains("notify_bucket_metadata_reload("),
|
||||
"{method} should notify peers to reload cached bucket metadata"
|
||||
);
|
||||
assert!(
|
||||
body.contains(operation),
|
||||
"{method} should identify the bucket metadata operation in reload logs"
|
||||
);
|
||||
let expected_reload = format!(
|
||||
"notify_bucket_metadata_reload(bucket.clone(), \"{operation}\", request_context, {scanner_maintenance_change}).await;"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&expected_reload),
|
||||
"{method} should propagate scanner_maintenance_change={scanner_maintenance_change}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket lifecycle configuration use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
const ERR_LIFECYCLE_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled";
|
||||
|
||||
fn assign_lifecycle_rule_ids(rules: &mut [LifecycleRule]) {
|
||||
let mut rule_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
for rule in rules.iter() {
|
||||
if let Some(id) = rule.id.as_ref() {
|
||||
rule_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, rule) in rules.iter_mut().enumerate() {
|
||||
if rule.id.is_none() {
|
||||
let mut suffix = 0usize;
|
||||
let mut generated_id = format!("rule-{}", idx);
|
||||
|
||||
while rule_ids.contains(&generated_id) {
|
||||
suffix += 1;
|
||||
generated_id = format!("rule-{idx}-{suffix}");
|
||||
}
|
||||
|
||||
rule_ids.insert(generated_id.clone());
|
||||
rule.id = Some(generated_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_lifecycle_rule_status(rules: &[LifecycleRule]) -> std::result::Result<(), &'static str> {
|
||||
for rule in rules {
|
||||
if rule.status != ExpirationStatus::from_static(ExpirationStatus::ENABLED)
|
||||
&& rule.status != ExpirationStatus::from_static(ExpirationStatus::DISABLED)
|
||||
{
|
||||
return Err(ERR_LIFECYCLE_RULE_STATUS);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map a lifecycle validation failure onto the S3 error the client should see.
|
||||
///
|
||||
/// The validator reports a schema-shape violation (a `Filter` with more than
|
||||
/// one predicate, a one-member `And`) with
|
||||
/// [`LIFECYCLE_MALFORMED_XML_ERROR_KIND`]; AWS answers those with
|
||||
/// `MalformedXML`. Everything else is a value the schema allows but S3 refuses,
|
||||
/// which stays `InvalidArgument` — the code this path has always returned
|
||||
/// (backlog#2201).
|
||||
fn lifecycle_validation_error(err: &std::io::Error) -> S3Error {
|
||||
if err.kind() == LIFECYCLE_MALFORMED_XML_ERROR_KIND {
|
||||
return S3Error::with_message(S3ErrorCode::MalformedXML, format!("Malformed XML: {err}"));
|
||||
}
|
||||
s3_error!(InvalidArgument, "{err}")
|
||||
}
|
||||
|
||||
fn lifecycle_has_transition_rules(config: &PersistedLifecycleConfiguration) -> bool {
|
||||
config.rules.iter().any(|rule| {
|
||||
rule.status == "Enabled"
|
||||
&& (rule.transitions.as_ref().is_some_and(|transitions| {
|
||||
transitions.iter().any(|transition| {
|
||||
transition
|
||||
.storage_class
|
||||
.as_ref()
|
||||
.is_some_and(|storage_class| !storage_class.as_str().is_empty())
|
||||
})
|
||||
}) || rule.noncurrent_version_transitions.as_ref().is_some_and(|transitions| {
|
||||
transitions.iter().any(|transition| {
|
||||
transition
|
||||
.storage_class
|
||||
.as_ref()
|
||||
.is_some_and(|storage_class| !storage_class.as_str().is_empty())
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn lifecycle_has_expiry_rules(config: &PersistedLifecycleConfiguration) -> bool {
|
||||
config.rules.iter().any(|rule| {
|
||||
rule.status == "Enabled"
|
||||
&& (rule.expiration.is_some() || rule.del_marker_expiration.is_some() || rule.noncurrent_version_expiration.is_some())
|
||||
})
|
||||
}
|
||||
|
||||
/// Status-independent presence of the expiry subset that site replication
|
||||
/// propagates (`replicateILMExpiry`): expiration / noncurrent-version
|
||||
/// expiration only. Distinct from [`lifecycle_has_expiry_rules`], which
|
||||
/// filters on ENABLED for scanner scheduling — editing a Disabled expiry rule
|
||||
/// must still advance the replication axis. Del-marker expiration and
|
||||
/// abort-multipart are site-local and never travel.
|
||||
fn lifecycle_rules_have_expiry(config: &PersistedLifecycleConfiguration) -> bool {
|
||||
config
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some())
|
||||
}
|
||||
|
||||
fn lifecycle_has_abort_multipart_rules(config: &PersistedLifecycleConfiguration) -> bool {
|
||||
config
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.status == "Enabled" && rule.abort_incomplete_multipart_upload.is_some())
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_delete_bucket_lifecycle(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketLifecycleInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketLifecycleInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_LIFECYCLE_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context, true).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle delete hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(DeleteBucketLifecycleOutput::default()))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_bucket_lifecycle_configuration(
|
||||
&self,
|
||||
req: S3Request<GetBucketLifecycleConfigurationInput>,
|
||||
) -> S3Result<PersistedLifecycleConfiguration> {
|
||||
let GetBucketLifecycleConfigurationInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let config = match metadata_sys::get_lifecycle_config(&bucket).await {
|
||||
Ok((cfg, _)) => cfg,
|
||||
Err(StorageError::ConfigNotFound) => {
|
||||
return Err(s3_error!(NoSuchLifecycleConfiguration));
|
||||
}
|
||||
// An unreadable stored configuration is not an absent one.
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_put_bucket_lifecycle_configuration(
|
||||
&self,
|
||||
req: S3Request<PutBucketLifecycleConfigurationInput>,
|
||||
prepared_configuration: std::io::Result<Option<PersistedLifecycleConfiguration>>,
|
||||
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketLifecycleConfigurationInput {
|
||||
bucket,
|
||||
lifecycle_configuration,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
let Some(mut wire_cfg) = lifecycle_configuration else { return Err(s3_error!(InvalidArgument)) };
|
||||
assign_lifecycle_rule_ids(&mut wire_cfg.rules);
|
||||
if let Err(err) = validate_lifecycle_rule_status(&wire_cfg.rules) {
|
||||
return Err(S3Error::with_message(S3ErrorCode::MalformedXML, format!("Malformed XML: {err}")));
|
||||
}
|
||||
|
||||
let rcfg = match metadata_sys::get_object_lock_config(&bucket).await {
|
||||
Ok((cfg, _)) => cfg,
|
||||
Err(StorageError::ConfigNotFound) => ObjectLockConfiguration::default(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_object_lock_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket object lock configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
// Keep conversion failures behind the request-boundary status and Object Lock checks.
|
||||
let Some(mut input_cfg) = prepared_configuration.map_err(to_internal_error)? else {
|
||||
return Err(rustfs_s3_types::s3_error!(InvalidArgument).into());
|
||||
};
|
||||
if input_cfg.rules.len() != wire_cfg.rules.len() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
"lifecycle conversion changed the rule count",
|
||||
));
|
||||
}
|
||||
for (rule, wire_rule) in input_cfg.rules.iter_mut().zip(&wire_cfg.rules) {
|
||||
rule.id.clone_from(&wire_rule.id);
|
||||
}
|
||||
|
||||
if let Err(err) = validate_lifecycle_config(&input_cfg, &rcfg).await {
|
||||
return Err(lifecycle_validation_error(&err));
|
||||
}
|
||||
|
||||
if let Err(err) = validate_transition_tier(&input_cfg).await {
|
||||
return Err(s3_error!(InvalidArgument, "{err}"));
|
||||
}
|
||||
|
||||
// Stamp the expiry axis only when the expiry subset can have changed
|
||||
// (MinIO: HasExpiry() || expiryRuleRemoved). Site-replication peers
|
||||
// judge lc-config staleness on this axis; a transition-only edit that
|
||||
// advanced it would let this site's stale expiry subset shadow — and
|
||||
// roll back — a newer peer expiry edit fleet-wide.
|
||||
let previous_expiry_updated_at = match metadata_sys::get_lifecycle_config(&bucket).await {
|
||||
Ok((previous, _)) => {
|
||||
if lifecycle_rules_have_expiry(&input_cfg) || lifecycle_rules_have_expiry(&previous) {
|
||||
Some(
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(to_internal_error)?,
|
||||
)
|
||||
} else {
|
||||
previous.expiry_updated_at
|
||||
}
|
||||
}
|
||||
// No previous config (or unreadable): stamping is the
|
||||
// conservative pre-existing behavior.
|
||||
Err(_) => {
|
||||
if lifecycle_rules_have_expiry(&input_cfg) {
|
||||
Some(
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(to_internal_error)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
input_cfg.expiry_updated_at = previous_expiry_updated_at;
|
||||
let data = serialize_config(&input_cfg)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_LIFECYCLE_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context, true).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
item.expiry_lc_config =
|
||||
Some(serialize_config(&input_cfg).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
// The item travels with the expiry axis, not the wall clock: a site
|
||||
// whose expiry knowledge is old (or absent — UNIX_EPOCH) must not
|
||||
// out-rank newer peer expiry state at the receivers.
|
||||
item.expiry_updated_at = input_cfg
|
||||
.expiry_updated_at
|
||||
.as_deref()
|
||||
.map(|value| time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339))
|
||||
.transpose()
|
||||
.map_err(to_internal_error)?
|
||||
.or(Some(time::OffsetDateTime::UNIX_EPOCH));
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle hook failed");
|
||||
}
|
||||
|
||||
if lifecycle_has_transition_rules(&input_cfg)
|
||||
&& let Some(store) = self.object_store()
|
||||
{
|
||||
let bucket_name = bucket.clone();
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
spawn_background_with_context(request_context, async move {
|
||||
if let Err(err) = enqueue_transition_for_existing_objects(store, &bucket_name).await {
|
||||
warn!(bucket = %bucket_name, error = ?err, "failed to enqueue transition for existing objects");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if lifecycle_has_expiry_rules(&input_cfg)
|
||||
&& let Some(store) = self.object_store()
|
||||
{
|
||||
let bucket_name = bucket.clone();
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
spawn_background_with_context(request_context, async move {
|
||||
if let Err(err) = enqueue_expiry_for_existing_objects(store, &bucket_name).await {
|
||||
warn!(bucket = %bucket_name, error = ?err, "failed to enqueue expiry for existing objects");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if lifecycle_has_abort_multipart_rules(&input_cfg)
|
||||
&& let Some(store) = self.object_store()
|
||||
{
|
||||
let bucket_name = bucket.clone();
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
spawn_background_with_context(request_context, async move {
|
||||
let deleted = run_stale_multipart_upload_cleanup_once(store).await;
|
||||
debug!(bucket = %bucket_name, deleted, "completed lifecycle abort multipart cleanup trigger");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(S3Response::new(PutBucketLifecycleConfigurationOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[test]
|
||||
fn normalize_lifecycle_rules_generate_rule_ids_for_missing_values() {
|
||||
let mut rules = vec![
|
||||
LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: None,
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
},
|
||||
LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(60),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("rule-1".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
},
|
||||
LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(90),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: None,
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
},
|
||||
];
|
||||
|
||||
assign_lifecycle_rule_ids(&mut rules);
|
||||
|
||||
assert_eq!(rules[0].id.as_deref(), Some("rule-0"));
|
||||
assert_eq!(rules[1].id.as_deref(), Some("rule-1"));
|
||||
assert_eq!(rules[2].id.as_deref(), Some("rule-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_bucket_lifecycle_validation_errors_keep_their_s3_code() {
|
||||
// The PUT path answers a schema-shape violation with MalformedXML and a
|
||||
// rejected value with InvalidArgument. Both categories are produced by
|
||||
// the real validator here, so the mapping cannot drift from it
|
||||
// (backlog#2201).
|
||||
let malformed = validate_lifecycle_config(
|
||||
&rustfs_gateway_types::persistence::PersistedLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![rustfs_gateway_types::persistence::PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(rustfs_gateway_types::persistence::PersistedLifecycleExpiration {
|
||||
days: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: Some(rustfs_gateway_types::persistence::PersistedLifecycleFilter {
|
||||
and: Some(rustfs_gateway_types::persistence::PersistedLifecycleAnd {
|
||||
prefix: Some("logs/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("one-member-and".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
},
|
||||
&ObjectLockConfiguration::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("a Filter And with one predicate is a schema violation");
|
||||
assert_eq!(*lifecycle_validation_error(&malformed).code(), S3ErrorCode::MalformedXML);
|
||||
|
||||
let invalid_value = validate_lifecycle_config(
|
||||
&rustfs_gateway_types::persistence::PersistedLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![rustfs_gateway_types::persistence::PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("negative-count".to_string()),
|
||||
noncurrent_version_expiration: Some(
|
||||
rustfs_gateway_types::persistence::PersistedNoncurrentVersionExpiration {
|
||||
noncurrent_days: Some(30),
|
||||
newer_noncurrent_versions: Some(-1),
|
||||
},
|
||||
),
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
},
|
||||
&ObjectLockConfiguration::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("a negative retention count is rejected");
|
||||
assert_eq!(*lifecycle_validation_error(&invalid_value).code(), S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_lifecycle_rule_status_rejects_invalid_status() {
|
||||
let rules = vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static("enabled"),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: None,
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}];
|
||||
|
||||
assert_eq!(validate_lifecycle_rule_status(&rules).unwrap_err(), ERR_LIFECYCLE_RULE_STATUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_has_transition_rules_ignores_disabled_rules() {
|
||||
let config = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::DISABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("disabled-transition".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: Some(vec![Transition {
|
||||
days: Some(1),
|
||||
date: None,
|
||||
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||
}]),
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(!lifecycle_has_transition_rules(
|
||||
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_has_transition_rules_accepts_enabled_noncurrent_transitions() {
|
||||
let config = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("enabled-noncurrent-transition".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition {
|
||||
noncurrent_days: Some(1),
|
||||
newer_noncurrent_versions: None,
|
||||
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||
}]),
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(lifecycle_has_transition_rules(
|
||||
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_has_abort_multipart_rules_accepts_enabled_abort_rule() {
|
||||
let config = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: Some(s3s_crate::dto::AbortIncompleteMultipartUpload {
|
||||
days_after_initiation: Some(0),
|
||||
}),
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("enabled-abort".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(lifecycle_has_abort_multipart_rules(
|
||||
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config.clone()).unwrap()
|
||||
));
|
||||
assert!(!lifecycle_has_expiry_rules(
|
||||
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_lifecycle_configuration_rejects_missing_configuration() {
|
||||
let input = PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket logging configuration use-case operations (reserved for the next batch).
|
||||
@@ -1,493 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket notification configuration use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn is_valid_notification_filter_value(value: &str) -> bool {
|
||||
if value.len() > 1024 || value.contains('\\') {
|
||||
return false;
|
||||
}
|
||||
!value.split('/').any(|segment| segment == "." || segment == "..")
|
||||
}
|
||||
|
||||
fn invalid_filter_value_message(cfg_scope: &str, value: &str) -> String {
|
||||
format!("invalid notification filter value (len={}) ({cfg_scope})", value.len())
|
||||
}
|
||||
|
||||
fn invalid_filter_name_message(cfg_scope: &str, name: &str) -> String {
|
||||
format!(
|
||||
"invalid notification filter name (len={}) (only 'prefix'/'suffix' are supported) ({cfg_scope})",
|
||||
name.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_notification_filter_rules(
|
||||
filter: Option<&NotificationConfigurationFilter>,
|
||||
cfg_kind: &str,
|
||||
cfg_id: Option<&str>,
|
||||
) -> S3Result<()> {
|
||||
let Some(filter) = filter else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(s3key_filter) = filter.key.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(rules) = s3key_filter.filter_rules.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut has_prefix = false;
|
||||
let mut has_suffix = false;
|
||||
let cfg_scope = cfg_id.map_or_else(|| cfg_kind.to_string(), |id| format!("{cfg_kind} id={id}"));
|
||||
|
||||
for rule in rules {
|
||||
let Some(name) = rule.name.as_ref() else {
|
||||
return Err(s3_error!(InvalidArgument, "invalid notification filter rule: missing Name ({cfg_scope})"));
|
||||
};
|
||||
let Some(value) = rule.value.as_ref() else {
|
||||
return Err(s3_error!(
|
||||
InvalidArgument,
|
||||
"invalid notification filter rule: missing Value ({cfg_scope})"
|
||||
));
|
||||
};
|
||||
|
||||
if !is_valid_notification_filter_value(value) {
|
||||
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_value_message(&cfg_scope, value)));
|
||||
}
|
||||
|
||||
if name.as_str().eq_ignore_ascii_case("prefix") {
|
||||
if has_prefix {
|
||||
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'prefix' ({cfg_scope})"));
|
||||
}
|
||||
has_prefix = true;
|
||||
} else if name.as_str().eq_ignore_ascii_case("suffix") {
|
||||
if has_suffix {
|
||||
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'suffix' ({cfg_scope})"));
|
||||
}
|
||||
has_suffix = true;
|
||||
} else {
|
||||
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_name_message(&cfg_scope, name.as_str())));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_notification_configuration_filters(notification_configuration: &NotificationConfiguration) -> S3Result<()> {
|
||||
if let Some(queue_configs) = notification_configuration.queue_configurations.as_ref() {
|
||||
for cfg in queue_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "QueueConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
if let Some(topic_configs) = notification_configuration.topic_configurations.as_ref() {
|
||||
for cfg in topic_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "TopicConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
if let Some(lambda_configs) = notification_configuration.lambda_function_configurations.as_ref() {
|
||||
for cfg in lambda_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "LambdaFunctionConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_notification_target_id(arn_str: &str) -> Result<TargetID, TargetIDError> {
|
||||
ARN::parse(arn_str)
|
||||
.map(|arn| arn.target_id)
|
||||
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
|
||||
}
|
||||
|
||||
type NotificationEventRule = (Vec<EventName>, String, String, Vec<TargetID>);
|
||||
|
||||
/// Builds the notify runtime rules for a bucket notification configuration
|
||||
/// without touching the store or the runtime rule state.
|
||||
fn build_notification_event_rules(
|
||||
notification_configuration: &NotificationConfiguration,
|
||||
) -> S3Result<Vec<NotificationEventRule>> {
|
||||
let mut event_rules = Vec::new();
|
||||
let invalid_arn = |e: TargetIDError| {
|
||||
S3Error::with_message(S3ErrorCode::InvalidArgument, format!("Invalid ARN in notification configuration: {e}"))
|
||||
};
|
||||
|
||||
process_queue_configurations(
|
||||
&mut event_rules,
|
||||
notification_configuration.queue_configurations.clone(),
|
||||
parse_notification_target_id,
|
||||
)
|
||||
.map_err(invalid_arn)?;
|
||||
process_topic_configurations(
|
||||
&mut event_rules,
|
||||
notification_configuration.topic_configurations.clone(),
|
||||
parse_notification_target_id,
|
||||
)
|
||||
.map_err(invalid_arn)?;
|
||||
process_lambda_configurations(
|
||||
&mut event_rules,
|
||||
notification_configuration.lambda_function_configurations.clone(),
|
||||
parse_notification_target_id,
|
||||
)
|
||||
.map_err(invalid_arn)?;
|
||||
|
||||
Ok(event_rules)
|
||||
}
|
||||
|
||||
fn resolve_notification_region(global_region: Option<Region>, request_region: Option<Region>) -> String {
|
||||
global_region
|
||||
.or(request_region)
|
||||
.map(|region| region.to_string())
|
||||
.unwrap_or_else(|| RUSTFS_REGION.to_string())
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
pub async fn execute_get_bucket_notification_configuration(
|
||||
&self,
|
||||
req: S3Request<GetBucketNotificationConfigurationInput>,
|
||||
) -> S3Result<Option<PersistedNotificationConfiguration>> {
|
||||
let GetBucketNotificationConfigurationInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let has_notification_config = match metadata_sys::get_notification_config(&bucket).await {
|
||||
Ok(config) => config,
|
||||
// An unreadable config is not "no notifications configured".
|
||||
Err(err) if crate::storage_api::error::is_unreadable_config_error(&err) => {
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_notification_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket notification configuration"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok(has_notification_config)
|
||||
}
|
||||
|
||||
pub async fn execute_put_bucket_notification_configuration(
|
||||
&self,
|
||||
req: S3Request<PutBucketNotificationConfigurationInput>,
|
||||
prepared: S3Result<PersistedNotificationConfiguration>,
|
||||
) -> S3Result<S3Response<PutBucketNotificationConfigurationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_region = req.region.clone();
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
|
||||
let PutBucketNotificationConfigurationInput {
|
||||
bucket,
|
||||
notification_configuration,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
validate_notification_configuration_filters(¬ification_configuration)?;
|
||||
let persisted_notification_configuration = prepared?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let region = resolve_notification_region(self.global_region(), request_region);
|
||||
let notify = current_notify_interface_for_context(self.context.as_deref());
|
||||
let event_rules = build_notification_event_rules(¬ification_configuration)?;
|
||||
|
||||
// Reject the request before the store write so a failure cannot leave a
|
||||
// persisted configuration that the notify runtime refused to activate.
|
||||
notify
|
||||
.validate_event_specific_rules(&bucket, region.as_str(), &event_rules)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to add rules: {e}"))?;
|
||||
|
||||
let data = serialize_config(&persisted_notification_configuration)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_NOTIFICATION_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context, false).await;
|
||||
|
||||
notify
|
||||
.clear_bucket_notification_rules(&bucket)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to clear rules: {e}"))?;
|
||||
warn!("notify event rules: {:?}", &event_rules);
|
||||
notify
|
||||
.add_event_specific_rules(&bucket, region.as_str(), &event_rules)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to add rules: {e}"))?;
|
||||
|
||||
Ok(S3Response::new(PutBucketNotificationConfigurationOutput {}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[test]
|
||||
fn resolve_notification_region_prefers_global_region() {
|
||||
let binding = resolve_notification_region(Some("us-east-1".parse().unwrap()), Some("ap-southeast-1".parse().unwrap()));
|
||||
assert_eq!(binding, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_notification_region_falls_back_to_request_region() {
|
||||
let binding = resolve_notification_region(None, Some("ap-southeast-1".parse().unwrap()));
|
||||
assert_eq!(binding, "ap-southeast-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_notification_region_defaults_value() {
|
||||
let binding = resolve_notification_region(None, None);
|
||||
assert_eq!(binding, RUSTFS_REGION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_invalid_filter_name() {
|
||||
let raw_name = "unsupported".repeat(100);
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from(raw_name.clone())),
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let msg = err.message().unwrap_or_default();
|
||||
assert!(msg.contains("len="), "error message should include summarized length");
|
||||
assert!(!msg.contains(&raw_name), "error message should not echo full raw filter name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_accepts_case_insensitive_filter_names() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from("Prefix".to_string())),
|
||||
value: Some("uploads/".to_string()),
|
||||
},
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from("Suffix".to_string())),
|
||||
value: Some(".csv".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
validate_notification_configuration_filters(&cfg).expect("capitalized filter names should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_duplicate_prefix_rules() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: Some("uploads/".to_string()),
|
||||
},
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: Some("images/".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_invalid_filter_value() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some("../secret".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let msg = err.message().unwrap_or_default();
|
||||
assert!(msg.contains("len="), "error message should include summarized length");
|
||||
assert!(!msg.contains("../secret"), "error message should not echo full raw filter value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_missing_filter_name() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: None,
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_missing_filter_value() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: None,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_duplicate_suffix_rules() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some(".csv".to_string()),
|
||||
},
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some(".log".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_notification_configuration_rejects_invalid_filter_before_store_lookup() {
|
||||
let input = PutBucketNotificationConfigurationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.notification_configuration(NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from("unsupported".to_string())),
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_put_bucket_notification_configuration(req, Ok(PersistedNotificationConfiguration::default()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket object-lock configuration use-case operations (reserved for the next batch).
|
||||
@@ -1,294 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket policy and policy status use-case operations.
|
||||
|
||||
use super::*;
|
||||
use rustfs_policy::policy::Validator;
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
pub async fn execute_delete_bucket_policy(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketPolicyOutput>> {
|
||||
record_s3_op(S3Operation::DeleteBucketPolicy);
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketPolicyInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_POLICY_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "policy");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket policy delete hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(DeleteBucketPolicyOutput {}))
|
||||
}
|
||||
|
||||
pub async fn execute_get_bucket_policy(
|
||||
&self,
|
||||
req: S3Request<GetBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<GetBucketPolicyOutput>> {
|
||||
let GetBucketPolicyInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let (policy_str, _) = match metadata_sys::get_bucket_policy_raw(&bucket).await {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
if StorageError::ConfigNotFound == err {
|
||||
return Err(s3_error!(NoSuchBucketPolicy));
|
||||
}
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(S3Response::new(GetBucketPolicyOutput {
|
||||
policy: Some(policy_str),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_get_bucket_policy_status(
|
||||
&self,
|
||||
req: S3Request<GetBucketPolicyStatusInput>,
|
||||
) -> S3Result<S3Response<GetBucketPolicyStatusOutput>> {
|
||||
let GetBucketPolicyStatusInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
let client_info = req.extensions.get::<ClientInfo>();
|
||||
let conditions = get_condition_values_with_client_info(
|
||||
&req.headers,
|
||||
&rustfs_credentials::Credentials::default(),
|
||||
None,
|
||||
None,
|
||||
remote_addr,
|
||||
client_info,
|
||||
);
|
||||
|
||||
let read_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
|
||||
bucket: &bucket,
|
||||
action: Action::S3Action(S3Action::ListBucketAction),
|
||||
is_owner: false,
|
||||
account: "",
|
||||
groups: &None,
|
||||
conditions: &conditions,
|
||||
object: "",
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let write_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
|
||||
bucket: &bucket,
|
||||
action: Action::S3Action(S3Action::PutObjectAction),
|
||||
is_owner: false,
|
||||
account: "",
|
||||
groups: &None,
|
||||
conditions: &conditions,
|
||||
object: "",
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let mut is_public = read_allowed || write_allowed;
|
||||
let ignore_public_acls = match metadata_sys::get_public_access_block_config(&bucket).await {
|
||||
Ok((config, _)) => config.ignore_public_acls.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
let _ = ignore_public_acls;
|
||||
|
||||
let policy_public = match metadata_sys::get_bucket_policy(&bucket).await {
|
||||
Ok((cfg, _)) => cfg.statements.iter().any(|statement| {
|
||||
matches!(statement.effect, Effect::Allow)
|
||||
&& statement.principal.is_match("*")
|
||||
&& statement.conditions.is_empty()
|
||||
&& statement.actions.is_match(&Action::S3Action(S3Action::ListBucketAction))
|
||||
}),
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
false
|
||||
} else {
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if policy_public {
|
||||
is_public = true;
|
||||
}
|
||||
|
||||
let output = GetBucketPolicyStatusOutput {
|
||||
policy_status: Some(PolicyStatus {
|
||||
is_public: Some(is_public),
|
||||
}),
|
||||
};
|
||||
|
||||
Ok(S3Response::new(output))
|
||||
}
|
||||
|
||||
pub async fn execute_put_bucket_policy(
|
||||
&self,
|
||||
req: S3Request<PutBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<PutBucketPolicyOutput>> {
|
||||
record_s3_op(S3Operation::PutBucketPolicy);
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketPolicyInput { bucket, policy, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let cfg: BucketPolicy =
|
||||
serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?;
|
||||
|
||||
if let Err(err) = cfg.is_valid() {
|
||||
warn!("put_bucket_policy err input {:?}, {:?}", &policy, err);
|
||||
return Err(s3_error!(MalformedPolicy));
|
||||
}
|
||||
|
||||
let is_public_policy = cfg
|
||||
.statements
|
||||
.iter()
|
||||
.any(|statement| matches!(statement.effect, Effect::Allow) && statement.principal.is_match("*"));
|
||||
|
||||
if is_public_policy {
|
||||
match metadata_sys::get_public_access_block_config(&bucket).await {
|
||||
Ok((config, _)) => {
|
||||
if config.block_public_policy.unwrap_or(false) {
|
||||
return Err(s3_error!(AccessDenied, "Access Denied"));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if err != StorageError::ConfigNotFound {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_public_access_block_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket public access block configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = policy.as_bytes().to_vec();
|
||||
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_POLICY_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "policy");
|
||||
item.policy = Some(serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket policy hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(PutBucketPolicyOutput {}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::{build_request, s3_op_total};
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_delete_bucket_policy_records_s3_operation_before_store_lookup() {
|
||||
let input = DeleteBucketPolicyInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
let before = s3_op_total(S3Operation::DeleteBucketPolicy);
|
||||
|
||||
let err = usecase.execute_delete_bucket_policy(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(s3_op_total(S3Operation::DeleteBucketPolicy), before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_policy_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketPolicyInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_policy(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_policy_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutBucketPolicyInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.policy("{}".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
let before = s3_op_total(S3Operation::PutBucketPolicy);
|
||||
|
||||
let err = usecase.execute_put_bucket_policy(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(s3_op_total(S3Operation::PutBucketPolicy), before + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket public access block use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_delete_public_access_block(
|
||||
&self,
|
||||
req: S3Request<DeletePublicAccessBlockInput>,
|
||||
) -> S3Result<S3Response<DeletePublicAccessBlockOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeletePublicAccessBlockInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context, false).await;
|
||||
|
||||
Ok(S3Response::with_status(DeletePublicAccessBlockOutput::default(), StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_public_access_block(
|
||||
&self,
|
||||
req: S3Request<GetPublicAccessBlockInput>,
|
||||
) -> S3Result<PersistedPublicAccessBlockConfiguration> {
|
||||
let GetPublicAccessBlockInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let config = match metadata_sys::get_public_access_block_config(&bucket).await {
|
||||
Ok((config, _)) => config,
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::Custom("NoSuchPublicAccessBlockConfiguration".into()),
|
||||
"Public access block configuration does not exist".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_put_public_access_block(
|
||||
&self,
|
||||
req: S3Request<PutPublicAccessBlockInput>,
|
||||
prepared: S3Result<PersistedPublicAccessBlockConfiguration>,
|
||||
) -> S3Result<S3Response<PutPublicAccessBlockOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutPublicAccessBlockInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let public_access_block_configuration = prepared?;
|
||||
let data = serialize_config(&public_access_block_configuration)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context, false).await;
|
||||
|
||||
Ok(S3Response::new(PutPublicAccessBlockOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_delete_public_access_block_returns_internal_error_when_store_uninitialized() {
|
||||
let input = DeletePublicAccessBlockInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_delete_public_access_block(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_public_access_block_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetPublicAccessBlockInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_public_access_block(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_public_access_block_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutPublicAccessBlockInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.public_access_block_configuration(PublicAccessBlockConfiguration::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_put_public_access_block(req, Ok(PersistedPublicAccessBlockConfiguration::default()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,912 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket replication configuration use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn validate_replication_config_targets(targets: &BucketTargets, config: &PersistedReplicationConfiguration) -> S3Result<()> {
|
||||
let configured_arns = targets
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|target| target.target_type == BucketTargetType::ReplicationService)
|
||||
.map(|target| target.arn.as_str());
|
||||
|
||||
match validate_replication_config_target_arns(configured_arns, config) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let message = match err {
|
||||
ReplicationTargetValidationError::RoleWithMultipleDestinations => {
|
||||
"replication config with Role cannot define multiple destination targets"
|
||||
}
|
||||
ReplicationTargetValidationError::StaleTarget => "replication config has a stale target",
|
||||
};
|
||||
Err(S3Error::with_message(S3ErrorCode::InvalidRequest, message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_replication_config_capabilities(config: &PersistedReplicationConfiguration) -> S3Result<()> {
|
||||
if let Err(err) = validate_replication_config_structure(config) {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, err.message()));
|
||||
}
|
||||
if let Some(field) = invalid_replication_config_status_field(config) {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!("replication field {field} has an invalid status"),
|
||||
));
|
||||
}
|
||||
if let Some(field) = unsupported_replication_config_field(config) {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!("replication field {field} is not supported by this RustFS version"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_bucket_replication_update(bucket: &str, config: &PersistedReplicationConfiguration) -> S3Result<()> {
|
||||
if !BucketVersioningSys::enabled(bucket).await {
|
||||
return Err(s3_error!(
|
||||
InvalidRequest,
|
||||
"bucket versioning must be enabled before replication can be configured"
|
||||
));
|
||||
}
|
||||
|
||||
let targets = metadata_sys::get_bucket_targets_config(bucket)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
StorageError::ConfigNotFound => {
|
||||
S3Error::with_message(S3ErrorCode::InvalidRequest, "replication target configuration not found".to_string())
|
||||
}
|
||||
other => ApiError::from(other).into(),
|
||||
})?;
|
||||
|
||||
validate_replication_config_targets(&targets, config)
|
||||
}
|
||||
|
||||
/// Defense in depth for site-replication-managed buckets (issue #1948): an S3
|
||||
/// PutBucketReplication replaces the operator-authored rules but must not wipe
|
||||
/// the rules the reconciler derived for the current remote peers
|
||||
/// (`site_peer_deployment_ids`) — until its next pass (600s period) every
|
||||
/// peer link on this bucket would be silently dead. The same merge also drops
|
||||
/// incoming impostors of those rules. An empty peer set (site replication
|
||||
/// disabled) keeps the verbatim overwrite semantics: rule ids are not
|
||||
/// reserved, so an operator's own `site-repl-*` rule is ordinary state there.
|
||||
/// `contract` is what the peers were probed to support; the merged config is
|
||||
/// what gets broadcast, so it is built the way every peer will merge it.
|
||||
fn merge_user_replication_config_update(
|
||||
incoming: PersistedReplicationConfiguration,
|
||||
existing: Option<PersistedReplicationConfiguration>,
|
||||
site_peer_deployment_ids: &HashSet<String>,
|
||||
contract: OperatorRuleContract,
|
||||
) -> PersistedReplicationConfiguration {
|
||||
if site_peer_deployment_ids.is_empty() {
|
||||
return incoming;
|
||||
}
|
||||
// `incoming` passed structure validation, so it holds at least one rule;
|
||||
// `None` is only reachable when every incoming rule impersonates a
|
||||
// reconciler rule, and then the stored reconciler rules are what remains.
|
||||
merge_user_replication_config(Some(incoming.clone()), existing, site_peer_deployment_ids, contract).unwrap_or(incoming)
|
||||
}
|
||||
|
||||
/// Split of an S3 DeleteBucketReplication on the stored config (issue #1948):
|
||||
/// the operator-authored rules are removed, the rules the reconciler derived
|
||||
/// for the current remote peers survive (`None` means nothing survives and
|
||||
/// the config is deleted), and the returned ARNs are the ones whose bucket
|
||||
/// targets may be garbage-collected — never an ARN a surviving reconciler
|
||||
/// rule still points at.
|
||||
fn split_replication_config_for_user_delete(
|
||||
config: PersistedReplicationConfiguration,
|
||||
site_peer_deployment_ids: &HashSet<String>,
|
||||
contract: OperatorRuleContract,
|
||||
) -> (Option<PersistedReplicationConfiguration>, HashSet<String>) {
|
||||
let mut removable_arns = replication_target_arns(&config);
|
||||
let remaining = merge_user_replication_config(None, Some(config), site_peer_deployment_ids, contract);
|
||||
if let Some(remaining) = remaining.as_ref() {
|
||||
for rule in &remaining.rules {
|
||||
removable_arns.remove(rule.destination.bucket.trim());
|
||||
}
|
||||
}
|
||||
(remaining, removable_arns)
|
||||
}
|
||||
|
||||
async fn replication_targets_without_arns(
|
||||
bucket: &str,
|
||||
target_arns: &HashSet<String>,
|
||||
) -> S3Result<Option<(BucketTargets, usize)>> {
|
||||
if target_arns.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut targets = match metadata_sys::get_bucket_targets_config(bucket).await {
|
||||
Ok(targets) => targets,
|
||||
Err(StorageError::ConfigNotFound) => return Ok(None),
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
};
|
||||
|
||||
let removed = remove_replication_targets_from_config_targets(&mut targets, target_arns);
|
||||
if removed == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((targets, removed)))
|
||||
}
|
||||
|
||||
fn remove_replication_targets_from_config_targets(targets: &mut BucketTargets, target_arns: &HashSet<String>) -> usize {
|
||||
let original_len = targets.targets.len();
|
||||
targets.targets.retain(|target| {
|
||||
!should_remove_replication_target(
|
||||
target.arn.as_str(),
|
||||
target.target_type == BucketTargetType::ReplicationService,
|
||||
target_arns,
|
||||
)
|
||||
});
|
||||
|
||||
original_len - targets.targets.len()
|
||||
}
|
||||
|
||||
async fn write_replication_targets_after_config_delete(
|
||||
bucket: &str,
|
||||
targets: &BucketTargets,
|
||||
removed: usize,
|
||||
expected_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> S3Result<()> {
|
||||
let json_targets = serde_json::to_vec(&targets).map_err(to_internal_error)?;
|
||||
update_bucket_config_for_incarnation(bucket, BUCKET_TARGETS_FILE, json_targets, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
info!(bucket = %bucket, removed, "removed replication remote targets referenced by deleted bucket replication config");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_replication_config_after_target_cleanup_failure(
|
||||
bucket: &str,
|
||||
config: &PersistedReplicationConfiguration,
|
||||
cleanup_err: S3Error,
|
||||
expected_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> S3Error {
|
||||
match serialize(config) {
|
||||
Ok(data) => {
|
||||
if let Err(restore_err) =
|
||||
update_bucket_config_for_incarnation(bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id).await
|
||||
{
|
||||
error!(
|
||||
bucket = %bucket,
|
||||
error = ?restore_err,
|
||||
cleanup_error = ?cleanup_err,
|
||||
"failed to restore bucket replication config after target cleanup failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(restore_err) => {
|
||||
error!(
|
||||
bucket = %bucket,
|
||||
error = ?restore_err,
|
||||
cleanup_error = ?cleanup_err,
|
||||
"failed to serialize bucket replication config for restore after target cleanup failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_err
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
/// `site_peers` is the set of remote site-replication peer deployment ids
|
||||
/// (empty when site replication is disabled). The interface layer reads it
|
||||
/// from the persisted state and fails closed on a read error, so this
|
||||
/// usecase stays a pure function of its inputs (layer rule: app never
|
||||
/// imports interface).
|
||||
pub async fn execute_delete_bucket_replication(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketReplicationInput>,
|
||||
site_peers: HashSet<String>,
|
||||
contract: OperatorRuleContract,
|
||||
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketReplicationInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
|
||||
let replication_config = match metadata_sys::get_replication_config(&bucket).await {
|
||||
Ok((config, _)) => Some(config),
|
||||
Err(StorageError::ConfigNotFound) => None,
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
};
|
||||
let (remaining_config, updated_targets) = if let Some(config) = replication_config.as_ref() {
|
||||
let (remaining, removable_arns) = split_replication_config_for_user_delete(config.clone(), &site_peers, contract);
|
||||
let targets = replication_targets_without_arns(&bucket, &removable_arns).await?;
|
||||
(remaining, targets)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
match remaining_config {
|
||||
// Site-replication rules and the targets backing them survive the
|
||||
// S3 delete (issue #1948); only the operator-authored rules go.
|
||||
Some(remaining) => {
|
||||
let data = serialize_config(&remaining)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
None => {
|
||||
delete_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
}
|
||||
if let Some((targets, removed)) = updated_targets
|
||||
&& let Err(err) =
|
||||
write_replication_targets_after_config_delete(&bucket, &targets, removed, expected_incarnation_id).await
|
||||
{
|
||||
if let Some(config) = replication_config.as_ref() {
|
||||
return Err(restore_replication_config_after_target_cleanup_failure(
|
||||
&bucket,
|
||||
config,
|
||||
err,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
drop(targets_guard);
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context, true).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "replication-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket replication-config delete hook failed");
|
||||
}
|
||||
|
||||
info!(bucket = %bucket, "deleted bucket replication config");
|
||||
|
||||
Ok(S3Response::new(DeleteBucketReplicationOutput::default()))
|
||||
}
|
||||
|
||||
/// Answers the stored configuration in the engine's shape; the s3s edge
|
||||
/// (`storage::ecfs`) converts it into the `GetBucketReplication` output.
|
||||
pub async fn execute_get_bucket_replication(
|
||||
&self,
|
||||
req: S3Request<GetBucketReplicationInput>,
|
||||
) -> S3Result<PersistedReplicationConfiguration> {
|
||||
let GetBucketReplicationInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let replication_configuration = match metadata_sys::get_replication_config(&bucket).await {
|
||||
Ok((cfg, _created)) => cfg,
|
||||
Err(StorageError::ConfigNotFound) => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::ReplicationConfigurationNotFoundError,
|
||||
"replication not found".to_string(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_replication_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket replication configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(replication_configuration)
|
||||
}
|
||||
|
||||
/// See [`Self::execute_delete_bucket_replication`] for `site_peers` and
|
||||
/// `contract`. `replication_configuration` is the request body in the
|
||||
/// engine's shape: the s3s edge (`storage::ecfs`) converts it, and this use
|
||||
/// case never reads `req.input.replication_configuration`.
|
||||
pub async fn execute_put_bucket_replication(
|
||||
&self,
|
||||
req: S3Request<PutBucketReplicationInput>,
|
||||
replication_configuration: PersistedReplicationConfiguration,
|
||||
site_peers: HashSet<String>,
|
||||
contract: OperatorRuleContract,
|
||||
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketReplicationInput { bucket, .. } = req.input;
|
||||
info!(bucket = %bucket, "updating bucket replication config");
|
||||
|
||||
validate_replication_config_capabilities(&replication_configuration)?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
|
||||
validate_bucket_replication_update(&bucket, &replication_configuration).await?;
|
||||
let existing_config = match metadata_sys::get_replication_config(&bucket).await {
|
||||
Ok((config, _)) => Some(config),
|
||||
Err(StorageError::ConfigNotFound) => None,
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
};
|
||||
let replication_configuration =
|
||||
merge_user_replication_config_update(replication_configuration, existing_config, &site_peers, contract);
|
||||
let data = serialize_config(&replication_configuration)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
drop(targets_guard);
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context, true).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "replication-config");
|
||||
item.replication_config = Some(
|
||||
serialize_config(&replication_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
|
||||
);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket replication-config hook failed");
|
||||
}
|
||||
|
||||
Ok(S3Response::new(PutBucketReplicationOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedEncryptionConfiguration, PersistedReplicationDestination, PersistedReplicationRule,
|
||||
};
|
||||
|
||||
fn replication_rule_for_target(arn: &str) -> PersistedReplicationRule {
|
||||
PersistedReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: None,
|
||||
destination: PersistedReplicationDestination {
|
||||
bucket: arn.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
existing_object_replication: None,
|
||||
filter: None,
|
||||
id: Some("rule-1".to_string()),
|
||||
prefix: None,
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_arns_use_role_when_present() {
|
||||
let role = "arn:rustfs:replication:us-east-1:source:bucket";
|
||||
let destination = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: format!(" {role} "),
|
||||
rules: vec![replication_rule_for_target(destination)],
|
||||
};
|
||||
|
||||
let arns = replication_target_arns(&config);
|
||||
|
||||
assert!(arns.contains(role));
|
||||
assert!(!arns.contains(destination));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_arns_use_rule_destinations_without_role() {
|
||||
let destination = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_for_target(destination)],
|
||||
};
|
||||
|
||||
let arns = replication_target_arns(&config);
|
||||
|
||||
assert!(arns.contains(destination));
|
||||
}
|
||||
|
||||
fn replication_rule_with_id(arn: &str, id: &str, priority: i32) -> PersistedReplicationRule {
|
||||
let mut rule = replication_rule_for_target(arn);
|
||||
rule.id = Some(id.to_string());
|
||||
rule.priority = Some(priority);
|
||||
rule
|
||||
}
|
||||
|
||||
fn site_peers(deployment_ids: &[&str]) -> HashSet<String> {
|
||||
deployment_ids.iter().map(|id| id.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_replication_merge_preserves_site_replication_rules() {
|
||||
let existing = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
replication_rule_with_id("arn:rustfs:replication::peer-dep:bucket", "site-repl-peer-dep", 1),
|
||||
replication_rule_with_id("arn:rustfs:replication:us-east-1:old:bucket", "old-user-rule", 2),
|
||||
],
|
||||
};
|
||||
let incoming = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
replication_rule_with_id("arn:rustfs:replication:us-east-1:new:bucket", "new-user-rule", 1),
|
||||
replication_rule_with_id("arn:rustfs:replication::forged-dep:bucket", "site-repl-peer-dep", 2),
|
||||
replication_rule_with_id("arn:rustfs:replication::other-dep:bucket", "site-repl-other", 3),
|
||||
],
|
||||
};
|
||||
|
||||
let merged = merge_user_replication_config_update(
|
||||
incoming,
|
||||
Some(existing),
|
||||
&site_peers(&["peer-dep"]),
|
||||
OperatorRuleContract::Derived,
|
||||
);
|
||||
|
||||
let rules: Vec<_> = merged
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| (rule.id.as_deref().unwrap_or_default(), rule.destination.bucket.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rules,
|
||||
vec![
|
||||
("new-user-rule", "arn:rustfs:replication:us-east-1:new:bucket"),
|
||||
("site-repl-other", "arn:rustfs:replication::other-dep:bucket"),
|
||||
("site-repl-peer-dep", "arn:rustfs:replication::peer-dep:bucket"),
|
||||
],
|
||||
"user rules replaced, the reconciler rule for the current peer kept over the incoming impostor, \
|
||||
a site-repl-* id that names no current peer is ordinary operator state"
|
||||
);
|
||||
}
|
||||
|
||||
// Rule ids do not reserve `site-repl-*`: outside site replication an
|
||||
// owner's `site-repl-user` rule is ordinary state, so PUT stores it
|
||||
// verbatim and DELETE removes it and garbage-collects its target.
|
||||
#[test]
|
||||
fn put_then_delete_replication_without_site_replication_treats_site_repl_id_as_user_rule() {
|
||||
let user_arn = "arn:minio:replication:us-east-1:2f1c-remote:bucket";
|
||||
let incoming = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_with_id(user_arn, "site-repl-user", 1)],
|
||||
};
|
||||
|
||||
let stored = merge_user_replication_config_update(incoming.clone(), None, &HashSet::new(), OperatorRuleContract::Derived);
|
||||
assert_eq!(stored, incoming, "PUT on a non-site-replication bucket is verbatim");
|
||||
|
||||
let (remaining, removable) =
|
||||
split_replication_config_for_user_delete(stored, &HashSet::new(), OperatorRuleContract::Derived);
|
||||
assert!(remaining.is_none(), "DELETE must remove the operator's site-repl-* rule");
|
||||
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
|
||||
}
|
||||
|
||||
// Under site replication only a rule the reconciler would derive — id
|
||||
// `site-repl-<peer>` for a current peer, destination ARN naming the same
|
||||
// peer — is reconciler-owned. Everything else is operator state.
|
||||
#[test]
|
||||
fn delete_replication_split_keeps_only_reconciler_derived_rules() {
|
||||
let peer_arn = "arn:rustfs:replication::peer-dep:bucket";
|
||||
let user_arn = "arn:minio:replication:us-east-1:2f1c-remote:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
replication_rule_with_id(user_arn, "site-repl-user", 1),
|
||||
replication_rule_with_id(user_arn, "site-repl-peer-dep", 2),
|
||||
replication_rule_with_id("arn:rustfs:replication::gone-dep:bucket", "site-repl-gone-dep", 3),
|
||||
replication_rule_with_id(peer_arn, "site-repl-peer-dep", 4),
|
||||
],
|
||||
};
|
||||
|
||||
let (remaining, removable) =
|
||||
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
|
||||
|
||||
let remaining = remaining.expect("the reconciler-derived rule must survive");
|
||||
assert_eq!(remaining.rules.len(), 1);
|
||||
assert_eq!(remaining.rules[0].destination.bucket, peer_arn);
|
||||
assert_eq!(
|
||||
removable,
|
||||
HashSet::from([user_arn.to_string(), "arn:rustfs:replication::gone-dep:bucket".to_string()]),
|
||||
"targets of operator rules and of a removed peer are garbage-collected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_replication_merge_returns_incoming_verbatim_without_site_rules() {
|
||||
let existing = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_with_id(
|
||||
"arn:rustfs:replication:us-east-1:old:bucket",
|
||||
"old-user-rule",
|
||||
7,
|
||||
)],
|
||||
};
|
||||
let incoming = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_with_id(
|
||||
"arn:rustfs:replication:us-east-1:new:bucket",
|
||||
"new-user-rule",
|
||||
5,
|
||||
)],
|
||||
};
|
||||
|
||||
let merged = merge_user_replication_config_update(
|
||||
incoming.clone(),
|
||||
Some(existing),
|
||||
&HashSet::new(),
|
||||
OperatorRuleContract::Derived,
|
||||
);
|
||||
|
||||
assert_eq!(merged.role, incoming.role);
|
||||
assert_eq!(merged.rules, incoming.rules, "non-SR buckets keep the verbatim overwrite semantics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replication_split_keeps_site_rules_and_their_targets() {
|
||||
let sr_arn = "arn:rustfs:replication::peer-dep:bucket";
|
||||
let user_arn = "arn:rustfs:replication:us-east-1:user:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
replication_rule_with_id(user_arn, "user-rule", 1),
|
||||
replication_rule_with_id(sr_arn, "site-repl-peer-dep", 2),
|
||||
],
|
||||
};
|
||||
|
||||
let (remaining, removable) =
|
||||
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
|
||||
|
||||
let remaining = remaining.expect("site-replication rules must survive a user delete");
|
||||
let ids: Vec<_> = remaining
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| rule.id.as_deref().unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["site-repl-peer-dep"]);
|
||||
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replication_split_protects_targets_shared_with_site_rules() {
|
||||
let sr_arn = "arn:rustfs:replication::peer-dep:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
replication_rule_with_id(sr_arn, "user-rule-on-sr-target", 1),
|
||||
replication_rule_with_id(sr_arn, "site-repl-peer-dep", 2),
|
||||
],
|
||||
};
|
||||
|
||||
let (remaining, removable) =
|
||||
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
|
||||
|
||||
assert!(remaining.is_some());
|
||||
assert!(
|
||||
removable.is_empty(),
|
||||
"a target still referenced by a surviving site-replication rule must not be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replication_split_removes_everything_without_site_rules() {
|
||||
let user_arn = "arn:rustfs:replication:us-east-1:user:bucket";
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_with_id(user_arn, "user-rule", 1)],
|
||||
};
|
||||
|
||||
let (remaining, removable) =
|
||||
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
|
||||
|
||||
assert!(remaining.is_none(), "without site-replication rules the whole config is deleted");
|
||||
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
|
||||
}
|
||||
|
||||
fn replication_targets_with_arn(arns: &[&str]) -> BucketTargets {
|
||||
BucketTargets {
|
||||
targets: arns
|
||||
.iter()
|
||||
.map(|arn| BucketTarget {
|
||||
arn: (*arn).to_string(),
|
||||
target_type: BucketTargetType::ReplicationService,
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_accepts_matching_destination_arns() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let targets = replication_targets_with_arn(&[arn]);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_for_target(arn)],
|
||||
};
|
||||
|
||||
validate_replication_config_targets(&targets, &config).expect("matching target should pass validation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_rejects_stale_destination_arns() {
|
||||
let targets = replication_targets_with_arn(&["arn:rustfs:replication:us-east-1:target:bucket-a"]);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_for_target(
|
||||
"arn:rustfs:replication:us-east-1:target:bucket-b",
|
||||
)],
|
||||
};
|
||||
|
||||
let err = validate_replication_config_targets(&targets, &config).expect_err("stale target should fail validation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_accepts_matching_role_arn() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:role-target:bucket";
|
||||
let targets = replication_targets_with_arn(&[arn]);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: format!(" {arn} "),
|
||||
rules: vec![replication_rule_for_target("arn:rustfs:replication:us-east-1:ignored:bucket")],
|
||||
};
|
||||
|
||||
validate_replication_config_targets(&targets, &config).expect("matching role ARN should pass validation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_rejects_role_with_multiple_destinations() {
|
||||
let role = "arn:rustfs:replication:us-east-1:role-target:bucket";
|
||||
let targets = replication_targets_with_arn(&[role]);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: role.to_string(),
|
||||
rules: vec![
|
||||
replication_rule_for_target("arn:rustfs:replication:us-east-1:target-a:bucket"),
|
||||
replication_rule_for_target("arn:rustfs:replication:us-east-1:target-b:bucket"),
|
||||
],
|
||||
};
|
||||
|
||||
let err = validate_replication_config_targets(&targets, &config)
|
||||
.expect_err("role plus multiple destinations should be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_trims_destination_arns() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let targets = replication_targets_with_arn(&[arn]);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule_for_target(
|
||||
" arn:rustfs:replication:us-east-1:target:bucket ",
|
||||
)],
|
||||
};
|
||||
|
||||
validate_replication_config_targets(&targets, &config).expect("trimmed destination ARN should match configured target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_targets_ignores_disabled_rules() {
|
||||
let targets = replication_targets_with_arn(&[]);
|
||||
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:stale:bucket");
|
||||
rule.status = Status::DISABLED.to_string();
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
|
||||
validate_replication_config_targets(&targets, &config).expect("disabled rules should not require live targets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_capabilities_names_unsupported_field() {
|
||||
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
|
||||
let destination_key_id = "arn:aws:kms:us-east-1:123456789012:key/opaque-key-id";
|
||||
rule.destination.encryption_configuration = Some(PersistedEncryptionConfiguration {
|
||||
replica_kms_key_id: Some(destination_key_id.to_string()),
|
||||
});
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
|
||||
let err = validate_replication_config_capabilities(&config)
|
||||
.expect_err("destination encryption must be rejected until the execution path supports it");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Destination.EncryptionConfiguration is not supported")
|
||||
);
|
||||
assert!(!err.to_string().contains(destination_key_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_capabilities_rejects_structural_defects_before_write() {
|
||||
let mut first = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
|
||||
first.priority = Some(1);
|
||||
let mut second = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
|
||||
second.priority = Some(1);
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![first, second],
|
||||
};
|
||||
|
||||
let err = validate_replication_config_capabilities(&config)
|
||||
.expect_err("duplicate rule priorities must be rejected before persistence");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert!(err.to_string().contains("Priority must be unique"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_replication_config_capabilities_rejects_invalid_status_before_write() {
|
||||
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
|
||||
rule.status = "Invalid".to_owned();
|
||||
let config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
|
||||
let err = validate_replication_config_capabilities(&config)
|
||||
.expect_err("an invalid string-backed replication status must be rejected before persistence");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert!(err.to_string().contains("Rule.Status has an invalid status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_replication_targets_from_config_targets_only_removes_referenced_replication_targets() {
|
||||
let removed_arn = "arn:rustfs:replication:us-east-1:removed:bucket";
|
||||
let kept_replication_arn = "arn:rustfs:replication:us-east-1:kept:bucket";
|
||||
let kept_ilm_arn = "arn:rustfs:ilm:us-east-1:kept:bucket";
|
||||
let mut targets = BucketTargets {
|
||||
targets: vec![
|
||||
BucketTarget {
|
||||
arn: removed_arn.to_string(),
|
||||
target_type: BucketTargetType::ReplicationService,
|
||||
..Default::default()
|
||||
},
|
||||
BucketTarget {
|
||||
arn: kept_replication_arn.to_string(),
|
||||
target_type: BucketTargetType::ReplicationService,
|
||||
..Default::default()
|
||||
},
|
||||
BucketTarget {
|
||||
arn: kept_ilm_arn.to_string(),
|
||||
target_type: BucketTargetType::IlmService,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
let target_arns = HashSet::from([removed_arn.to_string(), kept_ilm_arn.to_string()]);
|
||||
|
||||
let removed = remove_replication_targets_from_config_targets(&mut targets, &target_arns);
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
let remaining_arns = targets
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.arn.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
assert!(!remaining_arns.contains(removed_arn));
|
||||
assert!(remaining_arns.contains(kept_replication_arn));
|
||||
assert!(remaining_arns.contains(kept_ilm_arn));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_delete_bucket_replication_returns_internal_error_when_store_uninitialized() {
|
||||
let input = DeleteBucketReplicationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_bucket_replication(req, HashSet::new(), OperatorRuleContract::Derived)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_replication_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketReplicationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_replication(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_replication_returns_internal_error_when_store_uninitialized() {
|
||||
// The config must clear the structural/capability validators so the
|
||||
// request actually reaches the store lookup this test pins.
|
||||
let configuration = PersistedReplicationConfiguration {
|
||||
role: "arn:aws:iam::123456789012:role/test".to_string(),
|
||||
rules: vec![replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket")],
|
||||
};
|
||||
let input = PutBucketReplicationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.replication_configuration(Default::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_put_bucket_replication(req, configuration, HashSet::new(), OperatorRuleContract::Derived)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_replication_rejects_unsupported_fields_before_store_or_metadata_write() {
|
||||
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
|
||||
rule.destination.account = Some("123456789012".to_string());
|
||||
let configuration = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
let input = PutBucketReplicationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.replication_configuration(Default::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let err = DefaultBucketUsecase::without_context()
|
||||
.execute_put_bucket_replication(
|
||||
build_request(input, Method::PUT),
|
||||
configuration,
|
||||
HashSet::new(),
|
||||
OperatorRuleContract::Derived,
|
||||
)
|
||||
.await
|
||||
.expect_err("unsupported fields must be rejected before store access");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(
|
||||
err.message(),
|
||||
Some("replication field Destination.Account is not supported by this RustFS version")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket request-payment configuration use-case operations (reserved for the next batch).
|
||||
@@ -1,247 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket tagging use-case operations.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_delete_bucket_tagging(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketTaggingInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketTaggingOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketTaggingInput { bucket, .. } = req.input;
|
||||
|
||||
metadata_sys::update_s3_tagging_config(&bucket, None, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context, false).await;
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "tags");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging delete hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(DeleteBucketTaggingOutput {}))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_bucket_tagging(&self, req: S3Request<GetBucketTaggingInput>) -> S3Result<PersistedTagging> {
|
||||
let GetBucketTaggingInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let tagging = match metadata_sys::get_tagging_config(&bucket).await {
|
||||
Ok((tags, _)) => tags,
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
return Err(S3Error::with_message(S3ErrorCode::NoSuchTagSet, "The TagSet does not exist".to_string()));
|
||||
}
|
||||
warn!(
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET,
|
||||
event = "bucket_tagging_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket tagging configuration"
|
||||
);
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(tagging)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_put_bucket_tagging(
|
||||
&self,
|
||||
req: S3Request<PutBucketTaggingInput>,
|
||||
tagging: PersistedTagging,
|
||||
) -> S3Result<S3Response<PutBucketTaggingOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketTaggingInput { bucket, .. } = req.input;
|
||||
let s3_tag_set: Vec<s3s_crate::dto::Tag> = tagging
|
||||
.tag_set
|
||||
.iter()
|
||||
.map(|tag| s3s_crate::dto::Tag {
|
||||
key: tag.key.clone(),
|
||||
value: tag.value.clone(),
|
||||
})
|
||||
.collect();
|
||||
crate::app::storage_api::bucket_usecase::s3_api::tagging::validate_bucket_tag_set(&s3_tag_set)?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
metadata_sys::update_s3_tagging_config(&bucket, Some(tagging.clone()), expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "tags");
|
||||
item.tags = Some(serialize_config(&tagging).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutBucketTaggingOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_tagging(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn put_bucket_tagging_rejects_invalid_shapes_before_storage_mutation() {
|
||||
crate::app::gating_test_env::run_large_stack_test("bucket-tag-validation", || async {
|
||||
use crate::app::runtime_sources::{AppContext, ServerContextSlot};
|
||||
use crate::app::storage_api::test::ReqInfo;
|
||||
use crate::app::storage_api::test::ecfs::FS;
|
||||
use s3s_crate::access::S3Access as _;
|
||||
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let context = Arc::new(AppContext::new(store.clone(), ambient.iam(), ambient.kms()));
|
||||
let slot = ServerContextSlot::new();
|
||||
assert!(slot.install(context.clone()));
|
||||
let fs = FS::with_server_ctx(slot.clone());
|
||||
let usecase = DefaultBucketUsecase::with_context(Some(context));
|
||||
let bucket = format!("tag-validation-{}", uuid::Uuid::new_v4().simple());
|
||||
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
&bucket,
|
||||
BUCKET_POLICY_CONFIG,
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
|
||||
"Action":"s3:PutBucketTagging","Resource":format!("arn:aws:s3:::{bucket}")}]}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tag = s3s_crate::dto::Tag {
|
||||
key: Some("team".to_owned()),
|
||||
value: Some("storage".to_owned()),
|
||||
};
|
||||
for (tag_set, valid) in [
|
||||
(vec![tag.clone()], true),
|
||||
(
|
||||
vec![s3s_crate::dto::Tag {
|
||||
key: Some("team".to_owned()),
|
||||
value: None,
|
||||
}],
|
||||
false,
|
||||
),
|
||||
(vec![tag.clone(), tag], false),
|
||||
] {
|
||||
let persisted = PersistedTagging {
|
||||
tag_set: tag_set
|
||||
.iter()
|
||||
.map(|tag| rustfs_gateway_types::cors_tagging::PersistedTag {
|
||||
key: tag.key.clone(),
|
||||
value: tag.value.clone(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let input = PutBucketTaggingInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.tagging(s3s_crate::dto::Tagging { tag_set })
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut req = build_request(input, Method::PUT);
|
||||
req.extensions.insert(ReqInfo::default());
|
||||
req.extensions.insert(slot.clone());
|
||||
fs.put_bucket_tagging(&mut req).await.expect("permission allows tag updates");
|
||||
let result = usecase.execute_put_bucket_tagging(req, persisted).await;
|
||||
if valid {
|
||||
result.expect("valid tags are persisted");
|
||||
} else {
|
||||
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InvalidTag);
|
||||
}
|
||||
assert_eq!(
|
||||
store.get_bucket_tags_for_policy(&bucket).await.unwrap(),
|
||||
HashMap::from([("team".to_owned(), "storage".to_owned())])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutBucketTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.tagging(Tagging {
|
||||
tag_set: vec![Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: Some("prod".to_string()),
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase
|
||||
.execute_put_bucket_tagging(req, PersistedTagging::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Request fixtures shared by the bucket use-case tests.
|
||||
|
||||
use super::*;
|
||||
use http::{Extensions, HeaderMap, Method, Uri};
|
||||
|
||||
pub(super) fn s3_op_total(op: S3Operation) -> u64 {
|
||||
rustfs_io_metrics::s3_op_metrics_snapshot()
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.op == op.as_str())
|
||||
.map(|snapshot| snapshot.total)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_request_with_req_info<T>(input: T, method: Method, req_info: ReqInfo) -> S3Request<T> {
|
||||
let mut req = build_request(input, method);
|
||||
req.extensions.insert(req_info);
|
||||
req
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket versioning use-case operations.
|
||||
|
||||
use super::*;
|
||||
use crate::app::storage_api::bucket_usecase::bucket::{ObjectLockConfigExt as _, VersioningConfigExt as _};
|
||||
|
||||
fn versioning_configuration_has_object_lock_incompatible_settings(config: &PersistedVersioningConfiguration) -> bool {
|
||||
config.suspended()
|
||||
|| config.exclude_folders.unwrap_or(false)
|
||||
|| config
|
||||
.excluded_prefixes
|
||||
.as_ref()
|
||||
.is_some_and(|excluded_prefixes| !excluded_prefixes.is_empty())
|
||||
}
|
||||
|
||||
async fn validate_bucket_versioning_update(bucket: &str, config: &PersistedVersioningConfiguration) -> S3Result<()> {
|
||||
if config
|
||||
.status
|
||||
.as_ref()
|
||||
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
|
||||
{
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"bucket versioning configuration has an invalid status",
|
||||
));
|
||||
}
|
||||
match metadata_sys::get_object_lock_config(bucket).await {
|
||||
Ok((object_lock_config, _)) => {
|
||||
if object_lock_config.enabled() && versioning_configuration_has_object_lock_incompatible_settings(config) {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidBucketState,
|
||||
"An Object Lock configuration is present on this bucket, versioning cannot be suspended.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => {}
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
}
|
||||
// AWS S3 and MinIO both refuse to suspend versioning while a replication
|
||||
// configuration exists: suspension would start minting null versions that
|
||||
// the replication engine (versioned by contract) can never converge.
|
||||
if config.suspended() {
|
||||
match metadata_sys::get_replication_config(bucket).await {
|
||||
Ok(_) => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidBucketState,
|
||||
"A replication configuration is present on this bucket, bucket wide versioning cannot be suspended."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => {}
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_get_bucket_versioning(
|
||||
&self,
|
||||
req: S3Request<GetBucketVersioningInput>,
|
||||
) -> S3Result<PersistedVersioningConfiguration> {
|
||||
let GetBucketVersioningInput { bucket, .. } = req.input;
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
BucketVersioningSys::get(&bucket)
|
||||
.await
|
||||
.map_err(|err| ApiError::from(err).into())
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_put_bucket_versioning(
|
||||
&self,
|
||||
req: S3Request<PutBucketVersioningInput>,
|
||||
prepared: S3Result<PersistedVersioningConfiguration>,
|
||||
) -> S3Result<S3Response<PutBucketVersioningOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketVersioningInput { bucket, .. } = req.input;
|
||||
|
||||
let versioning_configuration = prepared?;
|
||||
validate_bucket_versioning_update(&bucket, &versioning_configuration).await?;
|
||||
|
||||
let data = serialize_config(&versioning_configuration)?;
|
||||
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_VERSIONING_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context, false).await;
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "version-config");
|
||||
item.versioning = Some(
|
||||
serialize_config(&versioning_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
|
||||
);
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket versioning hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutBucketVersioningOutput {}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::bucket_usecase::test_support::build_request;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_bucket_versioning_update_rejects_invalid_status_before_metadata_lookup() {
|
||||
let config = PersistedVersioningConfiguration {
|
||||
status: Some("Invalid".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_bucket_versioning_update("unregistered-test-bucket", &config)
|
||||
.await
|
||||
.expect_err("an invalid string-backed versioning status must be rejected before persistence");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_suspended() {
|
||||
let config = PersistedVersioningConfiguration {
|
||||
status: Some("Suspended".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_exclude_folders() {
|
||||
let config = PersistedVersioningConfiguration {
|
||||
exclude_folders: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_excluded_prefixes() {
|
||||
let config = PersistedVersioningConfiguration {
|
||||
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_get_bucket_versioning_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetBucketVersioningInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::GET);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_get_bucket_versioning(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bucket website configuration use-case operations (reserved for the next batch).
|
||||
+4102
-21
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! AbortMultipartUpload.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultMultipartUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_abort_multipart_upload(
|
||||
&self,
|
||||
req: S3Request<AbortMultipartUploadInput>,
|
||||
) -> S3Result<S3Response<AbortMultipartUploadOutput>> {
|
||||
reject_presigned_multipart_max_total_object_size_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
record_s3_op(S3Operation::AbortMultipartUpload);
|
||||
let mut opts = ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?;
|
||||
let AbortMultipartUploadInput {
|
||||
bucket, key, upload_id, ..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
// Per the AWS S3 API reference, a malformed upload ID is reported as
|
||||
// NoSuchUpload (404), the same as an upload that was aborted or
|
||||
// completed. See multipart_upload_api_error.
|
||||
match store
|
||||
.abort_multipart_upload(bucket.as_str(), key.as_str(), upload_id.as_str(), &opts)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
);
|
||||
Ok(S3Response::new(AbortMultipartUploadOutput { ..Default::default() }))
|
||||
}
|
||||
Err(err) => Err(multipart_upload_api_error(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_abort_multipart_upload_returns_internal_error_when_store_uninitialized() {
|
||||
let input = AbortMultipartUploadInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::DELETE);
|
||||
let before = s3_op_total(S3Operation::AbortMultipartUpload);
|
||||
|
||||
let err = make_usecase().execute_abort_multipart_upload(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(s3_op_total(S3Operation::AbortMultipartUpload), before + 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,418 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CreateMultipartUpload.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_multipart_upload_metadata(
|
||||
input_metadata: Option<HashMap<String, String>>,
|
||||
headers: &HeaderMap,
|
||||
tagging: Option<String>,
|
||||
storage_class: Option<&crate::app::storage_api::s3::dto::StorageClass>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut metadata = input_metadata.unwrap_or_default();
|
||||
namespace_reserved_user_metadata(&mut metadata);
|
||||
extract_metadata_from_mime(headers, &mut metadata);
|
||||
|
||||
if let Some(tags) = tagging {
|
||||
metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags);
|
||||
}
|
||||
if let Some(storage_class) = storage_class {
|
||||
metadata.insert(AMZ_STORAGE_CLASS.to_owned(), storage_class.as_str().to_owned());
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
/// A multipart session advertises disk compression only when the staged-rollout
|
||||
/// switch (`RUSTFS_COMPRESSION_MULTIPART_ENABLED`) is on, the object key/headers
|
||||
/// qualify, AND the session is not an SSE-C ciphertext-passthrough replication
|
||||
/// session, which must preserve source bytes verbatim.
|
||||
///
|
||||
/// The rollout switch defaults to off so a rolling upgrade never creates new
|
||||
/// compressed multipart objects while pre-fix nodes (whose decompressor is not
|
||||
/// resumable) may still serve reads. Enable it once the fleet has converged on a
|
||||
/// fixed build; the default flips per the `multipart-compression-default-off-window`
|
||||
/// entry in docs/architecture/compat-cleanup-register.md.
|
||||
///
|
||||
/// Each part is compressed as an independent stream; the GET path decodes across part
|
||||
/// boundaries (see `ReadTransform::Compressed`), so the session may advertise
|
||||
/// object-level compression again.
|
||||
///
|
||||
/// Unlike single PUT there is no `MIN_DISK_COMPRESSIBLE_SIZE` floor here: the total
|
||||
/// object size is unknown at CreateMultipartUpload time, so tiny multipart objects pay
|
||||
/// the (harmless) framing overhead. This is a deliberate trade-off, not a bug.
|
||||
fn should_advertise_session_compression(multipart_enabled: bool, ciphertext_passthrough: bool, disk_compressible: bool) -> bool {
|
||||
multipart_enabled && !ciphertext_passthrough && disk_compressible
|
||||
}
|
||||
|
||||
impl DefaultMultipartUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
#[hotpath::measure(impl_type = "MultipartUsecase")]
|
||||
pub async fn execute_create_multipart_upload(
|
||||
&self,
|
||||
req: S3Request<CreateMultipartUploadInput>,
|
||||
) -> S3Result<S3Response<CreateMultipartUploadOutput>> {
|
||||
validate_new_object_key(&req.input.key, "CreateMultipartUpload")?;
|
||||
let multipart_max_total_object_size = parse_presigned_multipart_max_total_object_size(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
let helper =
|
||||
OperationHelper::new(&req, EventName::ObjectCreatedCreateMultipartUpload, S3Operation::CreateMultipartUpload)
|
||||
.suppress_event();
|
||||
let replication_authorized = replication_request_authorized(&req);
|
||||
let CreateMultipartUploadInput {
|
||||
bucket,
|
||||
key,
|
||||
tagging,
|
||||
version_id,
|
||||
storage_class,
|
||||
server_side_encryption,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5,
|
||||
ssekms_key_id,
|
||||
object_lock_legal_hold_status,
|
||||
object_lock_mode,
|
||||
object_lock_retain_until_date,
|
||||
metadata: input_metadata,
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?);
|
||||
let ssekms_key_id = ssekms_key_id.or_else(|| {
|
||||
req.headers
|
||||
.get("x-amz-server-side-encryption-aws-kms-key-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
|
||||
// Validate storage class if provided
|
||||
if let Some(ref storage_class) = storage_class
|
||||
&& !is_valid_storage_class(storage_class.as_str())
|
||||
{
|
||||
return Err(s3_error!(InvalidStorageClass));
|
||||
}
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
validate_archive_content_encoding(
|
||||
&key,
|
||||
req.headers.get("content-type").and_then(|value| value.to_str().ok()),
|
||||
req.headers.get("content-encoding").and_then(|value| value.to_str().ok()),
|
||||
)?;
|
||||
|
||||
let mut metadata = create_multipart_upload_metadata(input_metadata, &req.headers, tagging, storage_class.as_ref());
|
||||
if let Some(limit) = multipart_max_total_object_size {
|
||||
insert_str(&mut metadata, SUFFIX_MAX_TOTAL_OBJECT_SIZE, limit.to_string());
|
||||
}
|
||||
|
||||
let has_explicit_object_lock_retention = object_lock_mode.is_some()
|
||||
|| object_lock_retain_until_date.is_some()
|
||||
|| has_replication_retention_update(&req.headers, replication_authorized);
|
||||
let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?;
|
||||
if let Some(object_lock_metadata) = build_put_like_object_lock_metadata(
|
||||
&bucket,
|
||||
&object_lock_config_state,
|
||||
object_lock_legal_hold_status,
|
||||
object_lock_mode,
|
||||
object_lock_retain_until_date,
|
||||
)? {
|
||||
metadata.extend(object_lock_metadata);
|
||||
}
|
||||
apply_bucket_default_lock_retention(
|
||||
&bucket,
|
||||
&object_lock_config_state,
|
||||
&mut metadata,
|
||||
has_explicit_object_lock_retention,
|
||||
)?;
|
||||
let (header_sse_customer_algorithm, header_sse_customer_key, header_sse_customer_key_md5) =
|
||||
extract_ssec_params_from_headers(&req.headers)?;
|
||||
let sse_customer_algorithm = sse_customer_algorithm.or(header_sse_customer_algorithm);
|
||||
let sse_customer_key = sse_customer_key.or(header_sse_customer_key);
|
||||
let sse_customer_key_md5 = sse_customer_key_md5.or(header_sse_customer_key_md5);
|
||||
|
||||
// The session data key is generated here, so this is where a multipart upload is held
|
||||
// to the KMS key it names. Parts and completion also authorize their own caller.
|
||||
let session_principal = SseKmsPrincipal::from_request(&req);
|
||||
let encryption_request = PrepareEncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption,
|
||||
ssekms_key_id,
|
||||
ssekms_context: extract_ssekms_context_from_headers(&req.headers)?,
|
||||
sse_customer_algorithm: sse_customer_algorithm.clone(),
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5: sse_customer_key_md5.clone(),
|
||||
principal: session_principal.as_ref(),
|
||||
};
|
||||
|
||||
// SSE-C ciphertext passthrough: parts are already encrypted, so no
|
||||
// session DEK is prepared; a session marker tells UploadPart to store
|
||||
// the ciphertext verbatim instead of recovering encryption material.
|
||||
let ciphertext_passthrough = replication_authorized
|
||||
&& get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true")
|
||||
&& rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some();
|
||||
if ciphertext_passthrough && let Some(metadata_sys) = self.bucket_metadata_sys() {
|
||||
let check_result = map_quota_check_outcome(
|
||||
&bucket,
|
||||
QuotaChecker::new(metadata_sys)
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 0)
|
||||
.await,
|
||||
)?;
|
||||
if check_result.quota_limit.is_some() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
"SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if ciphertext_passthrough {
|
||||
insert_str(&mut metadata, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, "true".to_string());
|
||||
}
|
||||
|
||||
let prepared_material = if ciphertext_passthrough {
|
||||
None
|
||||
} else {
|
||||
sse_prepare_encryption(encryption_request).await?
|
||||
};
|
||||
let (effective_sse, effective_kms_key_id) = match prepared_material {
|
||||
Some(material) => {
|
||||
let server_side_encryption = Some(material.server_side_encryption.clone());
|
||||
let ssekms_key_id = material.response_kms_key_id();
|
||||
|
||||
let mut encryption_metadata = encryption_material_to_metadata(&material)?;
|
||||
if material.key_kind == EncryptionKeyKind::Object {
|
||||
mark_encrypted_multipart_metadata(&mut encryption_metadata);
|
||||
}
|
||||
metadata.extend(encryption_metadata);
|
||||
|
||||
(server_side_encryption, ssekms_key_id)
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
if should_advertise_session_compression(
|
||||
is_multipart_disk_compression_enabled(),
|
||||
ciphertext_passthrough,
|
||||
is_disk_compressible(&req.headers, &key),
|
||||
) {
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
compression_metadata_value(CompressionAlgorithm::default()),
|
||||
);
|
||||
}
|
||||
|
||||
let mt2 = metadata.clone();
|
||||
let mut opts: ObjectOptions =
|
||||
put_opts_with_replication_authorization(&bucket, &key, version_id, &req.headers, metadata, replication_authorized)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut opts)?;
|
||||
|
||||
let dsc =
|
||||
must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone())
|
||||
.await;
|
||||
if dsc.replicate_any() {
|
||||
insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_GENERATION, Uuid::new_v4().to_string());
|
||||
insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
|
||||
insert_str(
|
||||
&mut opts.user_defined,
|
||||
SUFFIX_REPLICATION_STATUS,
|
||||
dsc.pending_status().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
if !super::super::object::shared::put_like_write_creates_new_version(&opts)
|
||||
&& super::super::object::shared::object_lock_checks_required_for_state(&object_lock_config_state)
|
||||
{
|
||||
let current_opts: ObjectOptions = internal_object_info_lookup_opts(
|
||||
get_opts(&bucket, &key, opts.version_id.clone(), None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?,
|
||||
);
|
||||
match store.get_object_info(&bucket, &key, ¤t_opts).await {
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?
|
||||
}
|
||||
Err(err) => {
|
||||
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let checksum_type = rustfs_rio::ChecksumType::from_header(&req.headers);
|
||||
if checksum_type.is(rustfs_rio::ChecksumType::INVALID) {
|
||||
return Err(s3_error!(InvalidArgument, "Invalid checksum type"));
|
||||
} else if checksum_type.is_set() && !checksum_type.is(rustfs_rio::ChecksumType::TRAILING) {
|
||||
opts.want_checksum = Some(rustfs_rio::Checksum {
|
||||
checksum_type,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if effective_sse.is_some() && opts.want_checksum.is_some() {
|
||||
insert_str(&mut opts.user_defined, SUFFIX_PLAINTEXT_CHECKSUM, "true".to_string());
|
||||
}
|
||||
|
||||
let MultipartUploadResult {
|
||||
upload_id,
|
||||
checksum_algo,
|
||||
checksum_type,
|
||||
} = store
|
||||
.new_multipart_upload(&bucket, &key, &opts)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let output = CreateMultipartUploadOutput {
|
||||
bucket: Some(bucket),
|
||||
key: Some(key),
|
||||
upload_id: Some(upload_id),
|
||||
server_side_encryption: effective_sse,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key_md5,
|
||||
ssekms_key_id: effective_kms_key_id,
|
||||
checksum_algorithm: checksum_algo.map(ChecksumAlgorithm::from),
|
||||
checksum_type: checksum_type.map(ChecksumType::from),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_compression_is_advertised_only_for_non_passthrough_compressible_uploads() {
|
||||
// (multipart_enabled, ciphertext_passthrough, disk_compressible, expected)
|
||||
let cases = [
|
||||
(true, false, false, false),
|
||||
(true, false, true, true),
|
||||
(true, true, false, false),
|
||||
(true, true, true, false),
|
||||
// The staged-rollout switch keeps multipart compression dark by
|
||||
// default regardless of the other gates.
|
||||
(false, false, true, false),
|
||||
(false, false, false, false),
|
||||
(false, true, true, false),
|
||||
(false, true, false, false),
|
||||
];
|
||||
|
||||
for (multipart_enabled, ciphertext_passthrough, disk_compressible, expected) in cases {
|
||||
assert_eq!(
|
||||
should_advertise_session_compression(multipart_enabled, ciphertext_passthrough, disk_compressible),
|
||||
expected,
|
||||
"multipart_enabled={multipart_enabled} ciphertext_passthrough={ciphertext_passthrough} disk_compressible={disk_compressible}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_create_multipart_upload_rejects_invalid_storage_class() {
|
||||
let input = CreateMultipartUploadInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.storage_class(Some(StorageClass::from("invalid".to_string())))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::POST);
|
||||
|
||||
let err = make_usecase().execute_create_multipart_upload(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multipart_upload_metadata_persists_dto_storage_class_without_raw_header() {
|
||||
let metadata = create_multipart_upload_metadata(
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
None,
|
||||
Some(&StorageClass::from_static("REDUCED_REDUNDANCY")),
|
||||
);
|
||||
|
||||
assert_eq!(metadata.get(AMZ_STORAGE_CLASS), Some(&"REDUCED_REDUNDANCY".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multipart_upload_metadata_keeps_user_and_system_namespaces_separate() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-type", HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(AMZ_STORAGE_CLASS, HeaderValue::from_static("STANDARD"));
|
||||
let input_metadata = HashMap::from([
|
||||
("content-type".to_string(), "user-content-type".to_string()),
|
||||
(AMZ_STORAGE_CLASS.to_string(), "user-storage-class".to_string()),
|
||||
]);
|
||||
|
||||
let metadata = create_multipart_upload_metadata(
|
||||
Some(input_metadata),
|
||||
&headers,
|
||||
Some("project=rustfs".to_string()),
|
||||
Some(&StorageClass::from_static("REDUCED_REDUNDANCY")),
|
||||
);
|
||||
|
||||
assert_eq!(metadata.get("content-type"), Some(&"application/octet-stream".to_string()));
|
||||
assert_eq!(metadata.get("x-amz-meta-content-type"), Some(&"user-content-type".to_string()));
|
||||
assert_eq!(metadata.get(AMZ_STORAGE_CLASS), Some(&"REDUCED_REDUNDANCY".to_string()));
|
||||
assert_eq!(metadata.get("x-amz-meta-x-amz-storage-class"), Some(&"user-storage-class".to_string()));
|
||||
assert_eq!(metadata.get(AMZ_OBJECT_TAGGING), Some(&"project=rustfs".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn create_multipart_rejects_ciphertext_replication_before_parts_are_staged() {
|
||||
let (_store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-multipart-quota", 4096).await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
let input = CreateMultipartUploadInput::builder()
|
||||
.bucket(bucket)
|
||||
.key("object".to_string())
|
||||
.build()
|
||||
.expect("create multipart request should build");
|
||||
let mut request = build_request(input, Method::POST);
|
||||
insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
request
|
||||
.headers
|
||||
.insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256"));
|
||||
request.extensions.insert(crate::storage::access::ReqInfo {
|
||||
replication_request_authorized: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_create_multipart_upload(request)
|
||||
.await
|
||||
.expect_err("quota-enabled ciphertext multipart replication should fail before upload creation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! ListMultipartUploads and ListParts.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultMultipartUsecase {
|
||||
pub async fn execute_list_multipart_uploads(
|
||||
&self,
|
||||
req: S3Request<ListMultipartUploadsInput>,
|
||||
) -> S3Result<S3Response<ListMultipartUploadsOutput>> {
|
||||
let table_list_access = req.extensions.get::<TableDataPlaneListAccess>().cloned();
|
||||
reject_presigned_multipart_max_total_object_size_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
let mut opts = ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?;
|
||||
let ListMultipartUploadsInput {
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
max_uploads,
|
||||
encoding_type,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
let ListMultipartUploadsParams {
|
||||
prefix,
|
||||
key_marker,
|
||||
max_uploads,
|
||||
} = parse_list_multipart_uploads_params(prefix, key_marker, max_uploads)?;
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
// `apply_bucket_generation_guard` tolerates a missing guard (only the S3
|
||||
// access layer installs one), so resolve the current generation rather
|
||||
// than failing the request. Listing is filtered by this value, so a
|
||||
// stale one simply hides foreign-incarnation uploads, as intended.
|
||||
let expected_incarnation_id = match opts.expected_bucket_incarnation_id {
|
||||
Some(incarnation_id) => incarnation_id,
|
||||
None => store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)?,
|
||||
};
|
||||
|
||||
let result = match table_list_access.as_ref() {
|
||||
Some(access) => {
|
||||
table_list_isolation::list_multipart_uploads(
|
||||
store.clone(),
|
||||
access,
|
||||
table_list_isolation::ListMultipartUploadsRequest {
|
||||
bucket: &bucket,
|
||||
prefix: &prefix,
|
||||
key_marker: key_marker.as_deref(),
|
||||
upload_id_marker: upload_id_marker.as_deref(),
|
||||
delimiter: delimiter.as_deref(),
|
||||
max_uploads,
|
||||
expected_incarnation_id,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => store
|
||||
.list_multipart_uploads_for_bucket_incarnation(
|
||||
&bucket,
|
||||
&prefix,
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
max_uploads,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?,
|
||||
};
|
||||
|
||||
Ok(S3Response::new(build_list_multipart_uploads_output(
|
||||
bucket,
|
||||
prefix,
|
||||
result,
|
||||
encoding_type.as_ref(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn execute_list_parts(&self, req: S3Request<ListPartsInput>) -> S3Result<S3Response<ListPartsOutput>> {
|
||||
reject_presigned_multipart_max_total_object_size_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
let mut opts = ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?;
|
||||
let ListPartsInput {
|
||||
bucket,
|
||||
key,
|
||||
upload_id,
|
||||
part_number_marker,
|
||||
max_parts,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
let params = parse_list_parts_params(part_number_marker, max_parts)?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let res = store
|
||||
.list_object_parts(&bucket, &key, &upload_id, params.part_number_marker, params.max_parts, &opts)
|
||||
.await
|
||||
.map_err(multipart_upload_api_error)?;
|
||||
|
||||
Ok(S3Response::new(build_list_parts_output(res)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_multipart_uploads_returns_internal_error_when_store_uninitialized() {
|
||||
let input = ListMultipartUploadsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
|
||||
let err = make_usecase().execute_list_multipart_uploads(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_multipart_uploads_rejects_invalid_key_marker_before_store_lookup() {
|
||||
let input = ListMultipartUploadsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.prefix(Some("prefix/".to_string()))
|
||||
.key_marker(Some("other/key".to_string()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
|
||||
let err = make_usecase().execute_list_multipart_uploads(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some("Invalid key marker"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_multipart_uploads_rejects_invalid_max_uploads_before_store_lookup() {
|
||||
let input = ListMultipartUploadsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.max_uploads(Some(0))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
let expected = "max-uploads must be between 1 and 1000";
|
||||
|
||||
let err = make_usecase().execute_list_multipart_uploads(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_parts_returns_internal_error_when_store_uninitialized() {
|
||||
let input = ListPartsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
|
||||
let err = make_usecase().execute_list_parts(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_parts_rejects_negative_part_number_marker_before_store_lookup() {
|
||||
let input = ListPartsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.part_number_marker(Some(-1))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
|
||||
let err = make_usecase().execute_list_parts(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some("part-number-marker must be non-negative"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_list_parts_rejects_invalid_max_parts_before_store_lookup() {
|
||||
let input = ListPartsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.max_parts(Some(1001))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::GET);
|
||||
|
||||
let err = make_usecase().execute_list_parts(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some("max-parts must be between 1 and 1000"));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Fixtures and test-only imports shared by the multipart use-case tests.
|
||||
|
||||
use super::*;
|
||||
pub(super) use crate::app::storage_api::s3::dto::CompletedMultipartUpload;
|
||||
pub(super) use crate::app::storage_api::s3::dto::StorageClass;
|
||||
pub(super) use http::Extensions;
|
||||
pub(super) use http::Method;
|
||||
pub(super) use rustfs_filemeta::ObjectPartInfo;
|
||||
pub(super) use rustfs_utils::http::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER;
|
||||
pub(super) use rustfs_utils::http::AMZ_OBJECT_LOCK_MODE_LOWER;
|
||||
pub(super) use rustfs_utils::http::AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER;
|
||||
pub(super) use std::io::Cursor;
|
||||
pub(super) use temp_env::async_with_vars;
|
||||
pub(super) use tokio::io::AsyncReadExt;
|
||||
|
||||
pub(super) fn s3_op_total(op: S3Operation) -> u64 {
|
||||
rustfs_io_metrics::s3_op_metrics_snapshot()
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.op == op.as_str())
|
||||
.map(|snapshot| snapshot.total)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn make_usecase() -> DefaultMultipartUsecase {
|
||||
DefaultMultipartUsecase::without_context()
|
||||
}
|
||||
@@ -1,823 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! UploadPart.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn resolve_upload_part_size(content_length: Option<i64>, body: Option<&StreamingBlob>) -> rustfs_s3_types::S3Result<Option<i64>> {
|
||||
if let Some(length) = content_length {
|
||||
return Ok(Some(length));
|
||||
}
|
||||
body.and_then(|body| body.remaining_length().exact())
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| rustfs_s3_types::s3_error!(UnexpectedContent))
|
||||
}
|
||||
|
||||
fn require_upload_part_size(size: Option<i64>, capped: bool) -> rustfs_s3_types::S3Result<i64> {
|
||||
match size {
|
||||
Some(size) if size >= 0 => Ok(size),
|
||||
Some(_) => Err(rustfs_s3_types::s3_error!(UnexpectedContent)),
|
||||
None if capped => Err(rustfs_s3_types::s3_error!(UnexpectedContent)),
|
||||
None => Err(rustfs_s3_types::S3Error::new(rustfs_s3_types::S3ErrorCode::MissingContentLength)),
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_part_body_read_timeout(configured: Duration, capped: bool) -> Duration {
|
||||
if capped {
|
||||
configured.max(Duration::from_secs(rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT))
|
||||
} else {
|
||||
configured
|
||||
}
|
||||
}
|
||||
|
||||
/// Plaintext length of one SSE-C passthrough part, declared by the sender on
|
||||
/// UploadPart (backlog#2363).
|
||||
fn passthrough_part_actual_size(headers: &HeaderMap) -> Option<i64> {
|
||||
get_header(headers, rustfs_utils::http::SUFFIX_REPLICATION_PART_ACTUAL_SIZE)?
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.filter(|size| *size > 0)
|
||||
}
|
||||
|
||||
// Hoisted values are signed query parameters, not HTTP headers. Normalize only
|
||||
// after access control has verified the presigned request.
|
||||
fn normalize_presigned_part_checksums(req: &mut S3Request<UploadPartInput>) -> S3Result<()> {
|
||||
if req.extensions.get::<VerifiedPresignedRequest>().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(query) = req.uri.query() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
let field = match name.as_ref() {
|
||||
"x-amz-checksum-crc32" => Some(&mut req.input.checksum_crc32),
|
||||
"x-amz-checksum-crc32c" => Some(&mut req.input.checksum_crc32c),
|
||||
"x-amz-checksum-crc64nvme" => Some(&mut req.input.checksum_crc64nvme),
|
||||
"x-amz-checksum-sha1" => Some(&mut req.input.checksum_sha1),
|
||||
"x-amz-checksum-sha256" => Some(&mut req.input.checksum_sha256),
|
||||
"x-amz-sdk-checksum-algorithm" => None,
|
||||
_ => continue,
|
||||
};
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Duplicate checksum query parameter"));
|
||||
}
|
||||
if let Some(header) = req.headers.get(name.as_ref())
|
||||
&& header.as_bytes() != value.as_bytes()
|
||||
{
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
"Conflicting checksum header and query parameter",
|
||||
));
|
||||
}
|
||||
let header = http::HeaderValue::from_str(&value)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid checksum query parameter"))?;
|
||||
req.headers.insert(
|
||||
http::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid checksum query parameter"))?,
|
||||
header,
|
||||
);
|
||||
if let Some(field) = field {
|
||||
*field = Some(value.into_owned());
|
||||
} else {
|
||||
req.input.checksum_algorithm = Some(ChecksumAlgorithm::from(value.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DefaultMultipartUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
#[hotpath::measure(impl_type = "MultipartUsecase")]
|
||||
pub async fn execute_upload_part(&self, mut req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
|
||||
reject_presigned_multipart_max_total_object_size_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
normalize_presigned_part_checksums(&mut req)?;
|
||||
let mut opts = ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?;
|
||||
let part_principal = SseKmsPrincipal::from_request(&req);
|
||||
let input = req.input;
|
||||
let UploadPartInput {
|
||||
body,
|
||||
bucket,
|
||||
key,
|
||||
upload_id,
|
||||
part_number,
|
||||
content_length,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5,
|
||||
// content_md5,
|
||||
..
|
||||
} = input;
|
||||
|
||||
let part_id = parse_upload_part_number(part_number)?;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let size = resolve_upload_part_size(content_length, body.as_ref())?;
|
||||
if let Some(size) = size {
|
||||
reject_oversize_single_upload(size)?;
|
||||
}
|
||||
let body_stream = body.ok_or_else(|| s3_error!(IncompleteBody))?;
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
let fi = store
|
||||
.get_multipart_info(&bucket, &key, &upload_id, &opts)
|
||||
.await
|
||||
.map_err(multipart_upload_api_error)?;
|
||||
authorize_sse_kms_multipart_write(part_principal.as_ref(), &fi.user_defined).await?;
|
||||
let max_total_object_size = multipart_max_total_object_size(&fi.user_defined)?;
|
||||
let mut size = require_upload_part_size(size, max_total_object_size.is_some())?;
|
||||
if let Some(limit) = max_total_object_size
|
||||
&& u64::try_from(size).is_ok_and(|size| size > limit)
|
||||
{
|
||||
return Err(S3Error::new(S3ErrorCode::EntityTooLarge));
|
||||
}
|
||||
let admission_wait_started = rustfs_io_metrics::put_stage_timer();
|
||||
let admission = self.concurrency_manager().admit_multipart_part(size).await;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from("multipart_admission_wait", admission_wait_started);
|
||||
let upload_part_admission = match admission
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "foreground write admission closed"))?
|
||||
{
|
||||
ForegroundWriteAdmission::Disabled => None,
|
||||
ForegroundWriteAdmission::Admitted(permit) => {
|
||||
counter!("rustfs.upload_part.foreground_admission.total", "result" => "admitted").increment(1);
|
||||
Some(permit)
|
||||
}
|
||||
ForegroundWriteAdmission::Rejected => {
|
||||
counter!("rustfs.upload_part.foreground_admission.total", "result" => "rejected").increment(1);
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::SlowDown,
|
||||
"foreground write concurrency limit reached, please reduce your request rate",
|
||||
));
|
||||
}
|
||||
};
|
||||
let request_id = req
|
||||
.extensions
|
||||
.get::<super::super::storage_api::multipart_usecase::request_context::RequestContext>()
|
||||
.map(|ctx| ctx.request_id.as_str())
|
||||
.unwrap_or_default();
|
||||
let timeout = upload_part_body_read_timeout(put_object_body_read_timeout(), max_total_object_size.is_some());
|
||||
let (body_stream, progress_control) =
|
||||
if let Some(control) = req.extensions.get::<super::super::object::request_body::BodyReadControl>() {
|
||||
control.activate_upload(timeout, &bucket, &key, request_id, size.max(0) as u64);
|
||||
(body_stream, control.clone())
|
||||
} else {
|
||||
super::super::object::request_body::guard_upload_progress(
|
||||
body_stream,
|
||||
timeout,
|
||||
&bucket,
|
||||
&key,
|
||||
request_id,
|
||||
size.max(0) as u64,
|
||||
)
|
||||
};
|
||||
|
||||
let ingress_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(std::time::Instant::now);
|
||||
|
||||
// Apply adaptive buffer sizing based on part size for optimal streaming performance.
|
||||
// Uses workload profile configuration (enabled by default) to select appropriate buffer size.
|
||||
// Buffer sizes range from 32KB to 4MB depending on part size and configured workload profile.
|
||||
let buffer_size = get_buffer_size_opt_in(size);
|
||||
let body = tokio::io::BufReader::with_capacity(
|
||||
buffer_size,
|
||||
StreamReader::new(body_stream.map(|f| f.map_err(s3s_body_error_to_io))),
|
||||
);
|
||||
|
||||
// An SSE-C passthrough session stores ciphertext parts verbatim: a
|
||||
// compression key restored on the session describes those stored
|
||||
// bytes and must not add a second compression layer, and each part's
|
||||
// plaintext length comes from the sender (backlog#2363).
|
||||
let preserve_ciphertext = contains_key_str(&fi.user_defined, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT);
|
||||
let is_disk_compressed = !preserve_ciphertext
|
||||
&& rustfs_utils::http::contains_key_str(&fi.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION);
|
||||
|
||||
let actual_size = if preserve_ciphertext {
|
||||
passthrough_part_actual_size(&req.headers).unwrap_or(size)
|
||||
} else {
|
||||
size
|
||||
};
|
||||
|
||||
let mut md5hex = if let Some(base64_md5) = input.content_md5 {
|
||||
let md5 = base64_simd::STANDARD
|
||||
.decode_to_vec(base64_md5.as_bytes())
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?;
|
||||
Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query());
|
||||
|
||||
let mut write_plan = WritePlan::new();
|
||||
let mut reader = if is_disk_compressed {
|
||||
let algorithm = CompressionAlgorithm::default();
|
||||
let mut hrd = HashReader::from_stream(body, size, actual_size, md5hex.take(), sha256hex.take(), false)
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
if let Err(err) = hrd.add_checksum(&req.headers, trailer_source(req.trailing_headers.clone()), false) {
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
|
||||
size = HashReader::SIZE_PRESERVE_LAYER;
|
||||
write_plan = write_plan.with_compression(algorithm);
|
||||
hrd
|
||||
} else {
|
||||
HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?
|
||||
};
|
||||
|
||||
if let Err(err) = reader.add_checksum(&req.headers, trailer_source(req.trailing_headers.clone()), size < 0) {
|
||||
return Err(ApiError::from(err).into());
|
||||
}
|
||||
opts.want_checksum = reader.checksum();
|
||||
|
||||
// An SSE-C passthrough session stores ciphertext parts verbatim: no
|
||||
// material recovery, no validation against the (absent) customer key.
|
||||
let has_ssec = !preserve_ciphertext
|
||||
&& fi
|
||||
.user_defined
|
||||
.contains_key("x-amz-server-side-encryption-customer-algorithm");
|
||||
let (server_side_encryption, ssekms_key_id) = if has_ssec || preserve_ciphertext {
|
||||
(None, None)
|
||||
} else {
|
||||
let sse = fi
|
||||
.user_defined
|
||||
.get("x-amz-server-side-encryption")
|
||||
.map(|s| {
|
||||
ServerSideEncryption::from_str(s)
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
|
||||
})
|
||||
.transpose()?;
|
||||
let key_id = match sse.as_ref() {
|
||||
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => fi
|
||||
.user_defined
|
||||
.get("x-amz-server-side-encryption-aws-kms-key-id")
|
||||
.map(|s| s.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
(sse, key_id)
|
||||
};
|
||||
if !preserve_ciphertext {
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: server_side_encryption.clone(),
|
||||
ssekms_key_id: ssekms_key_id.clone(),
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: sse_customer_algorithm.clone(),
|
||||
sse_customer_key: sse_customer_key.clone(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.clone(),
|
||||
content_size: actual_size,
|
||||
principal: None,
|
||||
}
|
||||
.validate_multipart_ssec(&fi.user_defined)?;
|
||||
}
|
||||
let (requested_sse, requested_kms_key_id) = if has_ssec {
|
||||
let ssec_material = sse_decryption(DecryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
metadata: &fi.user_defined,
|
||||
sse_customer_key: sse_customer_key.as_ref(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.as_ref(),
|
||||
principal: None,
|
||||
})
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::from(StorageError::other("Missing SSE-C session material")))?;
|
||||
let ssec_write = match ssec_material.key_kind {
|
||||
EncryptionKeyKind::Object => WriteEncryption::multipart_object_key(ssec_material.key_bytes, part_id as u32),
|
||||
EncryptionKeyKind::Direct => {
|
||||
WriteEncryption::multipart(ssec_material.key_bytes, ssec_material.base_nonce, part_id)
|
||||
}
|
||||
};
|
||||
write_plan = write_plan.with_encryption(ssec_write);
|
||||
(Some(ssec_material.server_side_encryption), ssec_material.kms_key_id)
|
||||
} else if let Some(server_side_encryption) = server_side_encryption {
|
||||
// Reuse the stored envelope after authorizing this caller against its
|
||||
// pinned destination key above.
|
||||
let managed_material = sse_decryption(DecryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
metadata: &fi.user_defined,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
principal: None,
|
||||
})
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::from(StorageError::other("Missing managed SSE session material")))?;
|
||||
let managed_write = match managed_material.key_kind {
|
||||
EncryptionKeyKind::Object => WriteEncryption::multipart_object_key(managed_material.key_bytes, part_id as u32),
|
||||
EncryptionKeyKind::Direct => {
|
||||
WriteEncryption::multipart(managed_material.key_bytes, managed_material.base_nonce, part_id)
|
||||
}
|
||||
};
|
||||
write_plan = write_plan.with_encryption(managed_write);
|
||||
(Some(server_side_encryption), ssekms_key_id)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?;
|
||||
|
||||
{
|
||||
use rustfs_rio::HashReaderMut;
|
||||
let inner = reader.take_inner();
|
||||
reader.inner =
|
||||
rustfs_rio::boxed_reader(super::super::object::request_body::DemandReader::new(inner, progress_control));
|
||||
}
|
||||
|
||||
let mut reader = PutObjReader::new(reader);
|
||||
|
||||
if let Some(stage_start) = ingress_stage_start {
|
||||
rustfs_io_metrics::record_put_object_stage_duration(
|
||||
"multipart_ingress_prepare",
|
||||
stage_start.elapsed().as_secs_f64() * 1000.0,
|
||||
);
|
||||
}
|
||||
|
||||
let _upload_part_admission = upload_part_admission;
|
||||
let store_write_started = rustfs_io_metrics::put_stage_timer();
|
||||
let result = store
|
||||
.put_object_part(&bucket, &key, &upload_id, part_id, &mut reader, &opts)
|
||||
.await;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from("multipart_store_write", store_write_started);
|
||||
drop(_upload_part_admission);
|
||||
let info = result.map_err(ApiError::from)?;
|
||||
|
||||
let mut checksum_crc32 = input.checksum_crc32;
|
||||
let mut checksum_crc32c = input.checksum_crc32c;
|
||||
let mut checksum_sha1 = input.checksum_sha1;
|
||||
let mut checksum_sha256 = input.checksum_sha256;
|
||||
let mut checksum_crc64nvme = input.checksum_crc64nvme;
|
||||
|
||||
if let Some(alg) = &input.checksum_algorithm
|
||||
&& let Some(Some(checksum_str)) = req.trailing_headers.as_ref().map(|trailer| {
|
||||
let key = match alg.as_str() {
|
||||
ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(),
|
||||
ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(),
|
||||
ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(),
|
||||
ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(),
|
||||
ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(),
|
||||
_ => return None,
|
||||
};
|
||||
trailer.read(|headers| {
|
||||
headers
|
||||
.get(key.unwrap_or_default())
|
||||
.and_then(|value| value.to_str().ok().map(|s| s.to_string()))
|
||||
})
|
||||
})
|
||||
{
|
||||
match alg.as_str() {
|
||||
ChecksumAlgorithm::CRC32 => checksum_crc32 = checksum_str,
|
||||
ChecksumAlgorithm::CRC32C => checksum_crc32c = checksum_str,
|
||||
ChecksumAlgorithm::SHA1 => checksum_sha1 = checksum_str,
|
||||
ChecksumAlgorithm::SHA256 => checksum_sha256 = checksum_str,
|
||||
ChecksumAlgorithm::CRC64NVME => checksum_crc64nvme = checksum_str,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
// XXHash3/64/128 and SHA-512 have no typed UploadPartOutput field; echo the
|
||||
// server-computed part checksum as a raw response header (#1261).
|
||||
let upload_part_extra_checksum_headers = crate::app::object_usecase::additional_checksum_echo_pairs(&opts.want_checksum);
|
||||
|
||||
let output = UploadPartOutput {
|
||||
server_side_encryption: requested_sse,
|
||||
ssekms_key_id: requested_kms_key_id,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key_md5,
|
||||
checksum_crc32,
|
||||
checksum_crc32c,
|
||||
checksum_sha1,
|
||||
checksum_sha256,
|
||||
checksum_crc64nvme,
|
||||
e_tag: info.etag.map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut response = S3Response::new(output);
|
||||
crate::app::object_usecase::inject_additional_checksum_headers(
|
||||
&mut response.headers,
|
||||
&upload_part_extra_checksum_headers,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
fn presigned_checksum_request(query: &str) -> S3Request<UploadPartInput> {
|
||||
let mut req = build_request(UploadPartInput::default(), Method::PUT);
|
||||
req.uri = format!("/bucket/object?{query}").parse().unwrap();
|
||||
req.extensions.insert(VerifiedPresignedRequest);
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normalize_presigned_part_checksums_validates_body() {
|
||||
// SHA256("abc"), including percent-encoded base64 punctuation.
|
||||
let checksum = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=";
|
||||
let query =
|
||||
"x-amz-checksum-sha256=ungWv48Bz%2BpBQUDeXa4iI7ADYaOWF3qctBD%2FYfIAFa0%3D&x-amz-sdk-checksum-algorithm=SHA256";
|
||||
for payload in [b"abc", b"abd"] {
|
||||
let mut req = presigned_checksum_request(query);
|
||||
normalize_presigned_part_checksums(&mut req).unwrap();
|
||||
assert_eq!(req.input.checksum_sha256.as_deref(), Some(checksum));
|
||||
assert_eq!(
|
||||
req.input.checksum_algorithm.as_ref().map(|algorithm| algorithm.as_str()),
|
||||
Some(ChecksumAlgorithm::SHA256)
|
||||
);
|
||||
let mut reader = HashReader::from_stream(Cursor::new(payload), 3, 3, None, None, false).unwrap();
|
||||
reader.add_checksum(&req.headers, None, false).unwrap();
|
||||
assert_eq!(reader.content_crc_type(), Some(rustfs_rio::ChecksumType::SHA256));
|
||||
let mut bytes = Vec::new();
|
||||
let result = reader.read_to_end(&mut bytes).await;
|
||||
if payload == b"abc" {
|
||||
result.unwrap();
|
||||
assert_eq!(bytes, payload);
|
||||
assert_eq!(reader.content_crc().get("SHA256").map(String::as_str), Some(checksum));
|
||||
} else {
|
||||
let err = S3Error::from(ApiError::from(result.unwrap_err()));
|
||||
assert_eq!(*err.code(), S3ErrorCode::BadDigest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normalize_presigned_part_checksums_accepts_sdk_crc32_default() {
|
||||
let mut req = presigned_checksum_request("x-amz-checksum-crc32=y%2FQ5Jg%3D%3D&x-amz-sdk-checksum-algorithm=CRC32");
|
||||
normalize_presigned_part_checksums(&mut req).unwrap();
|
||||
assert_eq!(req.input.checksum_crc32.as_deref(), Some("y/Q5Jg=="));
|
||||
let mut reader = HashReader::from_stream(Cursor::new(b"123456789"), 9, 9, None, None, false).unwrap();
|
||||
reader.add_checksum(&req.headers, None, false).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await.unwrap();
|
||||
assert_eq!(bytes, b"123456789");
|
||||
assert_eq!(reader.content_crc().get("CRC32").map(String::as_str), Some("y/Q5Jg=="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_presigned_part_checksums_rejects_ambiguous_values() {
|
||||
for query in [
|
||||
"x-amz-checksum-sha256=one&x-amz-checksum-sha256=two",
|
||||
"x-amz-sdk-checksum-algorithm=SHA256&x-amz-sdk-checksum-algorithm=CRC32",
|
||||
] {
|
||||
let mut req = presigned_checksum_request(query);
|
||||
assert_eq!(
|
||||
*normalize_presigned_part_checksums(&mut req).unwrap_err().code(),
|
||||
S3ErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
let mut req = presigned_checksum_request("x-amz-checksum-sha256=one");
|
||||
req.headers.insert("x-amz-checksum-sha256", HeaderValue::from_static("two"));
|
||||
assert_eq!(
|
||||
*normalize_presigned_part_checksums(&mut req).unwrap_err().code(),
|
||||
S3ErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_presigned_part_checksums_preserves_headers_and_rejects_invalid_values() {
|
||||
let mut req = presigned_checksum_request("partNumber=1&uploadId=test");
|
||||
req.input.checksum_sha256 = Some("existing".to_owned());
|
||||
req.headers
|
||||
.insert("x-amz-checksum-sha256", HeaderValue::from_static("existing"));
|
||||
normalize_presigned_part_checksums(&mut req).unwrap();
|
||||
assert_eq!(req.input.checksum_sha256.as_deref(), Some("existing"));
|
||||
assert_eq!(req.headers["x-amz-checksum-sha256"], "existing");
|
||||
|
||||
let mut req = presigned_checksum_request("x-amz-checksum-sha256=%0D%0A");
|
||||
assert_eq!(
|
||||
*normalize_presigned_part_checksums(&mut req).unwrap_err().code(),
|
||||
S3ErrorCode::InvalidArgument
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_presigned_part_checksums_requires_verified_presign() {
|
||||
let mut req = presigned_checksum_request("x-amz-checksum-sha256=one");
|
||||
req.extensions.remove::<VerifiedPresignedRequest>();
|
||||
normalize_presigned_part_checksums(&mut req).unwrap();
|
||||
assert!(req.input.checksum_sha256.is_none());
|
||||
assert!(req.headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_upload_part_size_uses_normalized_logical_length() {
|
||||
let result: rustfs_s3_types::S3Result<Option<i64>> = resolve_upload_part_size(Some(5242880), None);
|
||||
assert_eq!(result.expect("DTO length"), Some(5242880));
|
||||
assert_eq!(resolve_upload_part_size(None, None).expect("unknown length"), None);
|
||||
let body = StreamingBlob::from(Bytes::from_static(b"abc"));
|
||||
assert_eq!(resolve_upload_part_size(None, Some(&body)).expect("exact bytes"), Some(3));
|
||||
assert_eq!(resolve_upload_part_size(Some(0), Some(&body)).expect("explicit length wins"), Some(0));
|
||||
let body = StreamingBlob::wrap(futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"abc"))]));
|
||||
assert_eq!(futures::Stream::size_hint(&body), (1, Some(1)), "the stream knows its item count");
|
||||
assert_eq!(resolve_upload_part_size(None, Some(&body)).expect("unknown byte length"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_part_length_contract_rejects_unknown_and_all_negative_lengths() {
|
||||
let result: rustfs_s3_types::S3Result<i64> = require_upload_part_size(None, false);
|
||||
assert_eq!(
|
||||
result.expect_err("ordinary unknown").code(),
|
||||
&rustfs_s3_types::S3ErrorCode::MissingContentLength
|
||||
);
|
||||
assert_eq!(
|
||||
require_upload_part_size(None, true).expect_err("capped unknown").code(),
|
||||
&rustfs_s3_types::S3ErrorCode::UnexpectedContent
|
||||
);
|
||||
for capped in [false, true] {
|
||||
for size in [-1, -2, i64::MIN] {
|
||||
assert_eq!(
|
||||
require_upload_part_size(Some(size), capped).expect_err("negative").code(),
|
||||
&rustfs_s3_types::S3ErrorCode::UnexpectedContent
|
||||
);
|
||||
}
|
||||
assert_eq!(require_upload_part_size(Some(0), capped).expect("zero is valid"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_part_timeout_policy_preserves_disabled_and_capped_floor() {
|
||||
for seconds in [0, 1, 299, 300, 601] {
|
||||
let configured = Duration::from_secs(seconds);
|
||||
assert_eq!(upload_part_body_read_timeout(configured, false), configured);
|
||||
assert_eq!(upload_part_body_read_timeout(configured, true), Duration::from_secs(seconds.max(300)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_upload_part_rejects_missing_body() {
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.part_number(1)
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::PUT);
|
||||
|
||||
let err = make_usecase().execute_upload_part(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::IncompleteBody);
|
||||
}
|
||||
|
||||
/// issue #7596: a part whose declared length exceeds the 5 GiB
|
||||
/// single-request ceiling is rejected before the body is polled or the
|
||||
/// store is consulted. Exact-cap and zero-length parts pass admission.
|
||||
#[tokio::test]
|
||||
async fn execute_upload_part_rejects_oversize_declared_part_before_reading_the_body() {
|
||||
let ceiling = i64::try_from(rustfs_config::MAX_SINGLE_PUT_OBJECT_SIZE).expect("ceiling fits i64");
|
||||
|
||||
for (declared, expect_too_large) in [(ceiling + 1, true), (ceiling, false), (0, false)] {
|
||||
let (body, polls) = crate::app::object::PollCountingBody::streaming_blob();
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.part_number(1)
|
||||
.body(Some(body))
|
||||
.content_length(Some(declared))
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::PUT);
|
||||
|
||||
let err = make_usecase().execute_upload_part(req).await.unwrap_err();
|
||||
if expect_too_large {
|
||||
assert_eq!(err.code(), &S3ErrorCode::EntityTooLarge, "declared {declared}");
|
||||
assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), 0, "body must not be polled");
|
||||
} else {
|
||||
assert_ne!(err.code(), &S3ErrorCode::EntityTooLarge, "declared {declared} must pass admission");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_upload_part_rejects_invalid_part_number_before_body_lookup() {
|
||||
for part_number in [-1, 0, 10001] {
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.upload_id("upload-id".to_string())
|
||||
.part_number(part_number)
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::PUT);
|
||||
|
||||
let err = make_usecase().execute_upload_part(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some("partNumber must be between 1 and 10000"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn execute_upload_part_rejects_unknown_length_before_admission_or_body_polling() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations, MakeBucketOptions};
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
||||
let bucket = format!("upload-part-length-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket");
|
||||
let concurrency_manager = Arc::new(ConcurrencyManager::with_large_put_admission_for_test(
|
||||
true,
|
||||
1,
|
||||
rustfs_config::DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES,
|
||||
Duration::ZERO,
|
||||
));
|
||||
let held = concurrency_manager
|
||||
.admit_multipart_part(1024)
|
||||
.await
|
||||
.expect("hold the only permit");
|
||||
let usecase = DefaultMultipartUsecase::with_context_and_concurrency_manager(Some(context), concurrency_manager);
|
||||
|
||||
for capped in [false, true] {
|
||||
let mut options = ObjectOptions::default();
|
||||
if capped {
|
||||
insert_str(&mut options.user_defined, SUFFIX_MAX_TOTAL_OBJECT_SIZE, "1024".to_owned());
|
||||
}
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "object", &options)
|
||||
.await
|
||||
.expect("upload session");
|
||||
for content_length in [None, Some(-1)] {
|
||||
for declared_chunk_encoding in [false, true] {
|
||||
let (body, polls) = crate::app::object::PollCountingBody::streaming_blob();
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("object".to_owned())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.part_number(1)
|
||||
.content_length(content_length)
|
||||
.body(Some(body))
|
||||
.build()
|
||||
.expect("part request");
|
||||
let mut request = build_request(input, Method::PUT);
|
||||
request
|
||||
.headers
|
||||
.insert("x-amz-decoded-content-length", HeaderValue::from_static("1024"));
|
||||
if declared_chunk_encoding {
|
||||
request
|
||||
.headers
|
||||
.insert("content-encoding", HeaderValue::from_static("aws-chunked"));
|
||||
}
|
||||
let error = usecase
|
||||
.execute_upload_part(request)
|
||||
.await
|
||||
.expect_err("unknown or negative logical size");
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
&if capped || content_length.is_some() {
|
||||
S3ErrorCode::UnexpectedContent
|
||||
} else {
|
||||
S3ErrorCode::MissingContentLength
|
||||
}
|
||||
);
|
||||
assert_eq!(polls.load(std::sync::atomic::Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
let parts = store
|
||||
.list_object_parts(&bucket, "object", &upload.upload_id, None, 1000, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("list rejected session");
|
||||
assert!(parts.parts.is_empty());
|
||||
}
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn execute_upload_part_progress_timeout_releases_admission_and_leaves_no_part() {
|
||||
use crate::app::object::request_body::{BodyReadControl, ObservedBody};
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use http_body::Frame;
|
||||
use http_body_util::StreamBody;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||
let ambient = crate::app::runtime_sources::current_app_context().expect("test app context");
|
||||
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
||||
let bucket = format!("part-progress-{}", Uuid::new_v4().simple());
|
||||
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
|
||||
let manager = Arc::new(ConcurrencyManager::with_put_admission_for_test(true, 1, Duration::ZERO));
|
||||
let usecase = DefaultMultipartUsecase::with_context_and_concurrency_manager(Some(context), Arc::clone(&manager));
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "object", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<Result<Frame<Bytes>, std::io::Error>>();
|
||||
let control = BodyReadControl::default();
|
||||
let body = StreamingBlob::from(crate::app::storage_api::s3::Body::http_body_unsync(ObservedBody::new(
|
||||
StreamBody::new(UnboundedReceiverStream::new(receiver)),
|
||||
control.clone(),
|
||||
)));
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("object".to_owned())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.part_number(1)
|
||||
.content_length(Some(65536))
|
||||
.body(Some(body))
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut request = build_request(input, Method::PUT);
|
||||
request.extensions.insert(control.clone());
|
||||
let owner = tokio::spawn(async move { usecase.execute_upload_part(request).await });
|
||||
control.wait_until_pending_for_test().await;
|
||||
assert!(matches!(
|
||||
manager.admit_put_object(65536).await.unwrap(),
|
||||
ForegroundWriteAdmission::Rejected
|
||||
));
|
||||
control.advance_wait_for_test(Duration::from_secs(60));
|
||||
sender.send(Ok(Frame::data(Bytes::from_static(b"x")))).unwrap();
|
||||
assert_eq!(owner.await.unwrap().unwrap_err().code(), &S3ErrorCode::RequestTimeout);
|
||||
assert!(matches!(
|
||||
manager.admit_put_object(65536).await.unwrap(),
|
||||
ForegroundWriteAdmission::Admitted(_)
|
||||
));
|
||||
let parts = store
|
||||
.list_object_parts(&bucket, "object", &upload.upload_id, None, 1000, &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(parts.parts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn execute_upload_part_rejects_when_foreground_write_admission_is_full() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations, MakeBucketOptions};
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
||||
let bucket = format!("upload-part-admission-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create upload part admission test bucket");
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "object", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create multipart upload");
|
||||
let concurrency_manager = Arc::new(ConcurrencyManager::with_large_put_admission_for_test(
|
||||
true,
|
||||
1,
|
||||
rustfs_config::DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES,
|
||||
Duration::ZERO,
|
||||
));
|
||||
let held = concurrency_manager
|
||||
.admit_multipart_part(1024)
|
||||
.await
|
||||
.expect("first multipart part should acquire the only permit");
|
||||
let usecase =
|
||||
DefaultMultipartUsecase::with_context_and_concurrency_manager(Some(context), Arc::clone(&concurrency_manager));
|
||||
let body = StreamingBlob::wrap(futures::stream::pending::<Result<Bytes, std::io::Error>>());
|
||||
let input = UploadPartInput::builder()
|
||||
.bucket(bucket)
|
||||
.key("object".to_string())
|
||||
.upload_id(upload.upload_id)
|
||||
.part_number(1)
|
||||
.content_length(Some(1024))
|
||||
.body(Some(body))
|
||||
.build()
|
||||
.expect("upload part input should build");
|
||||
|
||||
let err = usecase
|
||||
.execute_upload_part(build_request(input, Method::PUT))
|
||||
.await
|
||||
.expect_err("full foreground write admission should reject before body ingest");
|
||||
assert_eq!(err.code(), &S3ErrorCode::SlowDown);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
mod body_read_tests;
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! UploadPartCopy.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn normalize_copy_source_version_id(version_id: Option<&str>) -> S3Result<Option<String>> {
|
||||
match version_id {
|
||||
Some(v) => {
|
||||
let trimmed = v.trim();
|
||||
if trimmed.eq_ignore_ascii_case("null") {
|
||||
Ok(Some(Uuid::nil().to_string()))
|
||||
} else if Uuid::parse_str(trimmed).is_ok() {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
} else {
|
||||
Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"Invalid version id specified in copy source",
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_copy_source_version_id(version_id: Option<Uuid>, src_versioned: bool, src_version_suspended: bool) -> Option<String> {
|
||||
if src_versioned || src_version_suspended {
|
||||
Some(match version_id {
|
||||
Some(vid) if vid != Uuid::nil() => vid.to_string(),
|
||||
_ => "null".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns InvalidRange error if CopySourceRange end exceeds the source object size.
|
||||
/// Used by execute_upload_part_copy to reject out-of-bounds ranges per S3 spec.
|
||||
fn validate_copy_source_range_not_exceeds(range_spec: &HTTPRangeSpec, object_size: i64) -> S3Result<()> {
|
||||
if range_spec.end >= object_size {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRange,
|
||||
"The requested range is not satisfiable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DefaultMultipartUsecase {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
#[hotpath::measure(impl_type = "MultipartUsecase")]
|
||||
pub async fn execute_upload_part_copy(
|
||||
&self,
|
||||
req: S3Request<UploadPartCopyInput>,
|
||||
) -> S3Result<S3Response<UploadPartCopyOutput>> {
|
||||
reject_presigned_multipart_max_total_object_size_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedSigV4Request>().is_some(),
|
||||
)?;
|
||||
reject_presigned_put_max_content_length_for_other_operation(
|
||||
&req.headers,
|
||||
req.uri.query(),
|
||||
req.extensions.get::<VerifiedPresignedRequest>().is_some(),
|
||||
)?;
|
||||
// Captured before `req.input` is destructured below.
|
||||
let copy_principal = SseKmsPrincipal::from_request(&req);
|
||||
let source_bucket = match &req.input.copy_source {
|
||||
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
|
||||
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
|
||||
CopySource::Bucket { bucket, .. } => bucket.to_string(),
|
||||
};
|
||||
let mut source_generation_opts = ObjectOptions::default();
|
||||
apply_copy_source_bucket_generation_guard(&req, &source_bucket, &mut source_generation_opts)?;
|
||||
let expected_source_incarnation_id = source_generation_opts.expected_bucket_incarnation_id;
|
||||
let mut destination_generation_opts = ObjectOptions::default();
|
||||
apply_bucket_generation_guard(&req, &req.input.bucket, &mut destination_generation_opts)?;
|
||||
let expected_destination_incarnation_id = destination_generation_opts.expected_bucket_incarnation_id;
|
||||
let UploadPartCopyInput {
|
||||
bucket,
|
||||
key,
|
||||
copy_source,
|
||||
copy_source_range,
|
||||
part_number,
|
||||
upload_id,
|
||||
copy_source_if_match,
|
||||
copy_source_if_none_match,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5,
|
||||
copy_source_sse_customer_algorithm,
|
||||
copy_source_sse_customer_key,
|
||||
copy_source_sse_customer_key_md5,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
let (src_bucket, src_key, raw_src_version_id) = match copy_source {
|
||||
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
|
||||
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
|
||||
CopySource::Bucket {
|
||||
bucket: ref src_bucket,
|
||||
key: ref src_key,
|
||||
version_id,
|
||||
} => (src_bucket.to_string(), src_key.to_string(), version_id.map(|v| v.to_string())),
|
||||
};
|
||||
let src_version_id = normalize_copy_source_version_id(raw_src_version_id.as_deref())?;
|
||||
|
||||
let rs = if let Some(range_str) = copy_source_range {
|
||||
Some(parse_copy_source_range(&range_str)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let part_id = parse_upload_part_number(part_number)?;
|
||||
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let (
|
||||
source_bucket_lifecycle_guard,
|
||||
current_source_incarnation_id,
|
||||
destination_bucket_lifecycle_guard_storage,
|
||||
current_destination_incarnation_id,
|
||||
) = store
|
||||
.acquire_copy_bucket_incarnation_fences(
|
||||
&src_bucket,
|
||||
expected_source_incarnation_id,
|
||||
&bucket,
|
||||
expected_destination_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let destination_bucket_lifecycle_guard = destination_bucket_lifecycle_guard_storage
|
||||
.as_ref()
|
||||
.unwrap_or(&source_bucket_lifecycle_guard);
|
||||
if source_bucket_lifecycle_guard.is_lock_lost() || destination_bucket_lifecycle_guard.is_lock_lost() {
|
||||
return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "copy_bucket_generation",
|
||||
bucket: bucket.clone(),
|
||||
object: key.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
})
|
||||
.into());
|
||||
}
|
||||
let mut dst_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(current_destination_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
if src_bucket != bucket {
|
||||
source_bucket_lifecycle_guard.attach_to_object_options(&mut dst_opts);
|
||||
}
|
||||
destination_bucket_lifecycle_guard.attach_to_object_options(&mut dst_opts);
|
||||
|
||||
let mp_info = store
|
||||
.get_multipart_info(&bucket, &key, &upload_id, &dst_opts)
|
||||
.await
|
||||
.map_err(multipart_upload_api_error)?;
|
||||
authorize_sse_kms_multipart_write(copy_principal.as_ref(), &mp_info.user_defined).await?;
|
||||
let destination_size_limit = multipart_max_total_object_size(&mp_info.user_defined)?;
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: None,
|
||||
ssekms_key_id: None,
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: sse_customer_algorithm.clone(),
|
||||
sse_customer_key: sse_customer_key.clone(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.clone(),
|
||||
content_size: 0,
|
||||
principal: None,
|
||||
}
|
||||
.validate_multipart_ssec(&mp_info.user_defined)?;
|
||||
|
||||
let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await;
|
||||
let src_version_suspended = BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await;
|
||||
let mut src_opts = copy_src_opts(&src_bucket, &src_key, &req.headers).map_err(ApiError::from)?;
|
||||
src_opts.version_id = src_version_id.clone();
|
||||
src_opts.versioned = src_versioned;
|
||||
src_opts.version_suspended = src_version_suspended;
|
||||
|
||||
let h = build_ssec_read_headers(
|
||||
copy_source_sse_customer_algorithm.as_ref(),
|
||||
copy_source_sse_customer_key.as_ref(),
|
||||
copy_source_sse_customer_key_md5.as_ref(),
|
||||
);
|
||||
let get_opts = ObjectOptions {
|
||||
version_id: src_opts.version_id.clone(),
|
||||
versioned: src_opts.versioned,
|
||||
version_suspended: src_opts.version_suspended,
|
||||
expected_bucket_incarnation_id: Some(current_source_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
if source_bucket_lifecycle_guard.is_lock_lost() {
|
||||
return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "copy_source_bucket_generation",
|
||||
bucket: src_bucket.clone(),
|
||||
object: src_key.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
})
|
||||
.into());
|
||||
}
|
||||
|
||||
let prepared_source = store
|
||||
.prepare_get_object_reader(&src_bucket, &src_key, rs.clone(), h, &get_opts)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
|
||||
let src_info = prepared_source.object_info();
|
||||
if src_info.delete_marker {
|
||||
let error = if src_opts.version_id.is_none() {
|
||||
StorageError::ObjectNotFound(src_bucket.clone(), src_key.clone())
|
||||
} else {
|
||||
StorageError::MethodNotAllowed
|
||||
};
|
||||
return Err(map_get_object_reader_error(error).into());
|
||||
}
|
||||
|
||||
// Same shape as CopyObject: the part copy reads the source plaintext, and the source
|
||||
// read resolves its material inside the object layer, which carries no request identity.
|
||||
authorize_sse_kms_object_read(copy_principal.as_ref(), &src_info.user_defined).await?;
|
||||
|
||||
let resolved_src_version_id = format_copy_source_version_id(src_info.version_id, src_versioned, src_version_suspended);
|
||||
|
||||
if let Some(if_match) = copy_source_if_match {
|
||||
if let Some(ref etag) = src_info.etag {
|
||||
if let Some(strong_etag) = if_match.into_etag() {
|
||||
if ETag::Strong(etag.clone()) != strong_etag {
|
||||
return Err(s3_error!(PreconditionFailed));
|
||||
}
|
||||
} else {
|
||||
return Err(s3_error!(PreconditionFailed));
|
||||
}
|
||||
} else {
|
||||
return Err(s3_error!(PreconditionFailed));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(if_none_match) = copy_source_if_none_match
|
||||
&& let Some(ref etag) = src_info.etag
|
||||
&& let Some(strong_etag) = if_none_match.into_etag()
|
||||
&& ETag::Strong(etag.clone()) == strong_etag
|
||||
{
|
||||
return Err(s3_error!(PreconditionFailed));
|
||||
}
|
||||
|
||||
let source_actual_size = src_info.get_actual_size().ok().filter(|size| *size >= 0);
|
||||
let source_logical_size = match source_actual_size {
|
||||
Some(size) => size,
|
||||
None if destination_size_limit.is_some() => {
|
||||
return Err(S3Error::new(S3ErrorCode::UnexpectedContent));
|
||||
}
|
||||
None => src_info.size,
|
||||
};
|
||||
|
||||
let (_start_offset, length) = if let Some(ref range_spec) = rs {
|
||||
// Copy-source ranges are expressed over the logical plaintext object.
|
||||
// Encrypted (and compressed) objects have a larger or smaller physical
|
||||
// representation, so validating against `size` rejects valid later parts.
|
||||
validate_copy_source_range_not_exceeds(range_spec, source_logical_size)?;
|
||||
|
||||
range_spec
|
||||
.get_offset_length(source_logical_size)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRange, e.to_string()))?
|
||||
} else {
|
||||
(0, source_logical_size)
|
||||
};
|
||||
|
||||
// Resolve the range under the prepared source's read lock, then try the
|
||||
// shared budget without waiting while holding namespace/lifecycle locks.
|
||||
// Keep both the budget and source cancellation alive through the write.
|
||||
let admission_length = source_actual_size.map_or(-1, |_| length);
|
||||
let _copy_admission = match self.concurrency_manager().try_admit_multipart_part_copy(admission_length) {
|
||||
ForegroundWriteAdmission::Disabled => None,
|
||||
ForegroundWriteAdmission::Admitted(permit) => Some(permit),
|
||||
ForegroundWriteAdmission::Rejected => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::SlowDown,
|
||||
"foreground write concurrency limit reached, please reduce your request rate",
|
||||
));
|
||||
}
|
||||
};
|
||||
let (src_reader, _source_cancellation) = prepared_source
|
||||
.into_reader_for_copy()
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
let src_stream = src_reader.stream;
|
||||
|
||||
let is_disk_compressed =
|
||||
rustfs_utils::http::contains_key_str(&mp_info.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION);
|
||||
|
||||
let actual_size = length;
|
||||
|
||||
let mut write_plan = WritePlan::new();
|
||||
let mut reader = if is_disk_compressed {
|
||||
let algorithm = CompressionAlgorithm::default();
|
||||
let hrd = HashReader::from_stream(src_stream, length, actual_size, None, None, false).map_err(ApiError::from)?;
|
||||
write_plan = write_plan.with_compression(algorithm);
|
||||
hrd
|
||||
} else {
|
||||
HashReader::from_stream(src_stream, length, actual_size, None, None, false).map_err(ApiError::from)?
|
||||
};
|
||||
|
||||
let server_side_encryption = mp_info
|
||||
.user_defined
|
||||
.get("x-amz-server-side-encryption")
|
||||
.map(|s| {
|
||||
ServerSideEncryption::from_str(s)
|
||||
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
|
||||
})
|
||||
.transpose()?;
|
||||
let has_ssec = mp_info
|
||||
.user_defined
|
||||
.contains_key("x-amz-server-side-encryption-customer-algorithm");
|
||||
let ssekms_key_id = match server_side_encryption.as_ref() {
|
||||
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => mp_info
|
||||
.user_defined
|
||||
.get("x-amz-server-side-encryption-aws-kms-key-id")
|
||||
.map(|s| s.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let (requested_sse, requested_kms_key_id, dst_user_defined) = if has_ssec {
|
||||
let ssec_material = sse_decryption(DecryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
metadata: &mp_info.user_defined,
|
||||
sse_customer_key: sse_customer_key.as_ref(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.as_ref(),
|
||||
principal: None,
|
||||
})
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::from(StorageError::other("Missing SSE-C session material")))?;
|
||||
let ssec_write = match ssec_material.key_kind {
|
||||
EncryptionKeyKind::Object => WriteEncryption::multipart_object_key(ssec_material.key_bytes, part_id as u32),
|
||||
EncryptionKeyKind::Direct => {
|
||||
WriteEncryption::multipart(ssec_material.key_bytes, ssec_material.base_nonce, part_id)
|
||||
}
|
||||
};
|
||||
write_plan = write_plan.with_encryption(ssec_write);
|
||||
(
|
||||
Some(ssec_material.server_side_encryption),
|
||||
ssec_material.kms_key_id,
|
||||
mp_info.user_defined.clone(),
|
||||
)
|
||||
} else if let Some(server_side_encryption) = server_side_encryption {
|
||||
// Reuse the destination envelope after authorizing this caller against
|
||||
// its pinned key above. The source read is separately authorized.
|
||||
let managed_material = sse_decryption(DecryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
metadata: &mp_info.user_defined,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
principal: None,
|
||||
})
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::from(StorageError::other("Missing managed SSE session material")))?;
|
||||
let managed_write = match managed_material.key_kind {
|
||||
EncryptionKeyKind::Object => WriteEncryption::multipart_object_key(managed_material.key_bytes, part_id as u32),
|
||||
EncryptionKeyKind::Direct => {
|
||||
WriteEncryption::multipart(managed_material.key_bytes, managed_material.base_nonce, part_id)
|
||||
}
|
||||
};
|
||||
write_plan = write_plan.with_encryption(managed_write);
|
||||
(Some(server_side_encryption), ssekms_key_id, mp_info.user_defined.clone())
|
||||
} else {
|
||||
(None, None, mp_info.user_defined.clone())
|
||||
};
|
||||
|
||||
reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?;
|
||||
|
||||
if let Some(checksum_algorithm) = mp_info
|
||||
.user_defined
|
||||
.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM)
|
||||
.filter(|checksum_algorithm| !checksum_algorithm.is_empty())
|
||||
{
|
||||
let checksum_type = rustfs_rio::ChecksumType::from_string_with_obj_type(
|
||||
checksum_algorithm,
|
||||
mp_info
|
||||
.user_defined
|
||||
.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE)
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if !checksum_type.is_set() {
|
||||
return Err(ApiError::from(StorageError::other(format!(
|
||||
"Invalid multipart checksum type: {checksum_algorithm}"
|
||||
)))
|
||||
.into());
|
||||
}
|
||||
reader.add_calculated_checksum(checksum_type).map_err(ApiError::from)?;
|
||||
}
|
||||
|
||||
let mut reader = PutObjReader::new(reader);
|
||||
|
||||
dst_opts.user_defined = dst_user_defined;
|
||||
|
||||
let part_info = store
|
||||
.put_object_part(&bucket, &key, &upload_id, part_id, &mut reader, &dst_opts)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let copy_checksums = reader.as_hash_reader().content_crc();
|
||||
let checksum_value = |checksum_type: rustfs_rio::ChecksumType| copy_checksums.get(&checksum_type.to_string()).cloned();
|
||||
|
||||
let copy_part_result = CopyPartResult {
|
||||
checksum_crc32: checksum_value(rustfs_rio::ChecksumType::CRC32),
|
||||
checksum_crc32c: checksum_value(rustfs_rio::ChecksumType::CRC32C),
|
||||
checksum_sha1: checksum_value(rustfs_rio::ChecksumType::SHA1),
|
||||
checksum_sha256: checksum_value(rustfs_rio::ChecksumType::SHA256),
|
||||
checksum_crc64nvme: checksum_value(rustfs_rio::ChecksumType::CRC64_NVME),
|
||||
e_tag: part_info.etag.map(|etag| to_s3s_xml_etag(&etag)),
|
||||
last_modified: part_info.last_mod.map(Timestamp::from),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = UploadPartCopyOutput {
|
||||
copy_part_result: Some(copy_part_result),
|
||||
copy_source_version_id: resolved_src_version_id,
|
||||
server_side_encryption: requested_sse,
|
||||
ssekms_key_id: requested_kms_key_id,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key_md5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(S3Response::new(output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_upload_part_copy_returns_internal_error_when_store_uninitialized() {
|
||||
let input = UploadPartCopyInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: "src-bucket".into(),
|
||||
key: "src-object".into(),
|
||||
version_id: None,
|
||||
})
|
||||
.part_number(1)
|
||||
.upload_id("upload-id".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::PUT);
|
||||
|
||||
let err = Box::pin(make_usecase().execute_upload_part_copy(req)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_upload_part_copy_rejects_invalid_part_number_before_store_lookup() {
|
||||
for part_number in [-1, 0, 10001] {
|
||||
let input = UploadPartCopyInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
.key("object".to_string())
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: "src-bucket".into(),
|
||||
key: "src-object".into(),
|
||||
version_id: None,
|
||||
})
|
||||
.part_number(part_number)
|
||||
.upload_id("upload-id".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let req = build_request(input, Method::PUT);
|
||||
|
||||
let err = Box::pin(make_usecase().execute_upload_part_copy(req)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(err.message(), Some("partNumber must be between 1 and 10000"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_copy_source_range_not_exceeds_returns_invalid_range_when_range_exceeds() {
|
||||
use super::validate_copy_source_range_not_exceeds;
|
||||
|
||||
let range_exceeds = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 0,
|
||||
end: 21,
|
||||
};
|
||||
let err = validate_copy_source_range_not_exceeds(&range_exceeds, 5).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRange);
|
||||
assert!(err.to_string().contains("not satisfiable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_copy_source_range_not_exceeds_ok_when_range_valid() {
|
||||
use super::validate_copy_source_range_not_exceeds;
|
||||
|
||||
let range_valid = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 0,
|
||||
end: 4,
|
||||
};
|
||||
assert!(validate_copy_source_range_not_exceeds(&range_valid, 5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_copy_source_range_not_exceeds_ok_for_suffix_range() {
|
||||
use super::validate_copy_source_range_not_exceeds;
|
||||
|
||||
let range_suffix = HTTPRangeSpec {
|
||||
is_suffix_length: true,
|
||||
start: -5,
|
||||
end: -1,
|
||||
};
|
||||
assert!(validate_copy_source_range_not_exceeds(&range_suffix, 5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_part_copy_normalizes_and_formats_source_version_id() {
|
||||
assert_eq!(normalize_copy_source_version_id(None).expect("missing version id"), None);
|
||||
assert_eq!(
|
||||
normalize_copy_source_version_id(Some(" null ")).expect("null version id"),
|
||||
Some(Uuid::nil().to_string())
|
||||
);
|
||||
let valid = Uuid::new_v4();
|
||||
assert_eq!(
|
||||
normalize_copy_source_version_id(Some(&format!(" {valid} "))).expect("valid uuid"),
|
||||
Some(valid.to_string())
|
||||
);
|
||||
let err = normalize_copy_source_version_id(Some("not-a-uuid")).expect_err("invalid uuid should fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
|
||||
assert_eq!(format_copy_source_version_id(Some(Uuid::nil()), false, false), None);
|
||||
assert_eq!(format_copy_source_version_id(None, true, false), Some("null".to_string()));
|
||||
assert_eq!(format_copy_source_version_id(Some(Uuid::nil()), false, true), Some("null".to_string()));
|
||||
assert_eq!(format_copy_source_version_id(Some(valid), true, false), Some(valid.to_string()));
|
||||
}
|
||||
|
||||
mod copy_admission_tests;
|
||||
}
|
||||
@@ -58,7 +58,6 @@ pub(crate) mod s3 {
|
||||
pub(crate) use s3s::auth::{Credentials, SimpleAuth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::config::{S3Config, StaticConfigProvider};
|
||||
pub(crate) use s3s::dto;
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{
|
||||
BucketLocationConstraint, CreateBucketConfiguration, CreateBucketInput, CreateMultipartUploadInput, DeleteObjectInput,
|
||||
@@ -75,8 +74,6 @@ pub(crate) mod s3 {
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::stream::upload_stream::{UploadStream, UploadStreamError};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::xml;
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::xml::{Serialize as XmlSerialize, Serializer as XmlSerializer};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::{Body, BodySizeLimitExceeded, S3, S3Response};
|
||||
|
||||
+1752
-81
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@ mod tests {
|
||||
remove_object_lock_retention_metadata, set_bucket_metadata, validate_bucket_object_lock_enabled,
|
||||
validate_list_object_unordered_with_delimiter,
|
||||
};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use http::{Extensions, HeaderMap, HeaderValue, Method, StatusCode, Uri};
|
||||
use rustfs_config::MI_B;
|
||||
use rustfs_utils::http::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
@@ -62,14 +62,108 @@ mod tests {
|
||||
};
|
||||
use rustfs_zip::CompressionFormat;
|
||||
use s3s::dto::{
|
||||
DefaultRetention, Delimiter, FilterRule, FilterRuleName, LambdaFunctionConfiguration, NotificationConfigurationFilter,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
|
||||
ObjectLockRetentionMode, ObjectLockRule, QueueConfiguration, S3KeyFilter, TopicConfiguration,
|
||||
DefaultRetention, DeleteObjectTaggingInput, Delimiter, FilterRule, FilterRuleName, GetBucketAclInput, GetObjectAclInput,
|
||||
GetObjectLegalHoldInput, GetObjectRetentionInput, GetObjectTaggingInput, LambdaFunctionConfiguration,
|
||||
NotificationConfigurationFilter, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold,
|
||||
ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode, ObjectLockRule, PutBucketAclInput,
|
||||
PutObjectAclInput, PutObjectLegalHoldInput, PutObjectLockConfigurationInput, PutObjectRetentionInput,
|
||||
PutObjectTaggingInput, QueueConfiguration, S3KeyFilter, Tag, Tagging, TopicConfiguration,
|
||||
};
|
||||
use s3s::{S3Error, S3ErrorCode, s3_error};
|
||||
use s3s::{S3, S3Error, S3ErrorCode, S3Request, s3_error};
|
||||
use std::collections::HashMap;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn list_apis_report_storage_read_quorum_as_unavailable() {
|
||||
crate::app::gating_test_env::run_large_stack_test("list-quorum-surface", || async {
|
||||
use crate::storage::storage_api::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use s3s::dto::{ListObjectVersionsInput, ListObjectsInput, ListObjectsV2Input};
|
||||
crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("list-quorum-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create listing bucket");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let saved_disks = set.disks.read().await.clone();
|
||||
{
|
||||
let mut disks = set.disks.write().await;
|
||||
for disk in disks.iter_mut().skip(1) {
|
||||
*disk = None;
|
||||
}
|
||||
}
|
||||
let fs = FS::new();
|
||||
let errors = [
|
||||
S3::list_objects(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectsInput {
|
||||
bucket: bucket.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("V1 cannot list below storage read quorum"),
|
||||
S3::list_objects_v2(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectsV2Input {
|
||||
bucket: bucket.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("V2 cannot list below storage read quorum"),
|
||||
S3::list_object_versions(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectVersionsInput {
|
||||
bucket,
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("versions cannot list below storage read quorum"),
|
||||
];
|
||||
*set.disks.write().await = saved_disks;
|
||||
for error in errors {
|
||||
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
|
||||
assert_eq!(error.message(), Some("The service is unavailable. Please retry."));
|
||||
let source = error.source().expect("retain storage diagnostics");
|
||||
assert!(
|
||||
matches!(
|
||||
source.downcast_ref::<StorageError>(),
|
||||
Some(StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _))
|
||||
),
|
||||
"the production LIST boundary must expose a typed read-quorum failure: {source}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_creation() {
|
||||
let _fs = FS::new();
|
||||
@@ -199,6 +293,164 @@ mod tests {
|
||||
assert_eq!(gz_format.extension(), "gz");
|
||||
}
|
||||
|
||||
/// Premise guard for the `*_returns_internal_error_when_store_uninitialized`
|
||||
/// tests (backlog#1830): they assert the error path taken while the global
|
||||
/// object layer is absent. Under nextest — the authoritative runner — every
|
||||
/// test owns its process, so the premise always holds and the assertion
|
||||
/// always runs. Under the documented shared-process `cargo test` fallback a
|
||||
/// sibling test may have initialized the store first; the premise is then
|
||||
/// unattainable, so the test skips instead of asserting against a scenario
|
||||
/// it does not describe.
|
||||
fn store_uninitialized_premise_holds() -> bool {
|
||||
if crate::runtime_sources::current_object_store_handle().is_some() {
|
||||
eprintln!("skipping store-uninitialized assertion: a sibling test already initialized the global object layer");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_acl(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_bucket_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetBucketAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_bucket_acl(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_legal_hold_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectLegalHoldInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_legal_hold(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_retention_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetObjectRetentionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_retention(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_legal_hold_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectLegalHoldInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_legal_hold(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_bucket_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutBucketAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_bucket_acl(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_acl(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_retention_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutObjectRetentionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_retention(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_lock_configuration_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectLockConfigurationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.object_lock_configuration(Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs
|
||||
.put_object_lock_configuration(build_request(input, Method::PUT))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
/// The bucket default retention path reads the persisted shape; the request
|
||||
/// literals in these tests stay on the wire DTO and convert once here.
|
||||
fn persisted_lock(cfg: &ObjectLockConfiguration) -> rustfs_gateway_types::persistence::PersistedObjectLockConfiguration {
|
||||
@@ -525,6 +777,104 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_tagging(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_rejects_too_many_tags() {
|
||||
let tag_set = (0..11)
|
||||
.map(|index| Tag {
|
||||
key: Some(format!("k{index}")),
|
||||
value: Some(format!("v{index}")),
|
||||
})
|
||||
.collect();
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging { tag_set })
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
|
||||
assert!(err.to_string().contains("Cannot have more than 10 tags per object"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_rejects_empty_tag_key_before_store_lookup() {
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging {
|
||||
tag_set: vec![Tag {
|
||||
key: Some(String::new()),
|
||||
value: Some("v1".to_string()),
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
|
||||
assert!(err.to_string().contains("Tag key cannot be empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging {
|
||||
tag_set: vec![Tag {
|
||||
key: Some("k".to_string()),
|
||||
value: Some("v".to_string()),
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = DeleteObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs
|
||||
.delete_object_tagging(build_request(input, Method::DELETE))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_buffer_size_with_profile() {
|
||||
const KB: i64 = 1024;
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of bucket policy, policy status, versioning, notification and ACL: batch A2 of
|
||||
//! the gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api::{self, acl};
|
||||
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
|
||||
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
|
||||
impl FS {
|
||||
async fn put_bucket_acl_inner(&self, req: S3Request<PutBucketAclInput>) -> S3Result<S3Response<PutBucketAclOutput>> {
|
||||
let PutBucketAclInput {
|
||||
bucket,
|
||||
access_control_policy,
|
||||
..
|
||||
} = req.input;
|
||||
record_s3_op(S3Operation::PutBucketAcl);
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
if access_control_policy.is_some() {
|
||||
return Err(s3_error!(
|
||||
NotImplemented,
|
||||
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(S3Response::new(PutBucketAclOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FS {
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_policy_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketPolicyOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeleteBucketPolicy, |req| usecase.execute_delete_bucket_policy(req)).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_acl_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketAclInput>,
|
||||
) -> S3Result<S3Response<GetBucketAclOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketAcl);
|
||||
let GetBucketAclInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(acl::build_get_bucket_acl_output()))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_notification_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketNotificationConfigurationInput>,
|
||||
) -> S3Result<S3Response<GetBucketNotificationConfigurationOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketNotificationConfiguration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let notification_configuration = usecase
|
||||
.execute_get_bucket_notification_configuration(req)
|
||||
.await?
|
||||
.as_ref()
|
||||
.map(persisted_to_s3s::<_, NotificationConfiguration>)
|
||||
.transpose()?;
|
||||
match notification_configuration {
|
||||
Some(NotificationConfiguration {
|
||||
event_bridge_configuration,
|
||||
lambda_function_configurations,
|
||||
queue_configurations,
|
||||
topic_configurations,
|
||||
}) => Ok(S3Response::new(GetBucketNotificationConfigurationOutput {
|
||||
event_bridge_configuration,
|
||||
lambda_function_configurations,
|
||||
queue_configurations,
|
||||
topic_configurations,
|
||||
})),
|
||||
None => Ok(S3Response::new(GetBucketNotificationConfigurationOutput::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_policy_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<GetBucketPolicyOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketPolicy);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_get_bucket_policy(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_policy_status_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketPolicyStatusInput>,
|
||||
) -> S3Result<S3Response<GetBucketPolicyStatusOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketPolicyStatus);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_get_bucket_policy_status(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "get_bucket_versioning", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_versioning_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketVersioningInput>,
|
||||
) -> S3Result<S3Response<GetBucketVersioningOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketVersioning);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let config = usecase.execute_get_bucket_versioning(req).await?;
|
||||
Ok(S3Response::new(GetBucketVersioningOutput {
|
||||
status: config.status.map(BucketVersioningStatus::from),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_acl_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketAclInput>,
|
||||
) -> S3Result<S3Response<PutBucketAclOutput>> {
|
||||
audit_only_operation(req, S3Operation::PutBucketAcl, |req| self.put_bucket_acl_inner(req)).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_notification_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketNotificationConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutBucketNotificationConfigurationOutput>> {
|
||||
let prepared = persisted_from_s3s(&req.input.notification_configuration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketNotificationConfiguration, |req| {
|
||||
usecase.execute_put_bucket_notification_configuration(req, prepared)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_policy_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketPolicyInput>,
|
||||
) -> S3Result<S3Response<PutBucketPolicyOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketPolicy, |req| usecase.execute_put_bucket_policy(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "put_bucket_versioning", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_versioning_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketVersioningInput>,
|
||||
) -> S3Result<S3Response<PutBucketVersioningOutput>> {
|
||||
let prepared = persisted_from_s3s(&req.input.versioning_configuration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketVersioning, |req| {
|
||||
usecase.execute_put_bucket_versioning(req, prepared)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of the bucket CORS, tagging, public access block and encryption configurations:
|
||||
//! batch A1 of the gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use crate::storage::{persisted_from_s3s, persisted_to_s3s, tagging_from_s3s, tagging_to_s3s};
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "delete_bucket_cors", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_cors_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketCorsInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeleteBucketCors, |req| usecase.execute_delete_bucket_cors(req)).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_encryption_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketEncryptionInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketEncryptionOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeleteBucketEncryption, |req| {
|
||||
usecase.execute_delete_bucket_encryption(req)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(name = "delete_bucket_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketTaggingInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketTaggingOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeleteBucketTagging, |req| usecase.execute_delete_bucket_tagging(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "delete_public_access_block", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_public_access_block_edge(
|
||||
&self,
|
||||
req: S3Request<DeletePublicAccessBlockInput>,
|
||||
) -> S3Result<S3Response<DeletePublicAccessBlockOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeletePublicAccessBlock, |req| {
|
||||
usecase.execute_delete_public_access_block(req)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(name = "get_bucket_cors", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_cors_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketCorsInput>,
|
||||
) -> S3Result<S3Response<GetBucketCorsOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketCors);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let cors_configuration = usecase.execute_get_bucket_cors(req).await?;
|
||||
let cors_configuration: CORSConfiguration = persisted_to_s3s(&cors_configuration)?;
|
||||
Ok(S3Response::new(GetBucketCorsOutput {
|
||||
cors_rules: Some(cors_configuration.cors_rules),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_encryption_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketEncryptionInput>,
|
||||
) -> S3Result<S3Response<GetBucketEncryptionOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketEncryption);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let server_side_encryption_configuration = usecase
|
||||
.execute_get_bucket_encryption(req)
|
||||
.await?
|
||||
.as_ref()
|
||||
.map(persisted_to_s3s::<_, ServerSideEncryptionConfiguration>)
|
||||
.transpose()?;
|
||||
Ok(S3Response::new(GetBucketEncryptionOutput {
|
||||
server_side_encryption_configuration,
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "get_bucket_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketTaggingInput>,
|
||||
) -> S3Result<S3Response<GetBucketTaggingOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketTagging);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let tagging = usecase.execute_get_bucket_tagging(req).await?;
|
||||
Ok(S3Response::new(GetBucketTaggingOutput {
|
||||
tag_set: tagging_to_s3s(&tagging).tag_set,
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "get_public_access_block", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_public_access_block_edge(
|
||||
&self,
|
||||
req: S3Request<GetPublicAccessBlockInput>,
|
||||
) -> S3Result<S3Response<GetPublicAccessBlockOutput>> {
|
||||
record_s3_op(S3Operation::GetPublicAccessBlock);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let config = usecase.execute_get_public_access_block(req).await?;
|
||||
Ok(S3Response::new(GetPublicAccessBlockOutput {
|
||||
public_access_block_configuration: Some(persisted_to_s3s(&config)?),
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "put_bucket_cors", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_cors_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketCorsInput>,
|
||||
) -> S3Result<S3Response<PutBucketCorsOutput>> {
|
||||
let prepared = persisted_from_s3s(&req.input.cors_configuration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketCors, |req| usecase.execute_put_bucket_cors(req, prepared)).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_encryption_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketEncryptionInput>,
|
||||
) -> S3Result<S3Response<PutBucketEncryptionOutput>> {
|
||||
let prepared = persisted_from_s3s(&req.input.server_side_encryption_configuration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketEncryption, |req| {
|
||||
usecase.execute_put_bucket_encryption(req, prepared)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(name = "put_public_access_block", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_public_access_block_edge(
|
||||
&self,
|
||||
req: S3Request<PutPublicAccessBlockInput>,
|
||||
) -> S3Result<S3Response<PutPublicAccessBlockOutput>> {
|
||||
let prepared = persisted_from_s3s(&req.input.public_access_block_configuration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutPublicAccessBlock, |req| {
|
||||
usecase.execute_put_public_access_block(req, prepared)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(name = "put_bucket_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketTaggingInput>,
|
||||
) -> S3Result<S3Response<PutBucketTaggingOutput>> {
|
||||
let tagging = tagging_from_s3s(&req.input.tagging);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::PutBucketTagging, |req| usecase.execute_put_bucket_tagging(req, tagging)).await
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of bucket create, delete, head and location, and the website, accelerate,
|
||||
//! logging and request-payment configurations: batch A4 of the gateway migration
|
||||
//! (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::access::bucket_config_mutation_incarnation;
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
|
||||
use crate::storage::storage_api::ecstore_bucket::utils::serialize as serialize_bucket_config;
|
||||
use crate::storage::validate_website_configuration;
|
||||
use crate::storage::{
|
||||
BUCKET_ACCELERATE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG, BUCKET_WEBSITE_CONFIG, StorageError,
|
||||
delete_bucket_metadata_config_if_incarnation, get_bucket_accelerate_config, get_bucket_logging_config,
|
||||
get_bucket_request_payment_config, get_bucket_website_config, update_bucket_metadata_config_if_incarnation,
|
||||
};
|
||||
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
|
||||
impl FS {
|
||||
async fn put_bucket_logging_inner(
|
||||
&self,
|
||||
req: S3Request<PutBucketLoggingInput>,
|
||||
) -> S3Result<S3Response<PutBucketLoggingOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
record_s3_op(S3Operation::PutBucketLogging);
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
let bucket_logging_status = persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedBucketLoggingStatus>(
|
||||
&req.input.bucket_logging_status,
|
||||
)?;
|
||||
let logging_config = serialize_bucket_config(&bucket_logging_status)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
|
||||
update_bucket_metadata_config_if_incarnation(
|
||||
&req.input.bucket,
|
||||
BUCKET_LOGGING_CONFIG,
|
||||
logging_config,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(PutBucketLoggingOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FS {
|
||||
#[instrument(
|
||||
name = "create_bucket",
|
||||
level = "debug",
|
||||
skip(self, req),
|
||||
fields(start_time=?time::OffsetDateTime::now_utc())
|
||||
)]
|
||||
pub(in crate::storage::ecfs) async fn create_bucket_edge(
|
||||
&self,
|
||||
req: S3Request<CreateBucketInput>,
|
||||
) -> S3Result<S3Response<CreateBucketOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_create_bucket(req).await
|
||||
}
|
||||
|
||||
/// Delete a bucket
|
||||
#[instrument(name = "delete_bucket", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_delete_bucket(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_website_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketWebsiteInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketWebsiteOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
delete_bucket_metadata_config_if_incarnation(&req.input.bucket, BUCKET_WEBSITE_CONFIG, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(DeleteBucketWebsiteOutput::default()))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_accelerate_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketAccelerateConfigurationInput>,
|
||||
) -> S3Result<S3Response<GetBucketAccelerateConfigurationOutput>> {
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
match get_bucket_accelerate_config(&req.input.bucket).await {
|
||||
Ok((accelerate, _)) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput {
|
||||
status: accelerate.status.map(BucketAccelerateStatus::from),
|
||||
..Default::default()
|
||||
})),
|
||||
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput::default())),
|
||||
Err(err) => Err(crate::error::ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get bucket location
|
||||
#[instrument(name = "get_bucket_location", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_location_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketLocationInput>,
|
||||
) -> S3Result<S3Response<GetBucketLocationOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketLocation);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_get_bucket_location(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_request_payment_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketRequestPaymentInput>,
|
||||
) -> S3Result<S3Response<GetBucketRequestPaymentOutput>> {
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
match get_bucket_request_payment_config(&req.input.bucket).await {
|
||||
Ok((payment, _)) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
|
||||
payer: Some(Payer::from(payment.payer)),
|
||||
})),
|
||||
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
|
||||
payer: Some(Payer::from_static(Payer::BUCKET_OWNER)),
|
||||
})),
|
||||
Err(err) => Err(crate::error::ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_website_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketWebsiteInput>,
|
||||
) -> S3Result<S3Response<GetBucketWebsiteOutput>> {
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
match get_bucket_website_config(&req.input.bucket).await {
|
||||
Ok((website, _)) => {
|
||||
let website: WebsiteConfiguration = persisted_to_s3s(&website)?;
|
||||
Ok(S3Response::new(GetBucketWebsiteOutput {
|
||||
error_document: website.error_document,
|
||||
index_document: website.index_document,
|
||||
redirect_all_requests_to: website.redirect_all_requests_to,
|
||||
routing_rules: website.routing_rules,
|
||||
}))
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => Err(s3_error!(NoSuchWebsiteConfiguration)),
|
||||
Err(err) => Err(crate::error::ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(name = "head_bucket", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn head_bucket_edge(
|
||||
&self,
|
||||
req: S3Request<HeadBucketInput>,
|
||||
) -> S3Result<S3Response<HeadBucketOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_head_bucket(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_accelerate_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketAccelerateConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutBucketAccelerateConfigurationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
let accelerate_configuration = persisted_from_s3s::<
|
||||
_,
|
||||
rustfs_gateway_types::persistence::PersistedAccelerateConfiguration,
|
||||
>(&req.input.accelerate_configuration)?;
|
||||
let accelerate_config = serialize_bucket_config(&accelerate_configuration)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
|
||||
update_bucket_metadata_config_if_incarnation(
|
||||
&req.input.bucket,
|
||||
BUCKET_ACCELERATE_CONFIG,
|
||||
accelerate_config,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(PutBucketAccelerateConfigurationOutput::default()))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_logging_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketLoggingInput>,
|
||||
) -> S3Result<S3Response<GetBucketLoggingOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketLogging);
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
match get_bucket_logging_config(&req.input.bucket).await {
|
||||
Ok((logging, _)) => {
|
||||
let logging: BucketLoggingStatus = persisted_to_s3s(&logging)?;
|
||||
Ok(S3Response::new(GetBucketLoggingOutput {
|
||||
logging_enabled: logging.logging_enabled,
|
||||
}))
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketLoggingOutput::default())),
|
||||
Err(err) => Err(crate::error::ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_logging_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketLoggingInput>,
|
||||
) -> S3Result<S3Response<PutBucketLoggingOutput>> {
|
||||
audit_only_operation(req, S3Operation::PutBucketLogging, |req| self.put_bucket_logging_inner(req)).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_request_payment_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketRequestPaymentInput>,
|
||||
) -> S3Result<S3Response<PutBucketRequestPaymentOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
let request_payment_configuration = persisted_from_s3s::<
|
||||
_,
|
||||
rustfs_gateway_types::persistence::PersistedRequestPaymentConfiguration,
|
||||
>(&req.input.request_payment_configuration)?;
|
||||
let payment_config = serialize_bucket_config(&request_payment_configuration)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
|
||||
update_bucket_metadata_config_if_incarnation(
|
||||
&req.input.bucket,
|
||||
BUCKET_REQUEST_PAYMENT_CONFIG,
|
||||
payment_config,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(PutBucketRequestPaymentOutput::default()))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_website_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketWebsiteInput>,
|
||||
) -> S3Result<S3Response<PutBucketWebsiteOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(s3_error!(InternalError, "Not init"));
|
||||
};
|
||||
store
|
||||
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
let website_configuration = persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedWebsiteConfiguration>(
|
||||
&req.input.website_configuration,
|
||||
)?;
|
||||
validate_website_configuration(&website_configuration)?;
|
||||
|
||||
let website_config = serialize_bucket_config(&website_configuration)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
|
||||
update_bucket_metadata_config_if_incarnation(
|
||||
&req.input.bucket,
|
||||
BUCKET_WEBSITE_CONFIG,
|
||||
website_config,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(PutBucketWebsiteOutput::default()))
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of the bucket lifecycle, replication and Object Lock configurations: batch A3 of
|
||||
//! the gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::admin::handlers::site_replication::site_replication_bucket_meta_hook;
|
||||
use crate::app::storage_api::object_usecase::bucket::replication::OperatorRuleContract;
|
||||
use crate::error::ApiError;
|
||||
use crate::storage::StorageVersioningConfigExt as _;
|
||||
use crate::storage::access::bucket_config_mutation_incarnation;
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use crate::storage::s3_compat::lifecycle::{lifecycle_configuration_from_s3s, lifecycle_configuration_to_s3s};
|
||||
use crate::storage::s3_compat::replication::{replication_configuration_from_s3s, replication_configuration_to_s3s};
|
||||
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
|
||||
use crate::storage::storage_api::ecstore_bucket::utils::serialize as serialize_bucket_config;
|
||||
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
||||
use crate::storage::{
|
||||
BUCKET_VERSIONING_CONFIG, BucketVersioningSys, OBJECT_LOCK_CONFIG, StorageError, get_bucket_object_lock_config,
|
||||
update_bucket_metadata_config_if_incarnation,
|
||||
};
|
||||
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
|
||||
use http::StatusCode;
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_madmin::{SITE_REPL_API_VERSION, SRBucketMeta};
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::{instrument, warn};
|
||||
|
||||
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) static SITE_REPLICATION_GATE_TEST_OVERRIDE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) const SITE_REPLICATION_GATE_FORCE_DISABLED: u8 = 1;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) const SITE_REPLICATION_GATE_FORCE_ENABLED: u8 = 2;
|
||||
|
||||
async fn site_replication_gate_enabled() -> S3Result<bool> {
|
||||
#[cfg(test)]
|
||||
match SITE_REPLICATION_GATE_TEST_OVERRIDE.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
SITE_REPLICATION_GATE_FORCE_DISABLED => return Ok(false),
|
||||
SITE_REPLICATION_GATE_FORCE_ENABLED => return Ok(true),
|
||||
_ => {}
|
||||
}
|
||||
crate::admin::handlers::site_replication::site_replication_enabled()
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Remote site-replication peer deployment ids and the operator-rule contract
|
||||
/// the peers support, handed to the bucket usecase so an S3 replication-config
|
||||
/// edit keeps exactly the reconciler-owned rules and merges the way every
|
||||
/// peer will (issue #1948). Read here, in the interface layer, because the
|
||||
/// usecase must not import the admin handlers (layer guard); a state-read
|
||||
/// failure propagates so the edit fails closed.
|
||||
async fn site_replication_edit_context() -> S3Result<(std::collections::HashSet<String>, OperatorRuleContract)> {
|
||||
// While the gate override is in effect the test exercises the deny/allow
|
||||
// branch, not the peer set; there is no persisted state to read.
|
||||
#[cfg(test)]
|
||||
if SITE_REPLICATION_GATE_TEST_OVERRIDE.load(std::sync::atomic::Ordering::SeqCst) != 0 {
|
||||
return Ok((std::collections::HashSet::new(), OperatorRuleContract::Derived));
|
||||
}
|
||||
crate::admin::handlers::site_replication::site_replication_edit_context()
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// MinIO `ErrReplicationDenyEditError`.
|
||||
fn replication_deny_edit_error() -> S3Error {
|
||||
let mut err = S3Error::with_message(
|
||||
S3ErrorCode::Custom("XMinioReplicationDenyEdit".into()),
|
||||
"Sub-User is not allowed to edit Replication configuration",
|
||||
);
|
||||
err.set_status_code(StatusCode::BAD_REQUEST);
|
||||
err
|
||||
}
|
||||
|
||||
/// Site-replication gate for S3 replication-config edits (issue #1948).
|
||||
///
|
||||
/// On a site-replication deployment the bucket's replication config carries
|
||||
/// the operator-managed `site-repl-*` rules that keep every peer in sync, and
|
||||
/// a successful edit is broadcast to all peers — so a user holding only
|
||||
/// bucket-scoped `s3:PutReplicationConfiguration` could rewrite or erase
|
||||
/// replication net-wide. MinIO parity (`ErrReplicationDenyEditError`): only
|
||||
/// owner credentials (root or root-parented) may edit. Runs after the policy
|
||||
/// authorization in the access layer and only on the external S3 path — the
|
||||
/// reconciler and peer bucket-meta ingestion never route through these
|
||||
/// handlers.
|
||||
async fn deny_replication_config_edit_for_non_owner<T>(req: &S3Request<T>) -> S3Result<()> {
|
||||
if crate::storage::access::req_info_ref(req)?.is_owner {
|
||||
return Ok(());
|
||||
}
|
||||
if site_replication_gate_enabled().await? {
|
||||
return Err(replication_deny_edit_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
|
||||
impl FS {
|
||||
async fn delete_bucket_replication_inner(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketReplicationInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
|
||||
deny_replication_config_edit_for_non_owner(&req).await?;
|
||||
let (site_peers, contract) = site_replication_edit_context().await?;
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_delete_bucket_replication(req, site_peers, contract).await
|
||||
}
|
||||
|
||||
async fn put_bucket_replication_inner(
|
||||
&self,
|
||||
mut req: S3Request<PutBucketReplicationInput>,
|
||||
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
|
||||
deny_replication_config_edit_for_non_owner(&req).await?;
|
||||
let (site_peers, contract) = site_replication_edit_context().await?;
|
||||
let replication_configuration =
|
||||
replication_configuration_from_s3s(std::mem::take(&mut req.input.replication_configuration));
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase
|
||||
.execute_put_bucket_replication(req, replication_configuration, site_peers, contract)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_object_lock_configuration_inner(
|
||||
&self,
|
||||
req: S3Request<PutObjectLockConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
|
||||
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
|
||||
let PutObjectLockConfigurationInput {
|
||||
bucket,
|
||||
object_lock_configuration,
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
let Some(input_cfg) = object_lock_configuration else { return Err(s3_error!(InvalidArgument)) };
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
validate_object_lock_configuration_input(&input_cfg)?;
|
||||
|
||||
match get_bucket_object_lock_config(&bucket).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
if !BucketVersioningSys::enabled(&bucket).await {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidBucketState,
|
||||
"Object Lock configuration cannot be enabled on existing buckets".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
warn!("get_object_lock_config err {:?}", err);
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
"Failed to get bucket ObjectLockConfiguration".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let persisted_cfg =
|
||||
persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedObjectLockConfiguration>(&input_cfg)?;
|
||||
let data = serialize_bucket_config(&persisted_cfg)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
|
||||
let object_lock_config =
|
||||
String::from_utf8(data.clone()).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
|
||||
|
||||
let updated_at = update_bucket_metadata_config_if_incarnation(&bucket, OBJECT_LOCK_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
// When Object Lock is enabled, automatically enable versioning if not already enabled.
|
||||
// This matches S3-compatible behavior.
|
||||
let versioning_config = BucketVersioningSys::get(&bucket).await.map_err(ApiError::from)?;
|
||||
if !versioning_config.enabled() {
|
||||
let enable_versioning_config = rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let versioning_data = serialize_bucket_config(&enable_versioning_config)
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
|
||||
update_bucket_metadata_config_if_incarnation(
|
||||
&bucket,
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
versioning_data,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
|
||||
if let Some(notification_sys) =
|
||||
runtime_sources::current_notification_system_for_context(self.server_ctx.app_context().as_deref())
|
||||
{
|
||||
propagate_object_lock_peer_reload(notification_sys.load_bucket_metadata(&bucket).await)?;
|
||||
}
|
||||
|
||||
if let Err(err) = site_replication_bucket_meta_hook(SRBucketMeta {
|
||||
bucket: bucket.clone(),
|
||||
r#type: "object-lock-config".to_string(),
|
||||
object_lock_config: Some(object_lock_config),
|
||||
updated_at: Some(updated_at),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
|
||||
event = "put_object_lock_configuration",
|
||||
bucket = %bucket,
|
||||
result = "site_replication_hook_failed",
|
||||
error = ?err,
|
||||
"storage object lock state"
|
||||
);
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "delete_bucket_lifecycle", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_lifecycle_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketLifecycleInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::DeleteBucketLifecycle, |req| {
|
||||
usecase.execute_delete_bucket_lifecycle(req)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn delete_bucket_replication_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteBucketReplicationInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
|
||||
audit_only_operation(req, S3Operation::DeleteBucketReplication, |req| self.delete_bucket_replication_inner(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "get_bucket_lifecycle_configuration", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_lifecycle_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketLifecycleConfigurationInput>,
|
||||
) -> S3Result<S3Response<GetBucketLifecycleConfigurationOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketLifecycleConfiguration);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let config = usecase.execute_get_bucket_lifecycle_configuration(req).await?;
|
||||
let config = lifecycle_configuration_to_s3s(config)
|
||||
.map_err(|error| S3Error::with_message(S3ErrorCode::InternalError, error.to_string()))?;
|
||||
Ok(S3Response::new(GetBucketLifecycleConfigurationOutput {
|
||||
rules: Some(config.rules),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_bucket_replication_edge(
|
||||
&self,
|
||||
req: S3Request<GetBucketReplicationInput>,
|
||||
) -> S3Result<S3Response<GetBucketReplicationOutput>> {
|
||||
record_s3_op(S3Operation::GetBucketReplication);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let replication_configuration = usecase.execute_get_bucket_replication(req).await?;
|
||||
Ok(S3Response::new(GetBucketReplicationOutput {
|
||||
replication_configuration: Some(replication_configuration_to_s3s(replication_configuration)),
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "get_object_lock_configuration", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_object_lock_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectLockConfigurationInput>,
|
||||
) -> S3Result<S3Response<GetObjectLockConfigurationOutput>> {
|
||||
record_s3_op(S3Operation::GetObjectLockConfiguration);
|
||||
let GetObjectLockConfigurationInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let object_lock_configuration = match get_bucket_object_lock_config(&bucket).await {
|
||||
Ok((cfg, _created)) => Some(persisted_to_s3s::<_, ObjectLockConfiguration>(&cfg)?),
|
||||
Err(err) => {
|
||||
if err == StorageError::ConfigNotFound {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::ObjectLockConfigurationNotFoundError,
|
||||
"Object Lock configuration does not exist for this bucket".to_string(),
|
||||
));
|
||||
}
|
||||
warn!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
|
||||
event = "object_lock_config_load_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"Failed to load bucket object lock configuration"
|
||||
);
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
"Failed to load Object Lock configuration".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(S3Response::new(GetObjectLockConfigurationOutput {
|
||||
object_lock_configuration,
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "put_bucket_lifecycle_configuration", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_lifecycle_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketLifecycleConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
audit_only_operation(req, S3Operation::PutBucketLifecycleConfiguration, |req| {
|
||||
usecase.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_bucket_replication_edge(
|
||||
&self,
|
||||
req: S3Request<PutBucketReplicationInput>,
|
||||
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
|
||||
audit_only_operation(req, S3Operation::PutBucketReplication, |req| self.put_bucket_replication_inner(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "put_object_lock_configuration", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_object_lock_configuration_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectLockConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
|
||||
audit_only_operation(req, S3Operation::PutObjectLockConfiguration, |req| {
|
||||
self.put_object_lock_configuration_inner(req)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of the object and multipart-upload listings, CreateMultipartUpload and
|
||||
//! AbortMultipartUpload: batch B2 of the gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "abort_multipart_upload", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn abort_multipart_upload_edge(
|
||||
&self,
|
||||
req: S3Request<AbortMultipartUploadInput>,
|
||||
) -> S3Result<S3Response<AbortMultipartUploadOutput>> {
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::AbortMultipartUpload, |req| usecase.execute_abort_multipart_upload(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "create_multipart_upload", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn create_multipart_upload_edge(
|
||||
&self,
|
||||
req: S3Request<CreateMultipartUploadInput>,
|
||||
) -> S3Result<S3Response<CreateMultipartUploadOutput>> {
|
||||
crate::hp_guard!("S3::create_multipart_upload");
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
usecase.execute_create_multipart_upload(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn list_multipart_uploads_edge(
|
||||
&self,
|
||||
req: S3Request<ListMultipartUploadsInput>,
|
||||
) -> S3Result<S3Response<ListMultipartUploadsOutput>> {
|
||||
record_s3_op(S3Operation::ListMultipartUploads);
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
usecase.execute_list_multipart_uploads(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn list_object_versions_edge(
|
||||
&self,
|
||||
req: S3Request<ListObjectVersionsInput>,
|
||||
) -> S3Result<S3Response<ListObjectVersionsOutput>> {
|
||||
record_s3_op(S3Operation::ListObjectVersions);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_list_object_versions(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "list_objects", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn list_objects_edge(
|
||||
&self,
|
||||
req: S3Request<ListObjectsInput>,
|
||||
) -> S3Result<S3Response<ListObjectsOutput>> {
|
||||
record_s3_op(S3Operation::ListObjects);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_list_objects(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "list_objects_v2", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn list_objects_v2_edge(
|
||||
&self,
|
||||
req: S3Request<ListObjectsV2Input>,
|
||||
) -> S3Result<S3Response<ListObjectsV2Output>> {
|
||||
crate::hp_guard!("S3::list_objects_v2");
|
||||
record_s3_op(S3Operation::ListObjectsV2);
|
||||
let usecase = s3_api::bucket_usecase_for(self);
|
||||
usecase.execute_list_objects_v2(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "list_parts", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn list_parts_edge(
|
||||
&self,
|
||||
req: S3Request<ListPartsInput>,
|
||||
) -> S3Result<S3Response<ListPartsOutput>> {
|
||||
record_s3_op(S3Operation::ListParts);
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
usecase.execute_list_parts(req).await
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The bodies of `impl S3 for FS`, one file per batch of the s3s-to-gateway migration
|
||||
//! (rustfs/backlog#2765), so that parallel batches edit disjoint files. `ecfs.rs` keeps the
|
||||
//! trait impl, which delegates every method here, and the helpers several batches share.
|
||||
//! `scripts/codemods/split_ecfs_legacy_edge.py` produced this split from the unsplit file.
|
||||
|
||||
mod bucket_access;
|
||||
mod bucket_configs;
|
||||
mod bucket_core;
|
||||
mod bucket_replication_lifecycle;
|
||||
mod listing_multipart;
|
||||
mod object_meta;
|
||||
mod object_misc;
|
||||
mod object_read;
|
||||
mod object_write;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,976 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of object ACL, tagging, legal hold, retention and torrent: batch B1 of the
|
||||
//! gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::app::storage_api::object_usecase::bucket::replication::{
|
||||
ReplicateDecision, get_read_proxy_targets, must_replicate_metadata, schedule_metadata_replication,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::storage::access::{apply_bucket_generation_guard, has_bypass_governance_header, replication_proxy_allowed};
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::OperationHelper;
|
||||
use crate::storage::options::get_opts;
|
||||
use crate::storage::s3_api::{self, acl};
|
||||
use crate::storage::storage_api::ecfs_consumer::StorageObjectOptions as ObjectOptions;
|
||||
use crate::storage::storage_api::ecfs_consumer::contract::{
|
||||
bucket::{BucketOperations, BucketOptions},
|
||||
object::ObjectLockRetentionOptions,
|
||||
};
|
||||
use crate::storage::storage_api::ecfs_consumer::object_lock::{
|
||||
parse_object_lock_legal_hold, parse_object_lock_retention, validate_bucket_object_lock_enabled,
|
||||
};
|
||||
use crate::storage::{
|
||||
BucketVersioningSys, check_retention_for_modification, decode_tags, encode_tags, is_err_object_not_found,
|
||||
is_err_version_not_found, record_replication_proxy,
|
||||
};
|
||||
use crate::table_catalog;
|
||||
use metrics::{counter, histogram};
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use rustfs_targets::EventName;
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_REPLICATION_GENERATION, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_TAGGING_TIMESTAMP, insert_str,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{debug, error, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
const LOG_SUBSYSTEM_TAGGING: &str = "tagging";
|
||||
|
||||
async fn reject_veeam_sosapi_mutation(fs: &FS, bucket: &str, key: &str) -> S3Result<()> {
|
||||
s3_api::reject_veeam_sosapi_mutation(|| fs.server_ctx.object_store(), bucket, key).await
|
||||
}
|
||||
|
||||
async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> {
|
||||
table_catalog::validate_bucket_object_mutation(bucket, key)
|
||||
.await
|
||||
.map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE))
|
||||
}
|
||||
|
||||
impl FS {
|
||||
/// Not-found classifier for proxied SDK tagging calls: a raw 404 covers
|
||||
/// NoSuchKey and NoSuchVersion alike; the caller silently tries the next
|
||||
/// replication target.
|
||||
fn proxy_sdk_error_is_not_found<E>(err: &aws_sdk_s3::error::SdkError<E>) -> bool {
|
||||
err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404)
|
||||
}
|
||||
|
||||
/// Selector options for a tagging proxy. Reuses `get_opts` so the
|
||||
/// anti-loop `source-proxy-request` header family and the bucket's
|
||||
/// version-suspension state gate proxying exactly like GET/HEAD.
|
||||
async fn tagging_proxy_opts(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Option<ObjectOptions> {
|
||||
get_opts(bucket, object, version_id, None, headers).await.ok()
|
||||
}
|
||||
|
||||
/// Serve a GetObjectTagging for an object missing locally by proxying to
|
||||
/// the bucket's replication targets (MinIO `proxyGetTaggingToRepTarget`,
|
||||
/// backlog#1675 P1-5). None means no target had the object.
|
||||
async fn proxy_get_object_tagging(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Option<GetObjectTaggingOutput> {
|
||||
let opts = Self::tagging_proxy_opts(bucket, object, version_id, headers).await?;
|
||||
let targets = get_read_proxy_targets(bucket, object, &opts).await;
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for target in targets {
|
||||
match target
|
||||
.get_object_tagging(&target.bucket, object, opts.version_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(remote) => {
|
||||
// MinIO-aligned accounting: one total per proxy attempt,
|
||||
// one failed when no target served it.
|
||||
record_replication_proxy(bucket, "GetObjectTagging", false).await;
|
||||
return Some(GetObjectTaggingOutput {
|
||||
tag_set: remote
|
||||
.tag_set
|
||||
.into_iter()
|
||||
.map(|tag| Tag {
|
||||
key: Some(tag.key),
|
||||
value: Some(tag.value),
|
||||
})
|
||||
.collect(),
|
||||
version_id: remote.version_id,
|
||||
});
|
||||
}
|
||||
Err(err) if Self::proxy_sdk_error_is_not_found(&err) => {
|
||||
debug!(bucket, object, arn = %target.arn, "tagging proxy: target does not have the object");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(bucket, object, arn = %target.arn, error = %err, "tagging proxy: GetObjectTagging against replication target failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
record_replication_proxy(bucket, "GetObjectTagging", true).await;
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply a PutObjectTagging for an object missing locally on a
|
||||
/// replication target (MinIO `proxyTaggingToRepTarget`).
|
||||
async fn proxy_put_object_tagging(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
headers: &http::HeaderMap,
|
||||
tag_set: &TagSet,
|
||||
) -> Option<()> {
|
||||
let opts = Self::tagging_proxy_opts(bucket, object, version_id, headers).await?;
|
||||
let mut tagging = aws_sdk_s3::types::Tagging::builder();
|
||||
for tag in tag_set {
|
||||
let sdk_tag = aws_sdk_s3::types::Tag::builder()
|
||||
.key(tag.key.clone().unwrap_or_default())
|
||||
.value(tag.value.clone().unwrap_or_default())
|
||||
.build()
|
||||
.ok()?;
|
||||
tagging = tagging.tag_set(sdk_tag);
|
||||
}
|
||||
let tagging = tagging.build().ok()?;
|
||||
let targets = get_read_proxy_targets(bucket, object, &opts).await;
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for target in targets {
|
||||
match target
|
||||
.put_object_tagging(&target.bucket, object, opts.version_id.clone(), tagging.clone())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// MinIO-aligned accounting: one total per proxy attempt,
|
||||
// one failed when no target served it.
|
||||
record_replication_proxy(bucket, "PutObjectTagging", false).await;
|
||||
return Some(());
|
||||
}
|
||||
Err(err) if Self::proxy_sdk_error_is_not_found(&err) => {
|
||||
debug!(bucket, object, arn = %target.arn, "tagging proxy: target does not have the object");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(bucket, object, arn = %target.arn, error = %err, "tagging proxy: PutObjectTagging against replication target failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
record_replication_proxy(bucket, "PutObjectTagging", true).await;
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply a DeleteObjectTagging for an object missing locally on a
|
||||
/// replication target (MinIO `proxyTaggingToRepTarget`).
|
||||
async fn proxy_delete_object_tagging(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Option<()> {
|
||||
let opts = Self::tagging_proxy_opts(bucket, object, version_id, headers).await?;
|
||||
let targets = get_read_proxy_targets(bucket, object, &opts).await;
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for target in targets {
|
||||
match target
|
||||
.delete_object_tagging(&target.bucket, object, opts.version_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// MinIO-aligned accounting: one total per proxy attempt,
|
||||
// one failed when no target served it.
|
||||
record_replication_proxy(bucket, "DeleteObjectTagging", false).await;
|
||||
return Some(());
|
||||
}
|
||||
Err(err) if Self::proxy_sdk_error_is_not_found(&err) => {
|
||||
debug!(bucket, object, arn = %target.arn, "tagging proxy: target does not have the object");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(bucket, object, arn = %target.arn, error = %err, "tagging proxy: DeleteObjectTagging against replication target failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
record_replication_proxy(bucket, "DeleteObjectTagging", true).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "delete_object_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_object_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteObjectTaggingInput>,
|
||||
) -> S3Result<S3Response<DeleteObjectTaggingOutput>> {
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut helper = OperationHelper::new(&req, EventName::ObjectTaggingDelete, S3Operation::DeleteObjectTagging);
|
||||
let DeleteObjectTaggingInput {
|
||||
bucket,
|
||||
key: object,
|
||||
version_id,
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &object).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &object).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_store_uninitialized",
|
||||
operation = "delete",
|
||||
"Object tagging operation failed because storage is not initialized"
|
||||
);
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let mut opts = get_opts(&bucket, &object, version_id.clone(), None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let existing_object_info = match store.get_object_info(&bucket, &object, &opts).await {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
// Replication lag window: apply the tagging delete on a
|
||||
// replication target that already has the object
|
||||
// (backlog#1675 P1-5). No local object exists, so no bucket
|
||||
// notification event is emitted for the proxied write.
|
||||
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
|
||||
&& replication_proxy_allowed(&req.extensions)
|
||||
&& Self::proxy_delete_object_tagging(&bucket, &object, version_id.clone(), &req.headers)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
counter!("rustfs_delete_object_tagging_success").increment(1);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "delete")
|
||||
.record(duration.as_secs_f64());
|
||||
return Ok(S3Response::new(DeleteObjectTaggingOutput { version_id }));
|
||||
}
|
||||
return Err(ApiError::from(e).into());
|
||||
}
|
||||
};
|
||||
let dsc = must_replicate_metadata(
|
||||
&bucket,
|
||||
&object,
|
||||
&existing_object_info.user_defined,
|
||||
String::new(),
|
||||
existing_object_info.replication_status.clone(),
|
||||
opts.clone(),
|
||||
)
|
||||
.await;
|
||||
if dsc.replicate_any() {
|
||||
let mut eval_metadata = HashMap::new();
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_GENERATION, Uuid::new_v4().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default());
|
||||
insert_str(
|
||||
&mut eval_metadata,
|
||||
SUFFIX_TAGGING_TIMESTAMP,
|
||||
OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_default(),
|
||||
);
|
||||
opts.eval_metadata = Some(eval_metadata);
|
||||
}
|
||||
|
||||
let delete_tags_result = store.delete_object_tags(&bucket, &object, &opts).await;
|
||||
let object_info = delete_tags_result.map_err(|e| {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_delete_failed",
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
error = %e,
|
||||
"Failed to delete object tags"
|
||||
);
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
let event_object_info = Some(object_info.clone());
|
||||
if dsc.replicate_any() {
|
||||
schedule_metadata_replication(object_info, store.clone(), dsc).await;
|
||||
}
|
||||
|
||||
counter!("rustfs_delete_object_tagging_success").increment(1);
|
||||
|
||||
let event_version_id = version_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
event_object_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.version_id.map(|version_id| version_id.to_string()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(event_object_info) = event_object_info {
|
||||
helper = helper.object(event_object_info);
|
||||
}
|
||||
helper = helper.version_id(event_version_id);
|
||||
|
||||
let result = Ok(S3Response::new(DeleteObjectTaggingOutput { version_id }));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&object,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "delete").record(duration.as_secs_f64());
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_object_acl_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectAclInput>,
|
||||
) -> S3Result<S3Response<GetObjectAclOutput>> {
|
||||
record_s3_op(S3Operation::GetObjectAcl);
|
||||
let GetObjectAclInput {
|
||||
bucket, key, version_id, ..
|
||||
} = req.input;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let opts: ObjectOptions = get_opts(&bucket, &key, version_id, None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
store.get_object_info(&bucket, &key, &opts).await.map_err(ApiError::from)?;
|
||||
|
||||
Ok(S3Response::new(acl::build_get_object_acl_output()))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_object_legal_hold_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectLegalHoldInput>,
|
||||
) -> S3Result<S3Response<GetObjectLegalHoldOutput>> {
|
||||
let mut helper =
|
||||
OperationHelper::new(&req, EventName::ObjectAccessedGetLegalHold, S3Operation::GetObjectLegalHold).suppress_event();
|
||||
let GetObjectLegalHoldInput {
|
||||
bucket, key, version_id, ..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let _ = store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
validate_bucket_object_lock_enabled(&bucket).await?;
|
||||
|
||||
let opts: ObjectOptions = get_opts(&bucket, &key, version_id, None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let object_info = store.get_object_info(&bucket, &key, &opts).await.map_err(|e| {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
event = "object_info_load_failed",
|
||||
bucket = %bucket,
|
||||
object = %key,
|
||||
error = %e,
|
||||
"Failed to load object info"
|
||||
);
|
||||
s3_error!(InternalError, "{}", e.to_string())
|
||||
})?;
|
||||
|
||||
let legal_hold = object_info
|
||||
.user_defined
|
||||
.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)
|
||||
.map(|v| v.as_str().to_string());
|
||||
|
||||
let status = if let Some(v) = legal_hold {
|
||||
v
|
||||
} else {
|
||||
ObjectLockLegalHoldStatus::OFF.to_string()
|
||||
};
|
||||
|
||||
let output = GetObjectLegalHoldOutput {
|
||||
legal_hold: Some(ObjectLockLegalHold {
|
||||
status: Some(ObjectLockLegalHoldStatus::from(status)),
|
||||
}),
|
||||
};
|
||||
|
||||
let version_id = req.input.version_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
helper = helper.object(object_info).version_id(version_id);
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_object_retention_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectRetentionInput>,
|
||||
) -> S3Result<S3Response<GetObjectRetentionOutput>> {
|
||||
let mut helper =
|
||||
OperationHelper::new(&req, EventName::ObjectAccessedGetRetention, S3Operation::GetObjectRetention).suppress_event();
|
||||
let GetObjectRetentionInput {
|
||||
bucket, key, version_id, ..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
validate_bucket_object_lock_enabled(&bucket).await?;
|
||||
|
||||
let opts: ObjectOptions = get_opts(&bucket, &key, version_id, None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let object_info = store.get_object_info(&bucket, &key, &opts).await.map_err(|e| {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
event = "object_info_load_failed",
|
||||
bucket = %bucket,
|
||||
object = %key,
|
||||
error = %e,
|
||||
"Failed to load object info"
|
||||
);
|
||||
s3_error!(InternalError, "{}", e.to_string())
|
||||
})?;
|
||||
|
||||
let output = GetObjectRetentionOutput {
|
||||
retention: Some(object_lock_retention_from_metadata(&object_info.user_defined)?),
|
||||
};
|
||||
let version_id = req.input.version_id.clone().unwrap_or_default();
|
||||
helper = helper.object(object_info).version_id(version_id);
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
result
|
||||
}
|
||||
|
||||
#[instrument(name = "get_object_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn get_object_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectTaggingInput>,
|
||||
) -> S3Result<S3Response<GetObjectTaggingOutput>> {
|
||||
use crate::storage::storage_api::ecstore_bucket::versioning::VersioningApi as _;
|
||||
|
||||
record_s3_op(S3Operation::GetObjectTagging);
|
||||
let start_time = std::time::Instant::now();
|
||||
let bucket = req.input.bucket.as_str();
|
||||
let object = req.input.key.as_str();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, bucket, object).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_store_uninitialized",
|
||||
operation = "get",
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
"Object tagging operation failed because storage is not initialized"
|
||||
);
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let version_id = parse_object_version_id(req.input.version_id.clone())?.map(Into::into);
|
||||
let versioning = BucketVersioningSys::get(bucket).await.map_err(ApiError::from)?;
|
||||
let opts = ObjectOptions {
|
||||
version_id,
|
||||
versioned: versioning.prefix_enabled(object),
|
||||
version_suspended: versioning.prefix_suspended(object),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Tags and response identity must come from the same locked metadata snapshot.
|
||||
let info = match store.get_object_info(bucket, object, &opts).await {
|
||||
Ok(info) if info.delete_marker => {
|
||||
return Err(S3Error::new(if opts.version_id.is_some() {
|
||||
S3ErrorCode::MethodNotAllowed
|
||||
} else {
|
||||
S3ErrorCode::NoSuchKey
|
||||
}));
|
||||
}
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
// Replication lag window: the object may exist on a
|
||||
// replication target even though it is missing locally —
|
||||
// proxy the tagging read there (backlog#1675 P1-5).
|
||||
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
|
||||
&& replication_proxy_allowed(&req.extensions)
|
||||
&& let Some(output) =
|
||||
Self::proxy_get_object_tagging(bucket, object, req.input.version_id.clone(), &req.headers).await
|
||||
{
|
||||
counter!("rustfs_get_object_tagging_success").increment(1);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "get")
|
||||
.record(duration.as_secs_f64());
|
||||
return Ok(S3Response::new(output));
|
||||
}
|
||||
if is_err_object_not_found(&e) {
|
||||
debug!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_not_found",
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
error = %e,
|
||||
"Object tags not found"
|
||||
);
|
||||
return Err(S3Error::new(if opts.version_id.is_some() {
|
||||
S3ErrorCode::NoSuchVersion
|
||||
} else {
|
||||
S3ErrorCode::NoSuchKey
|
||||
}));
|
||||
}
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_get_failed",
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
error = %e,
|
||||
"Failed to load object tags"
|
||||
);
|
||||
return Err(ApiError::from(e).into());
|
||||
}
|
||||
};
|
||||
|
||||
let tag_set = decode_tags(info.user_tags.as_str());
|
||||
debug!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
subsystem = LOG_SUBSYSTEM_TAGGING,
|
||||
event = "object_tagging_decoded",
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
tag_count = tag_set.len(),
|
||||
"Decoded object tags"
|
||||
);
|
||||
|
||||
counter!("rustfs_get_object_tagging_success").increment(1);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "get").record(duration.as_secs_f64());
|
||||
Ok(S3Response::new(GetObjectTaggingOutput {
|
||||
tag_set,
|
||||
version_id: s3_api::s3_response_version_id(info.version_id),
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(name = "get_object_torrent", level = "debug", skip(self, _req))]
|
||||
pub(in crate::storage::ecfs) async fn get_object_torrent_edge(
|
||||
&self,
|
||||
_req: S3Request<GetObjectTorrentInput>,
|
||||
) -> S3Result<S3Response<GetObjectTorrentOutput>> {
|
||||
// Torrent functionality is not implemented in RustFS
|
||||
// Per S3 API test expectations, return 404 NoSuchKey (not 501 Not Implemented)
|
||||
// This allows clients to gracefully handle the absence of torrent support
|
||||
record_s3_op(S3Operation::GetObjectTorrent);
|
||||
Err(S3Error::new(S3ErrorCode::NoSuchKey))
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_object_acl_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectAclInput>,
|
||||
) -> S3Result<S3Response<PutObjectAclOutput>> {
|
||||
let mut helper = OperationHelper::new(&req, EventName::ObjectAclPut, S3Operation::PutObjectAcl);
|
||||
let bucket = &req.input.bucket;
|
||||
let key = &req.input.key;
|
||||
let version_id = req.input.version_id.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, bucket, key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let opts: ObjectOptions = get_opts(bucket, key, version_id.clone(), None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let object_info = store.get_object_info(bucket, key, &opts).await.map_err(ApiError::from)?;
|
||||
|
||||
if req.input.access_control_policy.is_some() {
|
||||
return Err(s3_error!(
|
||||
NotImplemented,
|
||||
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
|
||||
));
|
||||
}
|
||||
|
||||
let event_version_id = version_id
|
||||
.or_else(|| object_info.version_id.map(|version_id| version_id.to_string()))
|
||||
.unwrap_or_default();
|
||||
helper = helper.object(object_info).version_id(event_version_id);
|
||||
|
||||
let result = Ok(S3Response::new(PutObjectAclOutput::default()));
|
||||
let _ = helper.complete(&result);
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_object_legal_hold_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectLegalHoldInput>,
|
||||
) -> S3Result<S3Response<PutObjectLegalHoldOutput>> {
|
||||
let mut helper =
|
||||
OperationHelper::new(&req, EventName::ObjectCreatedPutLegalHold, S3Operation::PutObjectLegalHold).suppress_event();
|
||||
let PutObjectLegalHoldInput {
|
||||
bucket,
|
||||
key,
|
||||
legal_hold,
|
||||
version_id,
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
if let Err(err) = validate_bucket_object_lock_enabled(&bucket).await {
|
||||
let _ = store
|
||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let opts: ObjectOptions = get_opts(&bucket, &key, version_id, None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let mut popts = ObjectOptions {
|
||||
mod_time: opts.mod_time,
|
||||
version_id: opts.version_id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut popts)?;
|
||||
|
||||
// PutObjectLegalHold only rewrites metadata, so replication is not scheduled by the
|
||||
// object PUT path. Schedule it explicitly, otherwise the legal hold never reaches the
|
||||
// replica and the peer copy remains deletable.
|
||||
//
|
||||
// Mirror the PUT path ordering (see object_usecase::put_object): compute the decision
|
||||
// once BEFORE the commit and persist the pending marker with it, so a restart between
|
||||
// the commit and the worker write-back leaves a Pending status on disk for the scanner
|
||||
// to re-drive. Scheduling without that marker would lose the task silently.
|
||||
let dsc = if crate::app::storage_api::bucket::metadata_sys::get_replication_config(&bucket)
|
||||
.await
|
||||
.is_ok()
|
||||
&& let Ok(info) = store.get_object_info(&bucket, &key, &opts).await
|
||||
{
|
||||
must_replicate_metadata(
|
||||
&bucket,
|
||||
&key,
|
||||
&info.user_defined,
|
||||
info.user_tags.as_ref().clone(),
|
||||
popts.delete_marker_replication_status(),
|
||||
popts.clone(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
ReplicateDecision::new()
|
||||
};
|
||||
|
||||
let mut eval_metadata = parse_object_lock_legal_hold(legal_hold)?;
|
||||
if dsc.replicate_any() {
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_GENERATION, Uuid::new_v4().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default());
|
||||
}
|
||||
popts.eval_metadata = Some(eval_metadata);
|
||||
|
||||
let info = store.put_object_metadata(&bucket, &key, &popts).await.map_err(|e| {
|
||||
error!("put_object_metadata failed, {}", e.to_string());
|
||||
s3_error!(InternalError, "{}", e.to_string())
|
||||
})?;
|
||||
|
||||
// The current target transport carries the updated metadata in a full-object PUT.
|
||||
if dsc.replicate_any() {
|
||||
schedule_metadata_replication(info.clone(), store.clone(), dsc).await;
|
||||
}
|
||||
|
||||
let output = PutObjectLegalHoldOutput {
|
||||
request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)),
|
||||
};
|
||||
let version_id = req.input.version_id.clone().unwrap_or_default();
|
||||
helper = helper.object(info).version_id(version_id);
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn put_object_retention_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectRetentionInput>,
|
||||
) -> S3Result<S3Response<PutObjectRetentionOutput>> {
|
||||
let mut helper =
|
||||
OperationHelper::new(&req, EventName::ObjectCreatedPutRetention, S3Operation::PutObjectRetention).suppress_event();
|
||||
let PutObjectRetentionInput {
|
||||
bucket,
|
||||
key,
|
||||
retention,
|
||||
version_id,
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
validate_bucket_object_lock_enabled(&bucket).await?;
|
||||
|
||||
let new_retain_until = retention
|
||||
.as_ref()
|
||||
.and_then(|r| r.retain_until_date.as_ref())
|
||||
.map(|d| OffsetDateTime::from(d.clone()));
|
||||
let new_mode = retention
|
||||
.as_ref()
|
||||
.and_then(|r| r.mode.as_ref())
|
||||
.map(|mode| mode.as_str().to_string());
|
||||
|
||||
let bypass_governance = has_bypass_governance_header(&req.headers);
|
||||
// Keep the early check for existing response behavior; put_object_metadata
|
||||
// repeats the same check after taking the metadata write lock.
|
||||
let check_opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let existing_obj_info = store.get_object_info(&bucket, &key, &check_opts).await.ok();
|
||||
|
||||
if let Some(existing_obj_info) = existing_obj_info.as_ref()
|
||||
&& let Some(block_reason) = check_retention_for_modification(
|
||||
&existing_obj_info.user_defined,
|
||||
new_mode.as_deref(),
|
||||
new_retain_until,
|
||||
bypass_governance,
|
||||
)
|
||||
{
|
||||
return Err(S3Error::with_message(S3ErrorCode::AccessDenied, block_reason.error_message()));
|
||||
}
|
||||
|
||||
let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id, None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
apply_bucket_generation_guard(&req, &bucket, &mut opts)?;
|
||||
opts.object_lock_retention = Some(ObjectLockRetentionOptions {
|
||||
mode: new_mode,
|
||||
retain_until: new_retain_until,
|
||||
bypass_governance,
|
||||
});
|
||||
|
||||
// PutObjectRetention only rewrites metadata, so the object PUT path that normally
|
||||
// computes a replication decision and schedules replication never runs for it. Without
|
||||
// scheduling here the peer keeps the previous, unprotected lock state and a
|
||||
// WORM-protected object stays deletable on the replica.
|
||||
//
|
||||
// Mirror the PUT path ordering (see object_usecase::put_object): compute the decision
|
||||
// once BEFORE the commit and persist the pending marker with it, so a restart between
|
||||
// the commit and the worker write-back leaves a Pending status on disk for the scanner
|
||||
// to re-drive. Scheduling without that marker would lose the task silently.
|
||||
let dsc = match existing_obj_info.as_ref() {
|
||||
Some(info) => {
|
||||
must_replicate_metadata(
|
||||
&bucket,
|
||||
&key,
|
||||
&info.user_defined,
|
||||
info.user_tags.as_ref().clone(),
|
||||
opts.delete_marker_replication_status(),
|
||||
opts.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => ReplicateDecision::new(),
|
||||
};
|
||||
|
||||
let mut eval_metadata = parse_object_lock_retention(retention)?;
|
||||
if dsc.replicate_any() {
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_GENERATION, Uuid::new_v4().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default());
|
||||
}
|
||||
opts.eval_metadata = Some(eval_metadata);
|
||||
|
||||
let object_info = store.put_object_metadata(&bucket, &key, &opts).await.map_err(|e| {
|
||||
error!("put_object_metadata failed, {}", e.to_string());
|
||||
S3Error::from(ApiError::from(e))
|
||||
})?;
|
||||
|
||||
// The current target transport carries the updated metadata in a full-object PUT.
|
||||
if dsc.replicate_any() {
|
||||
schedule_metadata_replication(object_info.clone(), store.clone(), dsc).await;
|
||||
}
|
||||
|
||||
let output = PutObjectRetentionOutput {
|
||||
request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)),
|
||||
};
|
||||
|
||||
let version_id = req.input.version_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
helper = helper.object(object_info).version_id(version_id);
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
#[instrument(name = "put_object_tagging", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_object_tagging_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectTaggingInput>,
|
||||
) -> S3Result<S3Response<PutObjectTaggingOutput>> {
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut helper = OperationHelper::new(&req, EventName::ObjectTaggingPut, S3Operation::PutObjectTagging);
|
||||
let PutObjectTaggingInput {
|
||||
bucket,
|
||||
key: object,
|
||||
tagging,
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &object).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &object).await?;
|
||||
|
||||
crate::storage::s3_api::tagging::validate_object_tag_set(&tagging.tag_set)?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let tags = encode_tags(tagging.tag_set.clone());
|
||||
debug!("Encoded tags: {}", tags);
|
||||
|
||||
let version_id = req.input.version_id.clone();
|
||||
let mut opts = get_opts(&bucket, &object, version_id.clone(), None, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
let existing_object_info = match store.get_object_info(&bucket, &object, &opts).await {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
// Replication lag window: apply the tagging update on a
|
||||
// replication target that already has the object
|
||||
// (backlog#1675 P1-5). No local object exists, so no bucket
|
||||
// notification event is emitted for the proxied write.
|
||||
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
|
||||
&& replication_proxy_allowed(&req.extensions)
|
||||
&& Self::proxy_put_object_tagging(&bucket, &object, version_id.clone(), &req.headers, &tagging.tag_set)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
counter!("rustfs_put_object_tagging_success").increment(1);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "put")
|
||||
.record(duration.as_secs_f64());
|
||||
return Ok(S3Response::new(PutObjectTaggingOutput {
|
||||
version_id: req.input.version_id.clone(),
|
||||
}));
|
||||
}
|
||||
return Err(ApiError::from(e).into());
|
||||
}
|
||||
};
|
||||
let dsc = must_replicate_metadata(
|
||||
&bucket,
|
||||
&object,
|
||||
&existing_object_info.user_defined,
|
||||
tags.clone(),
|
||||
existing_object_info.replication_status.clone(),
|
||||
opts.clone(),
|
||||
)
|
||||
.await;
|
||||
if dsc.replicate_any() {
|
||||
let mut eval_metadata = HashMap::new();
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_GENERATION, Uuid::new_v4().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
|
||||
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default());
|
||||
insert_str(
|
||||
&mut eval_metadata,
|
||||
SUFFIX_TAGGING_TIMESTAMP,
|
||||
OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_default(),
|
||||
);
|
||||
opts.eval_metadata = Some(eval_metadata);
|
||||
}
|
||||
|
||||
let put_tags_result = store.put_object_tags(&bucket, &object, &tags, &opts).await;
|
||||
let object_info = put_tags_result.map_err(|e| {
|
||||
error!("Failed to put object tags: {}", e);
|
||||
counter!("rustfs_put_object_tagging_failure").increment(1);
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
let event_object_info = Some(object_info.clone());
|
||||
if dsc.replicate_any() {
|
||||
schedule_metadata_replication(object_info, store.clone(), dsc).await;
|
||||
}
|
||||
|
||||
counter!("rustfs_put_object_tagging_success").increment(1);
|
||||
|
||||
let event_version_id = req
|
||||
.input
|
||||
.version_id
|
||||
.as_deref()
|
||||
.filter(|version_id| !version_id.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
event_object_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.version_id.map(|version_id| version_id.to_string()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(event_object_info) = event_object_info {
|
||||
helper = helper.object(event_object_info);
|
||||
}
|
||||
helper = helper.version_id(event_version_id);
|
||||
|
||||
let result = Ok(S3Response::new(PutObjectTaggingOutput {
|
||||
version_id: req.input.version_id.clone(),
|
||||
}));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&object,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "put").record(duration.as_secs_f64());
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of CopyObject, UploadPartCopy, CompleteMultipartUpload, DeleteObject,
|
||||
//! DeleteObjects, RestoreObject and SelectObjectContent: batch E of the gateway migration
|
||||
//! (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "complete_multipart_upload", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn complete_multipart_upload_edge(
|
||||
&self,
|
||||
req: S3Request<CompleteMultipartUploadInput>,
|
||||
) -> S3Result<S3Response<CompleteMultipartUploadOutput>> {
|
||||
crate::hp_guard!("S3::complete_multipart_upload");
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
Box::pin(usecase.execute_complete_multipart_upload(req)).await
|
||||
}
|
||||
|
||||
/// Copy an object from one location to another
|
||||
#[instrument(name = "copy_object", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn copy_object_edge(
|
||||
&self,
|
||||
req: S3Request<CopyObjectInput>,
|
||||
) -> S3Result<S3Response<CopyObjectOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_copy_object(req).await
|
||||
}
|
||||
|
||||
/// Delete an object
|
||||
#[instrument(name = "delete_object", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_object_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteObjectInput>,
|
||||
) -> S3Result<S3Response<DeleteObjectOutput>> {
|
||||
crate::hp_guard!("S3::delete_object");
|
||||
let request_id = req
|
||||
.extensions
|
||||
.get::<crate::storage::request_context::RequestContext>()
|
||||
.map(|ctx| ctx.request_id.clone());
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
let result = Box::pin(usecase.execute_delete_object(req)).await;
|
||||
if let Err(error) = &result {
|
||||
log_object_internal_error("DeleteObject", request_id.as_deref(), error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Delete multiple objects
|
||||
#[instrument(name = "delete_objects", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn delete_objects_edge(
|
||||
&self,
|
||||
req: S3Request<DeleteObjectsInput>,
|
||||
) -> S3Result<S3Response<DeleteObjectsOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_delete_objects(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn restore_object_edge(
|
||||
&self,
|
||||
req: S3Request<RestoreObjectInput>,
|
||||
) -> S3Result<S3Response<RestoreObjectOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_restore_object(req).await
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn select_object_content_edge(
|
||||
&self,
|
||||
req: S3Request<SelectObjectContentInput>,
|
||||
) -> S3Result<S3Response<SelectObjectContentOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_select_object_content(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "upload_part_copy", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn upload_part_copy_edge(
|
||||
&self,
|
||||
req: S3Request<UploadPartCopyInput>,
|
||||
) -> S3Result<S3Response<UploadPartCopyOutput>> {
|
||||
record_s3_op(S3Operation::UploadPartCopy);
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::UploadPartCopy, |req| Box::pin(usecase.execute_upload_part_copy(req))).await
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of GetObject, HeadObject and GetObjectAttributes: batch C of the gateway
|
||||
//! migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::s3_api;
|
||||
use tracing::instrument;
|
||||
|
||||
impl FS {
|
||||
/// Get bucket notification
|
||||
#[instrument(
|
||||
name = "get_object",
|
||||
level = "debug",
|
||||
skip(self, req),
|
||||
fields(start_time=?time::OffsetDateTime::now_utc())
|
||||
)]
|
||||
pub(in crate::storage::ecfs) async fn get_object_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectInput>,
|
||||
) -> S3Result<S3Response<GetObjectOutput>> {
|
||||
crate::hp_guard!("S3::get_object");
|
||||
let request_id = req
|
||||
.extensions
|
||||
.get::<crate::storage::request_context::RequestContext>()
|
||||
.map(|ctx| ctx.request_id.clone());
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
let result = usecase.execute_get_object(req).await;
|
||||
if let Err(error) = &result {
|
||||
log_object_internal_error("GetObject", request_id.as_deref(), error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::storage::ecfs) async fn get_object_attributes_edge(
|
||||
&self,
|
||||
req: S3Request<GetObjectAttributesInput>,
|
||||
) -> S3Result<S3Response<GetObjectAttributesOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_get_object_attributes(req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "head_object", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn head_object_edge(
|
||||
&self,
|
||||
req: S3Request<HeadObjectInput>,
|
||||
) -> S3Result<S3Response<HeadObjectOutput>> {
|
||||
crate::hp_guard!("S3::head_object");
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_head_object(req).await
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Legacy s3s edge of PutObject and UploadPart (PostObject has no trait method of its own and
|
||||
//! reaches PutObject): batch D of the gateway migration (rustfs/backlog#2765).
|
||||
//!
|
||||
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
|
||||
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
|
||||
//! owns this file and is the only one that edits it.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use crate::storage::helper::audit_only_operation;
|
||||
use crate::storage::s3_api;
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use tracing::instrument;
|
||||
|
||||
impl FS {
|
||||
#[instrument(name = "put_object", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn put_object_edge(
|
||||
&self,
|
||||
req: S3Request<PutObjectInput>,
|
||||
) -> S3Result<S3Response<PutObjectOutput>> {
|
||||
crate::hp_guard!("S3::put_object");
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_put_object(self, req).await
|
||||
}
|
||||
|
||||
#[instrument(name = "upload_part", level = "debug", skip(self, req))]
|
||||
pub(in crate::storage::ecfs) async fn upload_part_edge(
|
||||
&self,
|
||||
req: S3Request<UploadPartInput>,
|
||||
) -> S3Result<S3Response<UploadPartOutput>> {
|
||||
crate::hp_guard!("S3::upload_part");
|
||||
record_s3_op(S3Operation::UploadPart);
|
||||
let usecase = s3_api::multipart_usecase_for(self);
|
||||
audit_only_operation(req, S3Operation::UploadPart, |req| usecase.execute_upload_part(req)).await
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Tests of the `bucket_access` legacy-edge handlers (batch A2), moved unchanged from `ecfs.rs` and
|
||||
//! `ecfs_test.rs`.
|
||||
|
||||
use super::{build_request, store_uninitialized_premise_holds};
|
||||
use crate::storage::ecfs::*;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_bucket_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetBucketAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_bucket_acl(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_bucket_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutBucketAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_bucket_acl(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Tests of the `bucket_replication_lifecycle` legacy-edge handlers (batch A3), moved unchanged
|
||||
//! from `ecfs.rs` and `ecfs_test.rs`.
|
||||
|
||||
use super::super::bucket_replication_lifecycle::{
|
||||
SITE_REPLICATION_GATE_FORCE_DISABLED, SITE_REPLICATION_GATE_FORCE_ENABLED, SITE_REPLICATION_GATE_TEST_OVERRIDE,
|
||||
};
|
||||
use super::{build_request, store_uninitialized_premise_holds};
|
||||
use crate::storage::access::ReqInfo;
|
||||
use crate::storage::ecfs::*;
|
||||
use http::Method;
|
||||
use http::StatusCode;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn replication_config_edit_request<T>(input: T, is_owner: bool) -> S3Request<T> {
|
||||
let mut req = S3Request {
|
||||
input,
|
||||
method: Method::PUT,
|
||||
uri: http::Uri::from_static("/"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
req.extensions.insert(ReqInfo {
|
||||
is_owner,
|
||||
..Default::default()
|
||||
});
|
||||
req
|
||||
}
|
||||
|
||||
fn put_bucket_replication_input() -> PutBucketReplicationInput {
|
||||
PutBucketReplicationInput {
|
||||
bucket: "test-bucket".to_string(),
|
||||
checksum_algorithm: None,
|
||||
content_md5: None,
|
||||
expected_bucket_owner: None,
|
||||
replication_configuration: ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: Vec::new(),
|
||||
},
|
||||
token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_bucket_replication_input() -> DeleteBucketReplicationInput {
|
||||
DeleteBucketReplicationInput {
|
||||
bucket: "test-bucket".to_string(),
|
||||
expected_bucket_owner: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_replication_deny_edit(err: &S3Error) {
|
||||
match err.code() {
|
||||
S3ErrorCode::Custom(code) => assert_eq!(code, "XMinioReplicationDenyEdit"),
|
||||
other => panic!("expected XMinioReplicationDenyEdit, got {other:?}"),
|
||||
}
|
||||
assert_eq!(err.status_code(), Some(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
/// Single test on purpose: the branches share the process-wide gate
|
||||
/// override, and parallel tests would race it.
|
||||
#[tokio::test]
|
||||
async fn replication_config_edit_gate_denies_only_non_owner_under_site_replication() {
|
||||
let fs = FS::new();
|
||||
SITE_REPLICATION_GATE_TEST_OVERRIDE.store(SITE_REPLICATION_GATE_FORCE_ENABLED, Ordering::SeqCst);
|
||||
|
||||
// Non-owner PUT/DELETE through the real S3 handlers: denied by the
|
||||
// gate before the usecase (and thus the store) is ever touched.
|
||||
let err = fs
|
||||
.put_bucket_replication(replication_config_edit_request(put_bucket_replication_input(), false))
|
||||
.await
|
||||
.expect_err("non-owner PutBucketReplication must be denied while site replication is enabled");
|
||||
assert_replication_deny_edit(&err);
|
||||
let err = fs
|
||||
.delete_bucket_replication(replication_config_edit_request(delete_bucket_replication_input(), false))
|
||||
.await
|
||||
.expect_err("non-owner DeleteBucketReplication must be denied while site replication is enabled");
|
||||
assert_replication_deny_edit(&err);
|
||||
|
||||
// Owner passes the gate (the usecase's empty-rules structure error
|
||||
// proves the request reached the usecase instead of the deny path).
|
||||
let err = fs
|
||||
.put_bucket_replication(replication_config_edit_request(put_bucket_replication_input(), true))
|
||||
.await
|
||||
.expect_err("owner request should pass the gate and fail later on config validation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
|
||||
// Without site replication the policy check alone still governs the edit.
|
||||
SITE_REPLICATION_GATE_TEST_OVERRIDE.store(SITE_REPLICATION_GATE_FORCE_DISABLED, Ordering::SeqCst);
|
||||
let err = fs
|
||||
.put_bucket_replication(replication_config_edit_request(put_bucket_replication_input(), false))
|
||||
.await
|
||||
.expect_err("non-owner request should pass the gate and fail later on config validation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
|
||||
SITE_REPLICATION_GATE_TEST_OVERRIDE.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_lock_configuration_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectLockConfigurationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.object_lock_configuration(Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs
|
||||
.put_object_lock_configuration(build_request(input, Method::PUT))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Tests of the `listing_multipart` legacy-edge handlers (batch B2), moved unchanged from `ecfs.rs`
|
||||
//! and `ecfs_test.rs`.
|
||||
|
||||
use super::build_request;
|
||||
use crate::storage::StorageError;
|
||||
use crate::storage::ecfs::*;
|
||||
use http::{Method, StatusCode};
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn list_apis_report_storage_read_quorum_as_unavailable() {
|
||||
crate::app::gating_test_env::run_large_stack_test("list-quorum-surface", || async {
|
||||
use crate::storage::storage_api::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("list-quorum-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create listing bucket");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let saved_disks = set.disks.read().await.clone();
|
||||
{
|
||||
let mut disks = set.disks.write().await;
|
||||
for disk in disks.iter_mut().skip(1) {
|
||||
*disk = None;
|
||||
}
|
||||
}
|
||||
let fs = FS::new();
|
||||
let errors = [
|
||||
S3::list_objects(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectsInput {
|
||||
bucket: bucket.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("V1 cannot list below storage read quorum"),
|
||||
S3::list_objects_v2(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectsV2Input {
|
||||
bucket: bucket.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("V2 cannot list below storage read quorum"),
|
||||
S3::list_object_versions(
|
||||
&fs,
|
||||
build_request(
|
||||
ListObjectVersionsInput {
|
||||
bucket,
|
||||
..Default::default()
|
||||
},
|
||||
Method::GET,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("versions cannot list below storage read quorum"),
|
||||
];
|
||||
*set.disks.write().await = saved_disks;
|
||||
for error in errors {
|
||||
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
|
||||
assert_eq!(error.message(), Some("The service is unavailable. Please retry."));
|
||||
let source = error.source().expect("retain storage diagnostics");
|
||||
assert!(
|
||||
matches!(
|
||||
source.downcast_ref::<StorageError>(),
|
||||
Some(StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _))
|
||||
),
|
||||
"the production LIST boundary must expose a typed read-quorum failure: {source}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Tests of the legacy-edge handlers, one module per migration batch, and the fixtures they
|
||||
//! share.
|
||||
|
||||
use crate::storage::ecfs::*;
|
||||
use http::{Extensions, HeaderMap, Method, Uri};
|
||||
|
||||
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Premise guard for the `*_returns_internal_error_when_store_uninitialized`
|
||||
/// tests (backlog#1830): they assert the error path taken while the global
|
||||
/// object layer is absent. Under nextest — the authoritative runner — every
|
||||
/// test owns its process, so the premise always holds and the assertion
|
||||
/// always runs. Under the documented shared-process `cargo test` fallback a
|
||||
/// sibling test may have initialized the store first; the premise is then
|
||||
/// unattainable, so the test skips instead of asserting against a scenario
|
||||
/// it does not describe.
|
||||
fn store_uninitialized_premise_holds() -> bool {
|
||||
if crate::runtime_sources::current_object_store_handle().is_some() {
|
||||
eprintln!("skipping store-uninitialized assertion: a sibling test already initialized the global object layer");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
mod bucket_access;
|
||||
mod bucket_replication_lifecycle;
|
||||
mod listing_multipart;
|
||||
mod object_meta;
|
||||
@@ -1,208 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Tests of the `object_meta` legacy-edge handlers (batch B1), moved unchanged from `ecfs.rs` and
|
||||
//! `ecfs_test.rs`.
|
||||
|
||||
use super::{build_request, store_uninitialized_premise_holds};
|
||||
use crate::storage::ecfs::*;
|
||||
use http::Method;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_acl(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_legal_hold_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectLegalHoldInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_legal_hold(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_retention_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetObjectRetentionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_retention(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_legal_hold_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectLegalHoldInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_legal_hold(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_acl_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectAclInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_acl(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_retention_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutObjectRetentionInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_retention(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = GetObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.get_object_tagging(build_request(input, Method::GET)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_rejects_too_many_tags() {
|
||||
let tag_set = (0..11)
|
||||
.map(|index| Tag {
|
||||
key: Some(format!("k{index}")),
|
||||
value: Some(format!("v{index}")),
|
||||
})
|
||||
.collect();
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging { tag_set })
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
|
||||
assert!(err.to_string().contains("Cannot have more than 10 tags per object"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_rejects_empty_tag_key_before_store_lookup() {
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging {
|
||||
tag_set: vec![Tag {
|
||||
key: Some(String::new()),
|
||||
value: Some("v1".to_string()),
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
|
||||
assert!(err.to_string().contains("Tag key cannot be empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = PutObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.tagging(Tagging {
|
||||
tag_set: vec![Tag {
|
||||
key: Some("k".to_string()),
|
||||
value: Some("v".to_string()),
|
||||
}],
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_object_tagging_returns_internal_error_when_store_uninitialized() {
|
||||
if !store_uninitialized_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let input = DeleteObjectTaggingInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.key("test-key".to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let fs = FS::new();
|
||||
let err = fs
|
||||
.delete_object_tagging(build_request(input, Method::DELETE))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
+1
-1
@@ -168,7 +168,7 @@ their issue closes.
|
||||
|
||||
| Entry | Status | Purpose | Wiring / docs |
|
||||
|---|---|---|---|
|
||||
| `codemods/` | dev-tool | Scripted, idempotent source migrations for the gateway migration (rustfs/backlog#2734); `s3_error_to_s3_types.py` moves s3s error-type paths onto `rustfs-s3-types` per crate closure and refuses the s3s edge allowlist; `split_bucket_usecase.py` splits `rustfs/src/app/bucket_usecase.rs` into one file per configuration family (preparatory refactor P2, rustfs/backlog#2765) and stops on any item its tables do not assign | `make script-tests` (`--self-test`); usage in each script's docstring |
|
||||
| `codemods/` | dev-tool | Scripted, idempotent source migrations for the gateway migration (rustfs/backlog#2734); `s3_error_to_s3_types.py` moves s3s error-type paths onto `rustfs-s3-types` per crate closure and refuses the s3s edge allowlist | `make script-tests` (`--self-test`); usage in each script's docstring |
|
||||
| `fuzz/` | ci-gate | Unified cargo-fuzz runner and helpers for the `fuzz/` sub-workspace | fuzz.yml; `fuzz/README.md` |
|
||||
| `release/` | ci-gate | Release creation and DEB/RPM version-normalization helpers | build.yml; package.yml; manual-main-packages.yml |
|
||||
| `s3-tests/` | ci-gate | ceph/s3-tests compatibility harness (allow-lists, patches, report tooling) | ci.yml; e2e-s3tests.yml; `scripts/s3-tests/README.md` |
|
||||
|
||||
+46
-256
@@ -185,122 +185,57 @@ def _matching(text: str, mask: bytearray, open_pos: int) -> int:
|
||||
raise InputError(f"unbalanced brace at offset {open_pos}")
|
||||
|
||||
|
||||
def module_attributes(code: str, offset: int) -> list[tuple[int, int]]:
|
||||
"""Attribute spans immediately preceding a declaration, using masked code."""
|
||||
attributes = []
|
||||
end = offset
|
||||
while True:
|
||||
end = len(code[:end].rstrip())
|
||||
if end == 0 or code[end - 1] != "]":
|
||||
return attributes
|
||||
depth, start = 1, end - 2
|
||||
while start >= 0 and depth:
|
||||
depth += code[start] == "]"
|
||||
depth -= code[start] == "["
|
||||
start -= 1
|
||||
if depth:
|
||||
raise InputError("unbalanced module attribute")
|
||||
start = len(code[:start + 1].rstrip()) - 1
|
||||
if start < 0 or code[start] != "#":
|
||||
return attributes
|
||||
attributes.append((start, end))
|
||||
end = start
|
||||
def module_declaration(root: Path, path: str) -> tuple[list[str], str] | None:
|
||||
"""(candidate parent files, module name) for a file below src/, or None for a crate root."""
|
||||
parts = Path(path)
|
||||
if parts.name in ("lib.rs", "main.rs") and parts.parent.name == "src":
|
||||
return None
|
||||
if parts.name == "mod.rs":
|
||||
name, directory = parts.parent.name, parts.parent.parent
|
||||
else:
|
||||
name, directory = parts.stem, parts.parent
|
||||
if directory.name == "bin" and parts.name != "mod.rs":
|
||||
return None
|
||||
candidates = [directory.with_suffix(".rs"), directory / "mod.rs"]
|
||||
if directory.name == "src":
|
||||
candidates = [directory / "lib.rs", directory / "main.rs"]
|
||||
return [str(parent) for parent in candidates if (root / parent).is_file()], name
|
||||
|
||||
|
||||
class ModuleDeclarations:
|
||||
"""Resolve file-level external modules; inline and macro scopes are not inferred."""
|
||||
|
||||
def __init__(self, root: Path, files: list[str]):
|
||||
self.root = root.resolve()
|
||||
self.sources: dict[str, tuple[str, bytearray]] = {}
|
||||
self.parents: dict[str, list[tuple[str, int]]] = {}
|
||||
pending: list[tuple[str, int, str, list[str]]] = []
|
||||
declaration = re.compile(r"\b(?:pub(?:\([^()\n]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z_0-9]*)\s*;")
|
||||
for parent in files:
|
||||
text = (root / parent).read_text()
|
||||
mask = code_mask(text)
|
||||
self.sources[parent] = (text, mask)
|
||||
code = "".join(ch if mask[i] or ch == "\n" else " " for i, ch in enumerate(text))
|
||||
depth, previous = 0, 0
|
||||
for match in declaration.finditer(code):
|
||||
for ch in code[previous:match.start()]:
|
||||
depth += ch in "([{"
|
||||
depth -= ch in ")]}"
|
||||
previous = match.start()
|
||||
if depth != 0:
|
||||
continue
|
||||
paths = []
|
||||
for start, end in module_attributes(code, match.start()):
|
||||
attribute = code[start:end]
|
||||
if re.match(r"#\s*\[\s*path\b", attribute):
|
||||
literal = re.fullmatch(r'#\s*\[\s*path\s*=\s*"([^"\\]*)"\s*\]', text[start:end])
|
||||
if literal is None:
|
||||
raise InputError(f"{parent}: unsupported module path attribute")
|
||||
paths.append(literal.group(1))
|
||||
elif re.search(r"\bpath\s*=", attribute):
|
||||
raise InputError(f"{parent}: conditional module path attributes are unsupported")
|
||||
if len(paths) > 1:
|
||||
raise InputError(f"{parent}: duplicate module path attributes")
|
||||
pending.append((parent, match.start(), match.group(1), paths))
|
||||
# rustc treats every #[path]-included file like mod.rs for its children,
|
||||
# regardless of the source filename or the declared module name.
|
||||
attributed_files = {(root / Path(parent).parent / paths[0]).resolve()
|
||||
for parent, _, _, paths in pending if paths}
|
||||
for parent, offset, name, paths in pending:
|
||||
file = Path(parent)
|
||||
if paths:
|
||||
targets = [file.parent / paths[0]]
|
||||
else:
|
||||
crate_root = file.parent.name == "src" and file.name in ("lib.rs", "main.rs") or file.parent.name == "bin"
|
||||
mod_rs = file.name == "mod.rs" or crate_root or (root / file).resolve() in attributed_files
|
||||
directory = file.parent if mod_rs else file.with_suffix("")
|
||||
targets = [directory / f"{name}.rs", directory / name / "mod.rs"]
|
||||
if not paths and sum((root / target).is_file() for target in targets) > 1:
|
||||
raise InputError(f"{parent}: ambiguous module files for {name}")
|
||||
for target in targets:
|
||||
resolved = (root / target).resolve()
|
||||
if not resolved.is_relative_to(self.root):
|
||||
raise InputError(f"{parent}: module path escapes the repository: {target}")
|
||||
if not resolved.is_file():
|
||||
if paths:
|
||||
raise InputError(f"{parent}: module path is missing: {target}")
|
||||
continue
|
||||
relative = str(resolved.relative_to(self.root))
|
||||
self.parents.setdefault(relative, []).append((parent, offset))
|
||||
|
||||
|
||||
def is_test_only_file(root: Path, path: str, cache: dict[str, bool], modules: ModuleDeclarations, visiting: set[str]) -> bool:
|
||||
def is_test_only_file(root: Path, path: str, cache: dict[str, bool]) -> bool:
|
||||
if path in cache:
|
||||
return cache[path]
|
||||
if path in visiting:
|
||||
raise InputError(f"{path}: cycle in module declarations")
|
||||
visiting.add(path)
|
||||
try:
|
||||
parts = Path(path).parts
|
||||
if len(parts) > 1 and parts[1] in ("tests", "benches"):
|
||||
cache[path] = True
|
||||
return True
|
||||
text, mask = modules.sources[path]
|
||||
if any(text[m.start() + 1] == "!" for m in CFG_TEST.finditer(text) if mask[m.start()]):
|
||||
cache[path] = True
|
||||
return True
|
||||
file = Path(path)
|
||||
if file.name in ("lib.rs", "main.rs") and file.parent.name == "src" or file.parent.name == "bin":
|
||||
parts = Path(path).parts
|
||||
if len(parts) > 1 and parts[1] in ("tests", "benches"):
|
||||
cache[path] = True
|
||||
return True
|
||||
text = (root / path).read_text()
|
||||
mask = code_mask(text)
|
||||
if any(text[m.start() + 1] == "!" for m in CFG_TEST.finditer(text) if mask[m.start()]):
|
||||
cache[path] = True
|
||||
return True
|
||||
declaration = module_declaration(root, path)
|
||||
if declaration is None:
|
||||
if Path(path).name in ("lib.rs", "main.rs") or Path(path).parent.name == "bin":
|
||||
cache[path] = False
|
||||
return False
|
||||
declarations = modules.parents.get(path, [])
|
||||
if len(declarations) != 1:
|
||||
name = file.parent.name if file.name == "mod.rs" else file.stem
|
||||
raise InputError(f"{path}: expected one `mod {name};` declaration resolving to this file, found {len(declarations)}")
|
||||
parent, offset = declarations[0]
|
||||
parent_text, parent_mask = modules.sources[parent]
|
||||
cfg_test = any(start <= offset < end for start, end in test_regions(parent_text, parent_mask))
|
||||
# Validate the parent chain even when this declaration is test-only.
|
||||
parent_test = is_test_only_file(root, parent, cache, modules, visiting)
|
||||
cache[path] = cfg_test or parent_test
|
||||
return cache[path]
|
||||
finally:
|
||||
visiting.remove(path)
|
||||
raise InputError(f"{path}: cannot find the file that declares this module")
|
||||
parents, name = declaration
|
||||
declarations = []
|
||||
for parent in parents:
|
||||
parent_text = (root / parent).read_text()
|
||||
parent_mask = code_mask(parent_text)
|
||||
pattern = rf"(?m)^[ \t]*(?:pub(?:\([^)\n]*\))?[ \t]+)?mod[ \t]+{re.escape(name)}[ \t]*;"
|
||||
for m in re.finditer(pattern, parent_text):
|
||||
if parent_mask[m.start() + len(m.group()) - len(m.group().lstrip())]:
|
||||
declarations.append((parent, parent_text, parent_mask, m.start()))
|
||||
if len(declarations) != 1:
|
||||
raise InputError(f"{path}: expected one `mod {name};` in {' or '.join(parents) or 'a parent file'}, found {len(declarations)}")
|
||||
parent, parent_text, parent_mask, offset = declarations[0]
|
||||
cfg_test = any(start <= offset < end for start, end in test_regions(parent_text, parent_mask))
|
||||
result = cfg_test or is_test_only_file(root, parent, cache)
|
||||
cache[path] = result
|
||||
return result
|
||||
|
||||
|
||||
def rust_files(root: Path) -> list[str]:
|
||||
@@ -336,14 +271,13 @@ def check(root: Path, files: list[str] | None = None) -> list[str]:
|
||||
errors.append(f"{owner}: owner file of {method} is missing; update SEAMS in {Path(__file__).name}")
|
||||
files = rust_files(root) if files is None else files
|
||||
cache: dict[str, bool] = {}
|
||||
modules = ModuleDeclarations(root, files)
|
||||
for path in files:
|
||||
text = (root / path).read_text()
|
||||
mask = code_mask(text)
|
||||
calls = [m for m in CALL.finditer(text) if mask[m.start()]]
|
||||
if not calls:
|
||||
continue
|
||||
whole_file = is_test_only_file(root, path, cache, modules, set())
|
||||
whole_file = is_test_only_file(root, path, cache)
|
||||
regions = test_regions(text, mask)
|
||||
for match in calls:
|
||||
method = match.group(1)
|
||||
@@ -499,150 +433,6 @@ class SelfTest(unittest.TestCase):
|
||||
self.write("rustfs/src/app/object/put.rs", "fn prod() {}\n#[cfg(test)]\nmod tests {\n fn t() { u.execute_put_object(&fs, r); }\n}\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def path_module(self, attributes: str = "", body: str = "fn prod() { u.execute_get_object(r); }\n"):
|
||||
self.write("rustfs/src/app/storage.rs", attributes + '#[path = "moved/mod.rs"]\nmod relocated;\n')
|
||||
self.write("rustfs/src/app/moved/mod.rs", body)
|
||||
|
||||
def test_path_module_from_non_mod_file_passes(self):
|
||||
self.path_module()
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_path_module_from_mod_file_passes(self):
|
||||
self.write("rustfs/src/app/mod.rs", self.files["rustfs/src/app/mod.rs"] + '#[path = "elsewhere.rs"]\nmod renamed;\n')
|
||||
self.write("rustfs/src/app/elsewhere.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_path_module_child_uses_its_file_directory(self):
|
||||
self.path_module(body="mod child;\n")
|
||||
self.write("rustfs/src/app/moved/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_non_mod_file_default_child_uses_module_directory(self):
|
||||
self.write("rustfs/src/app/storage.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/storage/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_attributed_non_mod_file_child_is_a_sibling(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[path = "other.rs"]\nmod renamed;\n')
|
||||
self.write("rustfs/src/app/other.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_attributed_non_mod_file_child_does_not_use_stem_directory(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[path = "other.rs"]\nmod renamed;\n')
|
||||
self.write("rustfs/src/app/other.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/other/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
with self.assertRaisesRegex(InputError, "expected one `mod child;`.*found 0"):
|
||||
self.run_check()
|
||||
|
||||
def test_attributed_non_mod_file_test_ancestor_is_preserved(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg(test)]\n#[path = "other.rs"]\nmod renamed;\n')
|
||||
self.write("rustfs/src/app/other.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/child.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
self.assertRegex(self.run_check()[0], r"app/child.rs:1: test calls execute_get_object directly")
|
||||
|
||||
def test_two_conventional_module_files_are_ambiguous(self):
|
||||
self.write("rustfs/src/app/storage.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/storage/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.write("rustfs/src/app/storage/child/mod.rs", "fn prod() {}\n")
|
||||
with self.assertRaisesRegex(InputError, "ambiguous module files for child"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_cfg_test_still_rejects_direct_call(self):
|
||||
self.path_module("#[cfg(test)]\n")
|
||||
self.assertRegex(self.run_check()[0], r"moved/mod.rs:1: test calls execute_get_object directly")
|
||||
|
||||
def test_path_module_test_ancestor_still_rejects_direct_call(self):
|
||||
self.path_module(body="mod child;\n")
|
||||
self.write("rustfs/src/app/mod.rs", self.files["rustfs/src/app/mod.rs"].replace("mod storage;", "#[cfg(test)]\nmod storage;"))
|
||||
self.write("rustfs/src/app/moved/child.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
self.assertRegex(self.run_check()[0], r"moved/child.rs:1: test calls execute_get_object directly")
|
||||
|
||||
def test_path_module_test_using_seam_passes(self):
|
||||
self.path_module("#[cfg(test)]\n", "fn t() { crate::app::test_seams::get_object(&u, r); }\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_path_module_missing_target_fails(self):
|
||||
self.path_module()
|
||||
(self.root / "rustfs/src/app/moved/mod.rs").unlink()
|
||||
del self.files["rustfs/src/app/moved/mod.rs"]
|
||||
with self.assertRaisesRegex(InputError, "module path is missing"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_duplicate_declarations_fail(self):
|
||||
self.path_module()
|
||||
self.write("rustfs/src/app/storage.rs", self.files["rustfs/src/app/storage.rs"] + '#[path = "moved/mod.rs"]\nmod second;\n')
|
||||
with self.assertRaisesRegex(InputError, "found 2"):
|
||||
self.run_check()
|
||||
|
||||
def test_conventional_and_explicit_owners_are_ambiguous(self):
|
||||
self.write("rustfs/src/app/storage.rs", "mod child;\n")
|
||||
self.write("rustfs/src/app/storage/child.rs", "fn prod() { u.execute_get_object(r); }\n")
|
||||
self.write("rustfs/src/app/mod.rs", self.files["rustfs/src/app/mod.rs"] + '#[path = "storage/child.rs"]\nmod alias;\n')
|
||||
with self.assertRaisesRegex(InputError, "expected one `mod child;`.*found 2"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_normalized_target_and_same_line_attributes(self):
|
||||
self.path_module()
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg(test)] #[path = "./moved/../moved/mod.rs"] pub(crate) mod renamed;\n')
|
||||
self.assertRegex(self.run_check()[0], r"moved/mod.rs:1: test calls execute_get_object directly")
|
||||
|
||||
def test_path_module_does_not_declare_conventional_target(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg(test)]\n#[path = "actual.rs"]\nmod nominal;\n')
|
||||
self.write("rustfs/src/app/actual.rs", "fn actual() {}\n")
|
||||
self.write("rustfs/src/app/storage/nominal.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
with self.assertRaisesRegex(InputError, "expected one `mod nominal;`.*found 0"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_wrong_existing_target_leaves_file_undeclared(self):
|
||||
self.path_module()
|
||||
self.write("rustfs/src/app/elsewhere.rs", "fn elsewhere() {}\n")
|
||||
self.write("rustfs/src/app/storage.rs", '#[path = "elsewhere.rs"]\nmod relocated;\n')
|
||||
with self.assertRaisesRegex(InputError, "expected one `mod moved;`.*found 0"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_fake_declarations_are_ignored(self):
|
||||
self.path_module()
|
||||
self.write("rustfs/src/app/storage.rs", self.files["rustfs/src/app/storage.rs"] +
|
||||
'// #[path = "moved/mod.rs"] mod fake;\n'
|
||||
'/* #[path = "moved/mod.rs"] mod fake; */\n'
|
||||
'const S: &str = r#"#[path = "moved/mod.rs"] mod fake;"#;\n')
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_path_module_inline_and_macro_scopes_are_not_inferred(self):
|
||||
for wrapper in ("mod inline {\n%s}\n", "some_macro! {\n%s}\n"):
|
||||
with self.subTest(wrapper=wrapper):
|
||||
self.path_module()
|
||||
self.write("rustfs/src/app/storage.rs", wrapper % self.files["rustfs/src/app/storage.rs"])
|
||||
with self.assertRaisesRegex(InputError, "found 0"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_escape_is_rejected(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[path = "../../../../outside.rs"]\nmod escaped;\n')
|
||||
with self.assertRaisesRegex(InputError, "escapes the repository"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_unsupported_literal_is_rejected(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[path = "moved\\x2fmod.rs"]\nmod escaped;\n')
|
||||
with self.assertRaisesRegex(InputError, "unsupported module path"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_conditional_path_is_rejected(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg_attr(test, path = "moved.rs")]\nmod conditional;\n')
|
||||
with self.assertRaisesRegex(InputError, "conditional module path"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_duplicate_attributes_are_rejected(self):
|
||||
self.path_module('#[path = "moved/mod.rs"]\n')
|
||||
with self.assertRaisesRegex(InputError, "duplicate module path attributes"):
|
||||
self.run_check()
|
||||
|
||||
def test_path_module_parent_cycle_is_rejected(self):
|
||||
self.write("rustfs/src/app/a.rs", '#[path = "b.rs"]\nmod b;\nfn prod() { u.execute_get_object(r); }\n')
|
||||
self.write("rustfs/src/app/b.rs", '#[path = "a.rs"]\nmod a;\n')
|
||||
with self.assertRaisesRegex(InputError, "cycle in module declarations"):
|
||||
self.run_check()
|
||||
|
||||
def test_calls_in_comments_and_strings_are_ignored(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg(test)]\nmod tests {\n // u.execute_get_object(r)\n const S: &str = "u.execute_get_object(r)";\n}\n')
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
+59
-114
@@ -16,7 +16,7 @@ import unittest
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ALWAYS_JOBS = ("classify-changes", "typos", "quick-checks")
|
||||
CODE_JOBS = (
|
||||
"workspace-lint-and-build", "workspace-tests", "core-contract", "connect-feature-boundaries", "fips-provider", "test-and-lint-rio-v2",
|
||||
"workspace-clippy", "workspace-lint-and-build", "workspace-tests", "core-contract", "connect-feature-boundaries", "fips-provider", "test-and-lint-rio-v2",
|
||||
"test-and-lint-protocols", "build-rustfs-debug-binary", "uring-integration",
|
||||
"e2e-tests", "s3-implemented-tests", "s3-lifecycle-behavior-tests",
|
||||
)
|
||||
@@ -33,12 +33,12 @@ NIGHTLY_JOBS = ("connect-feature-boundaries", "fips-provider", "uring-integratio
|
||||
# against this revision's expected job contract. The workflow refuses a plan
|
||||
# that does not carry the current marker and falls back to the full matrix, so
|
||||
# a base planner predating the contract cannot authorize a narrower selection.
|
||||
PLAN_CONTRACT = "3"
|
||||
PLAN_CONTRACT = "4"
|
||||
# These jobs still exercise the server and its black-box harness when only E2E
|
||||
# Rust sources change. Production code, manifests and shared test configuration
|
||||
# always select the full matrix. No production package depends on e2e_test.
|
||||
E2E_JOBS = (
|
||||
"workspace-lint-and-build", "workspace-tests", "core-contract", "build-rustfs-debug-binary", "e2e-tests",
|
||||
"workspace-clippy", "workspace-lint-and-build", "workspace-tests", "core-contract", "build-rustfs-debug-binary", "e2e-tests",
|
||||
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
|
||||
)
|
||||
E2E_SELECTION_FILES = {
|
||||
@@ -47,15 +47,20 @@ E2E_SELECTION_FILES = {
|
||||
"e2e-distributed", "e2e-protocols", "e2e-odm-interop",
|
||||
)
|
||||
}
|
||||
# Clippy and the test-binary archive share no compiler output, so the lint runs
|
||||
# beside the archive producer instead of in front of it. LINT_JOB is the only
|
||||
# job that may lint the workspace; nothing downstream of ARCHIVE_PRODUCER may
|
||||
# wait for it, or the lint is back on the path every workspace test waits on.
|
||||
LINT_JOB = "workspace-clippy"
|
||||
ARCHIVE_PRODUCER = "workspace-lint-and-build"
|
||||
ARCHIVE_CONSUMERS = ("workspace-tests", "workspace-shards", "workspace-shard-evidence")
|
||||
LINT_COMMANDS = ("cargo clippy --all-targets -- -D warnings", "python3 scripts/ci_test_artifact.py cargo lint --configuration workspace")
|
||||
SHARD_JOBS = ("workspace-shards", "workspace-shard-evidence")
|
||||
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full", *SHARD_JOBS)
|
||||
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
|
||||
# Report-only lanes stay outside the aggregate until their milestone makes them
|
||||
# required. Each reruns a required job's suite with one environment change, so
|
||||
# the named steps must stay identical to that job's and the change must stay.
|
||||
# The last field names the `select` output that scopes the lane on pull
|
||||
# requests: it runs wherever its required job runs, except on a pull request
|
||||
# whose selection is the literal `false`. Every other event runs it.
|
||||
REPORT_ONLY_JOBS = {
|
||||
# rustfs/backlog#2734: required at T2.10.
|
||||
"e2e-tests-gateway": (
|
||||
@@ -68,34 +73,8 @@ REPORT_ONLY_JOBS = {
|
||||
"Verify e2e smoke execution completeness",
|
||||
),
|
||||
"RUSTFS_S3_STACK: gateway",
|
||||
"gateway_smoke",
|
||||
),
|
||||
}
|
||||
# Inputs that decide what the gateway S3 stack serves or how the lane starts
|
||||
# it (rustfs/backlog#2734). Until T2.10 the suite cannot pass on that stack,
|
||||
# so a pull request that changes none of these learns nothing from the lane
|
||||
# and is not shown its failure. The suite's own contents (crates/e2e_test, the
|
||||
# nextest profile, the smoke selection) are deliberately not inputs: feature
|
||||
# pull requests change them constantly, and what the suite contains carries no
|
||||
# signal while the stack cannot serve it. fnmatch's `*` also matches `/`. Some
|
||||
# of these paths exist only on branches that are still assembling the stack.
|
||||
#
|
||||
# At T2.10, when the lane becomes required, it must run on every pull request
|
||||
# again — delete this selection, do not extend it.
|
||||
GATEWAY_SMOKE_PATHS = (
|
||||
"crates/gateway-adapter/*",
|
||||
"rustfs/src/server/gateway_stack.rs", "rustfs/src/server/gateway_stack/*",
|
||||
"rustfs/src/server/gateway_handlers.rs", "rustfs/src/server/gateway_handlers/*",
|
||||
"rustfs/src/server/stack.rs", "rustfs/src/server/stack/*",
|
||||
"rustfs/src/config/config_struct.rs", # parses RUSTFS_S3_STACK
|
||||
"rustfs/Cargo.toml",
|
||||
".github/workflows/ci.yml", ".github/actions/test-runtime/*",
|
||||
"scripts/ci_gate.py", "scripts/e2e_binary.py",
|
||||
)
|
||||
# Shared manifests change in most dependency updates; only their gateway pins
|
||||
# are inputs of the lane.
|
||||
GATEWAY_PIN_FILES = ("Cargo.toml", "Cargo.lock")
|
||||
GATEWAY_PACKAGE_PREFIX = "rustfs-gateway"
|
||||
INTEGRATION_PUSH_REFS = ("refs/heads/main", "refs/heads/gateway/integration")
|
||||
|
||||
|
||||
@@ -179,40 +158,6 @@ def release_metadata_only(root: Path, base: str, head: str, path: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def gateway_pins(root: Path, revision: str, path: str) -> list:
|
||||
"""The entries of a shared manifest that pin a gateway crate, in file order."""
|
||||
document = tomllib.loads(git_file(root, revision, path))
|
||||
if path == "Cargo.lock":
|
||||
return [package for package in document.get("package", []) if str(package.get("name", "")).startswith(GATEWAY_PACKAGE_PREFIX)]
|
||||
tables = {"workspace.dependencies": document.get("workspace", {}).get("dependencies", {}), "replace": document.get("replace", {})}
|
||||
tables.update({f"patch.{source}": table for source, table in document.get("patch", {}).items()})
|
||||
return [(table, name, spec) for table, entries in tables.items() for name, spec in entries.items()
|
||||
if GATEWAY_PACKAGE_PREFIX in name or (isinstance(spec, dict) and str(spec.get("package", "")).startswith(GATEWAY_PACKAGE_PREFIX))]
|
||||
|
||||
|
||||
def gateway_smoke_selection(root: Path, event: str, base: str, head: str, changed: list[str]) -> dict:
|
||||
"""Decide whether the report-only gateway lane has anything to say about this run.
|
||||
|
||||
Only a pull request with a known change set that touches no gateway-stack
|
||||
input is skipped; an unreadable manifest or lockfile counts as touched.
|
||||
"""
|
||||
if event != "pull_request":
|
||||
return {"run": True, "reason": f"{event} validates the complete candidate"}
|
||||
if not changed or changed == [""]:
|
||||
return {"run": True, "reason": "change set unavailable"}
|
||||
for path in changed:
|
||||
if any(fnmatch.fnmatchcase(path, pattern) for pattern in GATEWAY_SMOKE_PATHS):
|
||||
return {"run": True, "reason": f"gateway-stack input changed: {path}"}
|
||||
if path in GATEWAY_PIN_FILES:
|
||||
try:
|
||||
same = gateway_pins(root, base, path) == gateway_pins(root, head, path)
|
||||
except (OSError, subprocess.CalledProcessError, UnicodeError, AttributeError, KeyError, TypeError, ValueError):
|
||||
same = False
|
||||
if not same:
|
||||
return {"run": True, "reason": f"gateway pin changed: {path}"}
|
||||
return {"run": False, "reason": "no gateway-stack input changed"}
|
||||
|
||||
|
||||
def dependency_tables(document: dict):
|
||||
for key, value in document.items():
|
||||
if key in ("dependencies", "dev-dependencies", "build-dependencies"):
|
||||
@@ -333,8 +278,8 @@ def read_policy(root: Path, revision: str) -> dict:
|
||||
|
||||
def configurations(jobs: dict[str, str]) -> list[dict]:
|
||||
values = [
|
||||
("workspace-lint", "workspace-lint-and-build", "dev", []),
|
||||
("workspace-tests", "workspace-lint-and-build", "test", []),
|
||||
("workspace-lint", LINT_JOB, "dev", []),
|
||||
("workspace-tests", ARCHIVE_PRODUCER, "test", []),
|
||||
("e2e-harness", "build-rustfs-debug-binary", "test", []),
|
||||
("uring-tests", "uring-integration", "test", []),
|
||||
("server-e2e-hooks", "build-rustfs-debug-binary", "dev", ["e2e-test-hooks"]),
|
||||
@@ -470,7 +415,6 @@ def select_plan(event: str, base: str, head: str, ref: str, root: Path, action:
|
||||
"head": head, "tree": tree, "policy": {"revision": policy_revision, "rollout": "shadow" if shadow else "enforced"},
|
||||
"mode": mode, "packages": effective_packages, "full_validation": effective_full, "connect": effective_connect,
|
||||
"rehearse_shards": rehearse_shards,
|
||||
"gateway_smoke": gateway_smoke_selection(root, event, base, head, changed),
|
||||
"configurations": configurations(jobs), "jobs": jobs, "proposed": proposed,
|
||||
"reasons": ["shadow rollout preserves current validation responsibility"] + reasons if shadow else reasons,
|
||||
"not_applicable": {job: f"not required for {mode}/{event}" for job, result in jobs.items() if result == "skipped"}}
|
||||
@@ -665,6 +609,49 @@ def condition(expression: str | bool, context: dict) -> bool:
|
||||
return bool(result)
|
||||
|
||||
|
||||
def job_needs(jobs: dict, job: str) -> list[str]:
|
||||
dependencies = jobs.get(job, {}).get("needs", [])
|
||||
return [dependencies] if isinstance(dependencies, str) else list(dependencies)
|
||||
|
||||
|
||||
def check_lint_lane(jobs: dict) -> list[str]:
|
||||
"""Keep workspace Clippy gating every code event without delaying the test archive."""
|
||||
errors = []
|
||||
lint_steps = {job: [step for step in block.get("steps", [])
|
||||
if any(command in step.get("run", "") for command in LINT_COMMANDS)]
|
||||
for job, block in jobs.items()}
|
||||
clippy = lint_steps.get(LINT_JOB, [])
|
||||
if len(clippy) != 1 or any("if" in step for step in clippy):
|
||||
errors.append("workspace Clippy must run on every code event, pull requests included")
|
||||
if any(steps for job, steps in lint_steps.items() if job != LINT_JOB):
|
||||
errors.append(f"workspace Clippy belongs to {LINT_JOB} alone")
|
||||
lint, producer = jobs.get(LINT_JOB, {}), jobs.get(ARCHIVE_PRODUCER, {})
|
||||
# The lint replaces a step of the producer; it must keep that job's scope.
|
||||
if not lint or lint.get("if") != producer.get("if"):
|
||||
errors.append(f"{LINT_JOB} must run under the same condition as {ARCHIVE_PRODUCER}")
|
||||
for job in (ARCHIVE_PRODUCER, *ARCHIVE_CONSUMERS):
|
||||
reached, pending = set(), [job]
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current not in reached:
|
||||
reached.add(current)
|
||||
pending.extend(job_needs(jobs, current))
|
||||
if LINT_JOB in reached:
|
||||
errors.append(f"{job} must not wait for {LINT_JOB}")
|
||||
if ARCHIVE_PRODUCER not in job_needs(jobs, "workspace-tests"):
|
||||
errors.append(f"workspace-tests must consume the archive of {ARCHIVE_PRODUCER}")
|
||||
return errors
|
||||
|
||||
|
||||
def check_plan_contract_marker(jobs: dict) -> list[str]:
|
||||
"""The workflow's fallback must test for the marker this revision's planner emits."""
|
||||
scopes = [step for step in jobs.get("classify-changes", {}).get("steps", []) if step.get("id") == "scope"]
|
||||
markers = re.findall(r"grep -q '\^plan_contract=([^$']*)\$'", scopes[0].get("run", "")) if len(scopes) == 1 else []
|
||||
if markers != [PLAN_CONTRACT]:
|
||||
return [f"classify-changes must fall back to the full matrix unless the plan carries contract {PLAN_CONTRACT}"]
|
||||
return []
|
||||
|
||||
|
||||
def check_workflow(root: Path) -> list[str]:
|
||||
try:
|
||||
document = workflow_document((root / ".github/workflows/ci.yml").read_text())
|
||||
@@ -677,28 +664,11 @@ def check_workflow(root: Path) -> list[str]:
|
||||
required = set(ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS)
|
||||
if set(jobs) - NON_VALIDATION_JOBS - set(REPORT_ONLY_JOBS) != required:
|
||||
errors.append("CI verification jobs and the required gate contract differ")
|
||||
for job, (mirror, steps, env, selection) in REPORT_ONLY_JOBS.items():
|
||||
for job, (mirror, steps, env) in REPORT_ONLY_JOBS.items():
|
||||
block, mirrored = jobs.get(job), jobs.get(mirror)
|
||||
if not isinstance(block, dict) or not isinstance(mirrored, dict):
|
||||
errors.append(f"report-only {job} and its required {mirror} must both exist")
|
||||
continue
|
||||
# A failed suite must render as a failed, non-blocking job: never as a
|
||||
# blocking one, and never as a green one behind a tolerated step.
|
||||
if block.get("continue-on-error") != "true":
|
||||
errors.append(f"{job} must stay report-only with job-level continue-on-error")
|
||||
if any(isinstance(item, dict) and "continue-on-error" in item for item in block.get("steps") or []):
|
||||
errors.append(f"{job} cannot hide a failed step behind continue-on-error")
|
||||
# The selection reaches the job's condition only through a declared
|
||||
# output of a job it needs; otherwise the expression reads an empty
|
||||
# string and the lane silently runs everywhere.
|
||||
dependencies = block.get("needs", [])
|
||||
if "classify-changes" not in ([dependencies] if isinstance(dependencies, str) else dependencies):
|
||||
errors.append(f"{job} must need classify-changes to read {selection}")
|
||||
published = (jobs.get("classify-changes", {}).get("outputs") or {}).get(selection, "")
|
||||
if not isinstance(published, str) or f"steps.scope.outputs.{selection}" not in published:
|
||||
errors.append(f"classify-changes must publish {selection} from the scope step")
|
||||
if re.search(r"\b(always|success|failure|cancelled)\s*\(", re.sub(r"'(?:[^']|'')*'", "", str(block.get("if", "")))):
|
||||
errors.append(f"{job} must not override its prerequisites with a status function")
|
||||
key, _, value = env.partition(": ")
|
||||
if (block.get("env") or {}).get(key) != value:
|
||||
errors.append(f"{job} must set {env}")
|
||||
@@ -733,11 +703,8 @@ def check_workflow(root: Path) -> list[str]:
|
||||
or verifiers[0].get("env", {}).get("CI_NEEDS") != "${{ toJSON(needs) }}"
|
||||
or verifiers[0].get("shell") != "bash" or "if" in verifiers[0]):
|
||||
errors.append("required-checks must unconditionally verify actual needs and preserve its exit status")
|
||||
lint_commands = ("cargo clippy --all-targets -- -D warnings", "python3 scripts/ci_test_artifact.py cargo lint --configuration workspace")
|
||||
clippy = [step for step in jobs.get("workspace-lint-and-build", {}).get("steps", [])
|
||||
if any(command in step.get("run", "") for command in lint_commands)]
|
||||
if not clippy or any("if" in step for step in clippy):
|
||||
errors.append("workspace Clippy must run on every code event, pull requests included")
|
||||
errors.extend(check_lint_lane(jobs))
|
||||
errors.extend(check_plan_contract_marker(jobs))
|
||||
matrix = jobs.get("test-and-lint-protocols", {}).get("strategy", {}).get("matrix", {})
|
||||
features = matrix.get("features", [])
|
||||
if not isinstance(features, list) or {item.get("name"): item.get("flags") for item in features} != {
|
||||
@@ -791,27 +758,6 @@ def check_workflow(root: Path) -> list[str]:
|
||||
continue
|
||||
if actual != should_run:
|
||||
errors.append(f"{job} condition differs from plan for {event}/{mode}/{connect}/{action}/{ref}")
|
||||
for job, (mirror, _, _, selection) in REPORT_ONLY_JOBS.items():
|
||||
block = jobs.get(job)
|
||||
if not isinstance(block, dict) or mirror not in jobs:
|
||||
continue
|
||||
for selected in ("true", "false", ""):
|
||||
try:
|
||||
dependencies = block.get("needs", [])
|
||||
ready = all(scheduled(dependency) for dependency in ([dependencies] if isinstance(dependencies, str) else dependencies))
|
||||
actual = ready and condition(block.get("if", "success()"), dict(
|
||||
context, **{f"needs.classify-changes.outputs.{selection}": selected}))
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
errors.append(f"{job}: {error}")
|
||||
continue
|
||||
wanted = scheduled(mirror) and (event != "pull_request" or selected != "false")
|
||||
if actual and not wanted:
|
||||
errors.append(f"{job} runs on pull requests that change no gateway-stack input" if scheduled(mirror)
|
||||
else f"{job} runs where {mirror} does not")
|
||||
elif wanted and not actual:
|
||||
errors.append(f"{job} is skipped on {event}" if event != "pull_request" else
|
||||
f"{job} is skipped on a pull request " + (
|
||||
"that changes a gateway-stack input" if selected == "true" else "whose selection is unknown"))
|
||||
for status in ("failure", "cancelled"):
|
||||
context.update(success=False, failure=status == "failure", cancelled=status == "cancelled")
|
||||
try:
|
||||
@@ -836,7 +782,6 @@ def main() -> int:
|
||||
("connect", str(plan["connect"]).lower()), ("packages", json.dumps(plan["packages"], separators=(",", ":"))),
|
||||
("full_validation", str(plan["full_validation"]).lower()),
|
||||
("rehearse_shards", str(plan["rehearse_shards"]).lower()),
|
||||
("gateway_smoke", str(plan["gateway_smoke"]["run"]).lower()),
|
||||
("plan_contract", PLAN_CONTRACT)):
|
||||
output.write(f"{name}={value}\n")
|
||||
print(json.dumps(plan, indent=2))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,994 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split rustfs/src/app/multipart_usecase.rs into one file per operation.
|
||||
|
||||
Preparatory refactor P3 of the s3s to rustfs-gateway migration. A pure code
|
||||
move: every top-level item, every method of `impl DefaultMultipartUsecase` and
|
||||
every test is assigned to a target file by the explicit tables below, and is
|
||||
moved verbatim with its attributes and doc comments. Nothing is renamed and no
|
||||
body changes, with these exceptions, each mechanical and listed here so a
|
||||
reviewer knows where the output differs from the input:
|
||||
|
||||
* the three test fixtures (`s3_op_total`, `build_request`, `make_usecase`)
|
||||
gain `pub(super)` because they move to `test_support.rs`;
|
||||
* the test module's `use` items become `pub(super) use` re-exports in
|
||||
`test_support.rs` (names the parent already imports are not repeated), so
|
||||
every per-operation test module reaches them with one glob;
|
||||
* code-position `s3s::` paths in the new files become
|
||||
`crate::app::storage_api::s3::`, the sanctioned s3s funnel, so the number
|
||||
of files naming s3s (scripts/check_s3s_footprint.sh) does not grow. The
|
||||
funnel gains two re-exports, `dto` and `xml`, in rustfs/src/app/storage_api.rs;
|
||||
* the nextest filter that names the one test it pins follows the test to its
|
||||
new module path.
|
||||
|
||||
Layout produced (parent keeps the struct, its constructors and accessors, the
|
||||
shared helpers and the test fixtures):
|
||||
|
||||
multipart_usecase.rs shared state and helpers
|
||||
multipart_usecase/test_support.rs fixtures and test-only imports
|
||||
multipart_usecase/{abort,create,list,upload_part,complete,upload_part_copy}.rs
|
||||
one operation: its helpers, its
|
||||
`impl DefaultMultipartUsecase` block,
|
||||
its tests
|
||||
multipart_usecase/upload_part/tests/body_read_tests.rs
|
||||
multipart_usecase/upload_part_copy/tests/copy_admission_tests.rs
|
||||
the two former test files, unchanged
|
||||
|
||||
Usage:
|
||||
scripts/codemods/split_multipart_usecase.py [--no-fmt] [--report]
|
||||
scripts/codemods/split_multipart_usecase.py --self-test
|
||||
|
||||
The input is the unsplit tree. A tree that is already split is refused, as is
|
||||
any top-level item, method or test without an assignment, any table entry that
|
||||
matches nothing, and any private item that a file other than its own refers
|
||||
to. Re-running therefore means restoring the pre-split files first:
|
||||
|
||||
git checkout <pre-split-commit> -- rustfs/src/app/multipart_usecase.rs \\
|
||||
rustfs/src/app/multipart_usecase rustfs/src/app/storage_api.rs .config/nextest.toml
|
||||
git clean -fd rustfs/src/app/multipart_usecase
|
||||
|
||||
and then running this script. rustfmt formats the written files unless
|
||||
--no-fmt is given. --report prints the item to file assignment and the
|
||||
cross-file reference check and writes nothing.
|
||||
|
||||
Exit status: 0 on success, 1 when the input cannot be split as asked (the
|
||||
message says why), 2 for a usage error.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
APP = "rustfs/src/app"
|
||||
SOURCE = f"{APP}/multipart_usecase.rs"
|
||||
OUT_DIR = f"{APP}/multipart_usecase"
|
||||
STORAGE_API = f"{APP}/storage_api.rs"
|
||||
NEXTEST = ".config/nextest.toml"
|
||||
STRUCT = "DefaultMultipartUsecase"
|
||||
FUNNEL = "crate::app::storage_api::s3::"
|
||||
|
||||
PARENT = "parent"
|
||||
SUPPORT = "test_support"
|
||||
OPS = ("abort", "create", "list", "upload_part", "complete", "upload_part_copy")
|
||||
OP_DOC = {
|
||||
"abort": "AbortMultipartUpload.",
|
||||
"create": "CreateMultipartUpload.",
|
||||
"list": "ListMultipartUploads and ListParts.",
|
||||
"upload_part": "UploadPart.",
|
||||
"complete": "CompleteMultipartUpload.",
|
||||
"upload_part_copy": "UploadPartCopy.",
|
||||
}
|
||||
|
||||
# Test files that already existed, moved unchanged: (old path, operation).
|
||||
OLD_TEST_FILES = {
|
||||
"body_read_tests": (f"{OUT_DIR}/tests/body_read_tests.rs", "upload_part"),
|
||||
"copy_admission_tests": (f"{OUT_DIR}/tests/copy_admission_tests.rs", "upload_part_copy"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assignment tables: item name -> target file. Every name must be listed; the
|
||||
# script fails on a missing or an unused entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def assigned(target, names):
|
||||
return {name: target for name in names.split()}
|
||||
|
||||
|
||||
# Top-level functions (everything that is not an import, the struct, its impl
|
||||
# or the test module). A helper stays in the parent when more than one
|
||||
# operation, or a cross-operation test, uses it.
|
||||
FUNCTIONS = {
|
||||
**assigned(
|
||||
PARENT,
|
||||
"""
|
||||
merge_part_encryption_metadata multipart_plaintext_size multipart_part_numbers
|
||||
multipart_max_total_object_size validate_table_catalog_object_mutation
|
||||
internal_object_info_lookup_opts multipart_upload_api_error
|
||||
""",
|
||||
),
|
||||
**assigned(
|
||||
"upload_part_copy",
|
||||
"normalize_copy_source_version_id format_copy_source_version_id validate_copy_source_range_not_exceeds",
|
||||
),
|
||||
**assigned(
|
||||
"complete",
|
||||
"""
|
||||
validate_complete_multipart_parts normalize_complete_multipart_parts complete_part_from_s3
|
||||
has_complete_multipart_object_lock_headers validate_complete_multipart_checksum_type
|
||||
quota_accounting_object_size encode_s3_path extract_request_scheme extract_request_host
|
||||
build_complete_multipart_location
|
||||
""",
|
||||
),
|
||||
**assigned("create", "create_multipart_upload_metadata should_advertise_session_compression"),
|
||||
**assigned(
|
||||
"upload_part",
|
||||
"""
|
||||
resolve_upload_part_size require_upload_part_size upload_part_body_read_timeout
|
||||
passthrough_part_actual_size normalize_presigned_part_checksums
|
||||
""",
|
||||
),
|
||||
}
|
||||
|
||||
# Methods of `impl DefaultMultipartUsecase`.
|
||||
METHODS = {
|
||||
**assigned(
|
||||
PARENT,
|
||||
"without_context from_global with_context with_context_and_concurrency_manager"
|
||||
" bucket_metadata_sys object_store object_data_cache concurrency_manager",
|
||||
),
|
||||
**assigned("abort", "execute_abort_multipart_upload"),
|
||||
**assigned("complete", "execute_complete_multipart_upload execute_complete_multipart_upload_owned"),
|
||||
**assigned("create", "execute_create_multipart_upload"),
|
||||
**assigned("upload_part", "execute_upload_part"),
|
||||
**assigned("list", "execute_list_multipart_uploads execute_list_parts"),
|
||||
**assigned("upload_part_copy", "execute_upload_part_copy"),
|
||||
}
|
||||
|
||||
# Items of `mod tests`. A test that drives more than one operation, and the
|
||||
# tests of the shared helpers, stay in the parent's own `mod tests`.
|
||||
TESTS = {
|
||||
**assigned(SUPPORT, "s3_op_total build_request make_usecase"),
|
||||
**assigned(
|
||||
PARENT,
|
||||
"""
|
||||
internal_object_info_lookup_opts_drops_http_preconditions
|
||||
merge_part_encryption_metadata_keeps_source_metadata_unchanged
|
||||
managed_multipart_roundtrip_preserves_session_nonce_between_parts
|
||||
multipart_upload_rejects_reserved_directory_markers
|
||||
multipart_max_total_object_size_reads_compatible_internal_metadata
|
||||
multipart_upload_api_error_reports_malformed_upload_id_as_no_such_upload
|
||||
multipart_handlers_reject_another_callers_denied_kms_upload
|
||||
""",
|
||||
),
|
||||
**assigned("abort", "execute_abort_multipart_upload_returns_internal_error_when_store_uninitialized"),
|
||||
**assigned(
|
||||
"create",
|
||||
"""
|
||||
session_compression_is_advertised_only_for_non_passthrough_compressible_uploads
|
||||
execute_create_multipart_upload_rejects_invalid_storage_class
|
||||
create_multipart_upload_metadata_persists_dto_storage_class_without_raw_header
|
||||
create_multipart_upload_metadata_keeps_user_and_system_namespaces_separate
|
||||
create_multipart_rejects_ciphertext_replication_before_parts_are_staged
|
||||
""",
|
||||
),
|
||||
**assigned(
|
||||
"list",
|
||||
"""
|
||||
execute_list_multipart_uploads_returns_internal_error_when_store_uninitialized
|
||||
execute_list_multipart_uploads_rejects_invalid_key_marker_before_store_lookup
|
||||
execute_list_multipart_uploads_rejects_invalid_max_uploads_before_store_lookup
|
||||
execute_list_parts_returns_internal_error_when_store_uninitialized
|
||||
execute_list_parts_rejects_negative_part_number_marker_before_store_lookup
|
||||
execute_list_parts_rejects_invalid_max_parts_before_store_lookup
|
||||
""",
|
||||
),
|
||||
**assigned(
|
||||
"upload_part",
|
||||
"""
|
||||
presigned_checksum_request
|
||||
normalize_presigned_part_checksums_validates_body
|
||||
normalize_presigned_part_checksums_accepts_sdk_crc32_default
|
||||
normalize_presigned_part_checksums_rejects_ambiguous_values
|
||||
normalize_presigned_part_checksums_preserves_headers_and_rejects_invalid_values
|
||||
normalize_presigned_part_checksums_requires_verified_presign
|
||||
resolve_upload_part_size_uses_normalized_logical_length
|
||||
upload_part_length_contract_rejects_unknown_and_all_negative_lengths
|
||||
upload_part_timeout_policy_preserves_disabled_and_capped_floor
|
||||
execute_upload_part_rejects_missing_body
|
||||
execute_upload_part_rejects_oversize_declared_part_before_reading_the_body
|
||||
execute_upload_part_rejects_invalid_part_number_before_body_lookup
|
||||
execute_upload_part_rejects_unknown_length_before_admission_or_body_polling
|
||||
execute_upload_part_progress_timeout_releases_admission_and_leaves_no_part
|
||||
execute_upload_part_rejects_when_foreground_write_admission_is_full
|
||||
""",
|
||||
),
|
||||
**assigned(
|
||||
"upload_part_copy",
|
||||
"""
|
||||
execute_upload_part_copy_returns_internal_error_when_store_uninitialized
|
||||
execute_upload_part_copy_rejects_invalid_part_number_before_store_lookup
|
||||
test_validate_copy_source_range_not_exceeds_returns_invalid_range_when_range_exceeds
|
||||
test_validate_copy_source_range_not_exceeds_ok_when_range_valid
|
||||
test_validate_copy_source_range_not_exceeds_ok_for_suffix_range
|
||||
upload_part_copy_normalizes_and_formats_source_version_id
|
||||
""",
|
||||
),
|
||||
**assigned(
|
||||
"complete",
|
||||
"""
|
||||
upload_metadata_with_checksum_type headers_with_checksum_type
|
||||
complete_multipart_checksum_type_matching_the_upload_is_accepted
|
||||
complete_multipart_checksum_type_contradicting_the_upload_is_rejected
|
||||
complete_multipart_checksum_type_without_both_sides_is_accepted
|
||||
quota_accounting_uses_logical_size_when_available
|
||||
quota_accounting_fails_closed_only_when_quota_is_configured
|
||||
test_build_complete_multipart_location_uses_forwarded_proto_and_encodes_key
|
||||
test_build_complete_multipart_location_falls_back_to_uri_authority_and_scheme
|
||||
test_build_complete_multipart_location_returns_path_without_host
|
||||
execute_complete_multipart_upload_rejects_missing_parts_payload
|
||||
execute_complete_multipart_upload_rejects_missing_parts_list
|
||||
compressed_complete_records_logical_quota_usage_and_overwrite_delta
|
||||
authorized_replica_only_multipart_complete_preserves_persisted_anti_cascade_state
|
||||
authorized_replica_only_multipart_complete_preserves_persisted_anti_cascade_state_inner
|
||||
ssec_completion_and_replay_require_key_before_disclosing_plaintext_checksums
|
||||
assert_multipart_etag_listings
|
||||
kms_multipart_completion_and_replay_preserve_etag_in_versioned_listings
|
||||
conditional_multipart_upload_if_none_match_returns_precondition_failed_when_object_exists
|
||||
conditional_multipart_upload_if_none_match_returns_precondition_failed_when_object_exists_inner
|
||||
concurrent_completions_share_durable_bucket_quota_reservations
|
||||
multipart_completion_commits_after_quota_fleet_proof_loss_before_rename
|
||||
data_movement_multipart_completion_has_zero_quota_growth
|
||||
cancelled_multipart_completion_emits_once_and_replay_does_not_emit
|
||||
rejected_empty_parts_preserve_existing_object_and_staging
|
||||
execute_complete_multipart_upload_allows_duplicate_part_numbers_by_using_last_occurrence
|
||||
execute_complete_multipart_upload_rejects_out_of_order_parts
|
||||
validate_complete_multipart_parts_enforces_part_number_range
|
||||
normalize_complete_multipart_parts_keeps_last_duplicate_part
|
||||
execute_complete_multipart_upload_rejects_object_lock_headers
|
||||
""",
|
||||
),
|
||||
}
|
||||
|
||||
# Test module items that are not code to assign.
|
||||
TEST_MODS = ("body_read_tests", "copy_admission_tests")
|
||||
|
||||
CODE, COMMENT, LITERAL = 0, 1, 2
|
||||
IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
|
||||
CHAR_LITERAL = re.compile(r"'(?:\\(?:[nrt\\0'\"]|x[0-9a-fA-F]{2}|u\{[0-9a-fA-F_]{1,6}\})|[^\\'\n])'")
|
||||
RAW_STRING_START = re.compile(r'r(#*)"')
|
||||
# A relative path in code moved one module down: `super::object::..` named a sibling of
|
||||
# multipart_usecase, and from an operation file that is `super::super::object::..`.
|
||||
SUPER_PATH = re.compile(r"(?<![A-Za-z0-9_:])super::(?!\*)")
|
||||
S3S_PATH = re.compile(r"(?<![\"A-Za-z0-9_])s3s::")
|
||||
VIS = r"(?:pub(?:\([^)]*\))?\s+)?"
|
||||
FN_HEAD = re.compile(r"^" + VIS + r"(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
|
||||
STRUCT_HEAD = re.compile(r"^" + VIS + r"struct\s+([A-Za-z_][A-Za-z0-9_]*)")
|
||||
IMPL_HEAD = re.compile(r"^impl\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{")
|
||||
MOD_BLOCK_HEAD = re.compile(r"^" + VIS + r"mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{")
|
||||
MOD_DECL_HEAD = re.compile(r"^" + VIS + r"mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;")
|
||||
USE_HEAD = re.compile(r"^" + VIS + r"use\s")
|
||||
|
||||
|
||||
class SplitError(Exception):
|
||||
"""The input cannot be split as asked: exit 1."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lexing: blank comments and literals so structure can be read from the rest.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify(text):
|
||||
"""Return one kind byte per character: CODE, COMMENT or LITERAL."""
|
||||
kinds = bytearray(len(text))
|
||||
i, n = 0, len(text)
|
||||
|
||||
def mark(start, end, kind):
|
||||
kinds[start:end] = bytes([kind]) * (end - start)
|
||||
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if text.startswith("//", i):
|
||||
end = text.find("\n", i)
|
||||
end = n if end < 0 else end
|
||||
mark(i, end, COMMENT)
|
||||
i = end
|
||||
elif text.startswith("/*", i):
|
||||
depth, j = 1, i + 2
|
||||
while j < n and depth:
|
||||
if text.startswith("/*", j):
|
||||
depth, j = depth + 1, j + 2
|
||||
elif text.startswith("*/", j):
|
||||
depth, j = depth - 1, j + 2
|
||||
else:
|
||||
j += 1
|
||||
mark(i, j, COMMENT)
|
||||
i = j
|
||||
elif ch == "r" and (i == 0 or text[i - 1] not in IDENT_CHARS or (text[i - 1] == "b" and (i == 1 or text[i - 2] not in IDENT_CHARS))):
|
||||
raw = RAW_STRING_START.match(text, i)
|
||||
if raw is None:
|
||||
i += 1
|
||||
continue
|
||||
closing = '"' + raw.group(1)
|
||||
end = text.find(closing, raw.end())
|
||||
end = n if end < 0 else end + len(closing)
|
||||
mark(i, end, LITERAL)
|
||||
i = end
|
||||
elif ch == '"':
|
||||
j = i + 1
|
||||
while j < n and text[j] != '"':
|
||||
j += 2 if text[j] == "\\" else 1
|
||||
mark(i, min(j + 1, n), LITERAL)
|
||||
i = j + 1
|
||||
elif ch == "'":
|
||||
literal = CHAR_LITERAL.match(text, i)
|
||||
if literal is None: # a lifetime or a loop label
|
||||
i += 1
|
||||
continue
|
||||
mark(i, literal.end(), LITERAL)
|
||||
i = literal.end()
|
||||
else:
|
||||
i += 1
|
||||
return kinds
|
||||
|
||||
|
||||
def code_only(text):
|
||||
"""`text` with comments and literals replaced by spaces, newlines kept."""
|
||||
kinds = classify(text)
|
||||
return "".join(ch if kinds[i] == CODE or ch == "\n" else " " for i, ch in enumerate(text))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item splitting. The input is rustfmt output, so an item is the run of lines
|
||||
# from its first attribute or comment to the line on which brackets balance
|
||||
# and the line ends in `}` or `;`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Item:
|
||||
def __init__(self, lines, code_lines):
|
||||
self.lines = lines # original lines, leading comments and attributes included
|
||||
self.code_lines = code_lines # the same lines with comments and literals blanked
|
||||
self.head = next((line.strip() for line in code_lines if line.strip() and not line.strip().startswith("#[")), "")
|
||||
self.cfg_test = any(line.strip() == "#[cfg(test)]" for line in lines)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return "\n".join(self.lines)
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return "\n".join(self.code_lines)
|
||||
|
||||
def name(self):
|
||||
for pattern in (FN_HEAD, STRUCT_HEAD, IMPL_HEAD, MOD_BLOCK_HEAD, MOD_DECL_HEAD):
|
||||
match = pattern.match(self.head)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def is_use(self):
|
||||
return bool(USE_HEAD.match(self.head))
|
||||
|
||||
|
||||
def split_items(lines, code_lines, what):
|
||||
"""Cut `lines` into items; blank lines separate them."""
|
||||
items, i, n = [], 0, len(lines)
|
||||
while i < n:
|
||||
if not lines[i].strip():
|
||||
i += 1
|
||||
continue
|
||||
start, balance, j = i, 0, i
|
||||
while j < n:
|
||||
stripped = code_lines[j].rstrip()
|
||||
balance += sum(stripped.count(c) for c in "([{") - sum(stripped.count(c) for c in ")]}")
|
||||
if balance == 0 and stripped.endswith(("}", ";")):
|
||||
break
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise SplitError(f"{what}: an item starting at line {start + 1} never closes")
|
||||
items.append(Item(lines[start : j + 1], code_lines[start : j + 1]))
|
||||
i = j + 1
|
||||
return items
|
||||
|
||||
|
||||
def canonical(items, separator):
|
||||
return separator.join(item.text for item in items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Use trees.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def split_top(inner):
|
||||
parts, depth, current = [], 0, []
|
||||
for ch in inner:
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(ch)
|
||||
parts.append("".join(current))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def flatten_use(tree, prefix=""):
|
||||
"""[(full path, bound name)] for a use tree; a glob binds `*`, `as _` binds `_`."""
|
||||
tree = tree.strip()
|
||||
brace = tree.find("{")
|
||||
if brace >= 0:
|
||||
head = tree[:brace].strip()
|
||||
inner = tree[brace + 1 : tree.rindex("}")]
|
||||
base = prefix + head
|
||||
found = []
|
||||
for part in split_top(inner):
|
||||
found.extend(flatten_use(part, base))
|
||||
return found
|
||||
alias = None
|
||||
if " as " in tree:
|
||||
tree, alias = (side.strip() for side in tree.split(" as ", 1))
|
||||
path = prefix + tree
|
||||
last = path.split("::")[-1]
|
||||
if last == "self":
|
||||
path = path[: -len("::self")]
|
||||
last = path.split("::")[-1]
|
||||
return [(path, alias if alias else last)]
|
||||
|
||||
|
||||
def use_paths(item):
|
||||
body = re.sub(r"^\s*(?:#\[[^\n]*\]\s*)*", "", item.code, flags=re.S)
|
||||
body = re.sub(r"^" + VIS + r"use\s+", "", body.strip())
|
||||
body = body.rstrip().rstrip(";")
|
||||
return flatten_use(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The parsed input.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Parsed:
|
||||
pass
|
||||
|
||||
|
||||
def parse(text):
|
||||
lines = text.split("\n")
|
||||
if lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
code_lines = code_only("\n".join(lines)).split("\n")
|
||||
|
||||
# Leading licence and `//!` lines are the file preamble.
|
||||
first = 0
|
||||
while first < len(lines) and (lines[first].startswith("//") or not lines[first].strip()):
|
||||
first += 1
|
||||
preamble = lines[:first]
|
||||
items = split_items(lines[first:], code_lines[first:], "multipart_usecase.rs")
|
||||
|
||||
parsed = Parsed()
|
||||
parsed.preamble = preamble
|
||||
parsed.imports, parsed.functions, parsed.body = [], [], []
|
||||
parsed.struct = parsed.impl = parsed.tests = None
|
||||
for item in items:
|
||||
if not item.is_use():
|
||||
parsed.body.append(item)
|
||||
name = item.name()
|
||||
if item.is_use():
|
||||
if parsed.functions or parsed.struct or parsed.impl:
|
||||
raise SplitError("a `use` item follows code: imports are expected first")
|
||||
parsed.imports.append(item)
|
||||
elif STRUCT_HEAD.match(item.head) and name == STRUCT:
|
||||
parsed.struct = item
|
||||
elif IMPL_HEAD.match(item.head) and name == STRUCT:
|
||||
if parsed.impl is not None:
|
||||
raise SplitError(f"more than one `impl {STRUCT}` block")
|
||||
parsed.impl = item
|
||||
elif MOD_BLOCK_HEAD.match(item.head) and name == "tests":
|
||||
parsed.tests = item
|
||||
elif FN_HEAD.match(item.head):
|
||||
item.after_impl = parsed.impl is not None
|
||||
parsed.functions.append(item)
|
||||
elif MOD_DECL_HEAD.match(item.head):
|
||||
raise SplitError(f"{SOURCE} already declares {item.head!r}: it is split already; restore the pre-split files first")
|
||||
else:
|
||||
raise SplitError(f"unrecognised top-level item: {item.head!r}")
|
||||
for what, value in (("struct", parsed.struct), ("impl", parsed.impl), ("tests module", parsed.tests)):
|
||||
if value is None:
|
||||
raise SplitError(f"the {what} was not found; is the input already split?")
|
||||
|
||||
# Spacing must be the canonical one, or the output would not be a move.
|
||||
rebuilt = "\n".join(preamble) + "\n" + "\n".join(i.text for i in parsed.imports) + "\n\n"
|
||||
rebuilt += canonical(parsed.body, "\n\n")
|
||||
if "\n".join(lines) != "\n".join(rebuilt.split("\n")):
|
||||
raise SplitError("the item layout is not the one this script reassembles (spacing differs from rustfmt's)")
|
||||
|
||||
parsed.impl_methods = body_items(parsed.impl, "impl")
|
||||
parsed.test_items = body_items(parsed.tests, "mod tests")
|
||||
return parsed
|
||||
|
||||
|
||||
def body_items(block, what):
|
||||
"""Items between the opening line and the closing brace of `block`."""
|
||||
opening = next(i for i, line in enumerate(block.code_lines) if line.strip() and not line.strip().startswith("#["))
|
||||
inner, inner_code = block.lines[opening + 1 : -1], block.code_lines[opening + 1 : -1]
|
||||
items = split_items(inner, inner_code, what)
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assignment and checks.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assign(items, table, what):
|
||||
placed = {}
|
||||
seen = set()
|
||||
for item in items:
|
||||
name = item.name()
|
||||
if name is None:
|
||||
raise SplitError(f"{what}: cannot name the item {item.head!r}")
|
||||
if name in seen:
|
||||
raise SplitError(f"{what}: two items are named {name}")
|
||||
seen.add(name)
|
||||
target = table.get(name)
|
||||
if target is None:
|
||||
raise SplitError(f"{what}: no assignment for {name} ({item.head[:60]!r})")
|
||||
placed[name] = (target, item)
|
||||
unused = sorted(set(table) - seen)
|
||||
if unused:
|
||||
raise SplitError(f"{what}: assignments that match nothing: {', '.join(unused)}")
|
||||
return placed
|
||||
|
||||
|
||||
def check_targets(table, what, allowed):
|
||||
for name, target in table.items():
|
||||
if target not in allowed:
|
||||
raise SplitError(f"{what}: {name} is assigned to unknown file {target!r}")
|
||||
|
||||
|
||||
def word(name):
|
||||
return re.compile(r"(?<![A-Za-z0-9_])" + re.escape(name) + r"(?![A-Za-z0-9_])")
|
||||
|
||||
|
||||
def referenced_from(name, texts, own):
|
||||
"""Files other than `own` whose code mentions `name`."""
|
||||
pattern = word(name)
|
||||
return sorted(f for f, code in texts.items() if f != own and pattern.search(code))
|
||||
|
||||
|
||||
def plan(parsed):
|
||||
"""Decide the content of every output file; returns (pieces, report)."""
|
||||
check_targets(FUNCTIONS, "functions", {PARENT, *OPS})
|
||||
check_targets(METHODS, "methods", {PARENT, *OPS})
|
||||
check_targets(TESTS, "tests", {PARENT, SUPPORT, *OPS})
|
||||
functions = assign(parsed.functions, FUNCTIONS, "functions")
|
||||
methods = assign(parsed.impl_methods, METHODS, "methods")
|
||||
|
||||
test_code_items = []
|
||||
test_uses, test_mods, test_glob = [], [], None
|
||||
for item in parsed.test_items:
|
||||
if item.is_use():
|
||||
if item.head.replace(" ", "") == "usesuper::*;":
|
||||
test_glob = item
|
||||
else:
|
||||
test_uses.append(item)
|
||||
elif MOD_DECL_HEAD.match(item.head):
|
||||
if item.name() not in TEST_MODS:
|
||||
raise SplitError(f"mod tests: unexpected module declaration {item.head!r}")
|
||||
test_mods.append(item)
|
||||
else:
|
||||
test_code_items.append(item)
|
||||
if test_glob is None:
|
||||
raise SplitError("mod tests does not start with `use super::*;`")
|
||||
if sorted(m.name() for m in test_mods) != sorted(TEST_MODS):
|
||||
raise SplitError("mod tests: the declared test modules differ from TEST_MODS")
|
||||
tests = assign(test_code_items, TESTS, "tests")
|
||||
|
||||
for placed in (functions, methods):
|
||||
for target, item in placed.values():
|
||||
if target != PARENT:
|
||||
push_down(item)
|
||||
|
||||
# Piece lists per target file.
|
||||
pieces = {f: {"functions": [], "methods": [], "tests": []} for f in (PARENT, SUPPORT, *OPS)}
|
||||
for _, (target, item) in functions.items():
|
||||
pieces[target]["functions"].append(item)
|
||||
for _, (target, item) in methods.items():
|
||||
pieces[target]["methods"].append(item)
|
||||
for _, (target, item) in tests.items():
|
||||
pieces[target]["tests"].append(item)
|
||||
if pieces[SUPPORT]["functions"] or pieces[SUPPORT]["methods"]:
|
||||
raise SplitError("test_support holds tests-module fixtures only")
|
||||
pieces["_uses"] = test_uses
|
||||
pieces["_mods"] = test_mods
|
||||
return pieces, (functions, methods, tests)
|
||||
|
||||
|
||||
def per_file_code(parsed, pieces, extra):
|
||||
"""Code of each output file for the reference check (parent keeps imports)."""
|
||||
texts = {}
|
||||
for target in (PARENT, SUPPORT, *OPS):
|
||||
parts = []
|
||||
if target == PARENT:
|
||||
parts += [i.code for i in parsed.imports] + [parsed.struct.code]
|
||||
parts += [i.code for i in pieces[target]["functions"]]
|
||||
parts += [i.code for i in pieces[target]["methods"]]
|
||||
parts += [i.code for i in pieces[target]["tests"]]
|
||||
texts[target] = "\n".join(parts)
|
||||
for target, code in extra.items():
|
||||
texts[target] += "\n" + code
|
||||
return texts
|
||||
|
||||
|
||||
def reference_check(pieces, texts):
|
||||
"""A private item moved out of the parent must stay inside its own file.
|
||||
|
||||
Items kept in the parent and fixtures in test_support are visible to every
|
||||
child through the globs, so only items placed in an operation file are
|
||||
checked. A name that is also a local binding elsewhere would show up here as
|
||||
a false alarm; the message names the files so the table can be adjusted.
|
||||
"""
|
||||
problems = []
|
||||
for group in ("functions", "methods", "tests"):
|
||||
for target in OPS:
|
||||
for item in pieces[target][group]:
|
||||
name = item.name()
|
||||
if name is None or not FN_HEAD.match(item.head):
|
||||
continue
|
||||
if re.match(r"^pub(\(|\s)", item.head) and group == "methods":
|
||||
continue # a public method is reached through the type
|
||||
users = referenced_from(name, texts, target)
|
||||
if users:
|
||||
problems.append(f"{name} is placed in {target} but also mentioned in {', '.join(users)}")
|
||||
return problems
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rewrite_code(text, pattern, replacement):
|
||||
"""Replace `pattern` matches that sit in code, never in comments or literals."""
|
||||
kinds = classify(text)
|
||||
out, last = [], 0
|
||||
for match in pattern.finditer(text):
|
||||
if kinds[match.start()] != CODE:
|
||||
continue
|
||||
out.append(text[last : match.start()])
|
||||
out.append(replacement)
|
||||
last = match.end()
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def rewrite_s3s(text):
|
||||
"""Send code-position `s3s::` paths through the sanctioned funnel."""
|
||||
return rewrite_code(text, S3S_PATH, FUNNEL)
|
||||
|
||||
|
||||
def push_down(item):
|
||||
"""Make the relative paths of an item that leaves the parent module point where they did."""
|
||||
text = rewrite_code(item.text, SUPER_PATH, "super::super::")
|
||||
item.lines = text.split("\n")
|
||||
item.code_lines = code_only(text).split("\n")
|
||||
|
||||
|
||||
def licence(preamble):
|
||||
block = []
|
||||
for line in preamble:
|
||||
if not line.startswith("//") or line.startswith("//!"):
|
||||
break
|
||||
block.append(line)
|
||||
return "\n".join(block)
|
||||
|
||||
|
||||
def doc_line(preamble):
|
||||
return next(line for line in preamble if line.startswith("//!"))
|
||||
|
||||
|
||||
def join_items(items):
|
||||
return "\n\n".join(item.text for item in items)
|
||||
|
||||
|
||||
def render_operation(op, pieces, header, tests_extra_mods):
|
||||
out = [header, f"//! {OP_DOC[op]}", "", "use super::*;", ""]
|
||||
body = []
|
||||
if pieces[op]["functions"]:
|
||||
body.append(join_items(pieces[op]["functions"]))
|
||||
if pieces[op]["methods"]:
|
||||
body.append(f"impl {STRUCT} {{\n{join_items(pieces[op]['methods'])}\n}}")
|
||||
if pieces[op]["tests"] or tests_extra_mods:
|
||||
inner = [join_items(pieces[op]["tests"])] if pieces[op]["tests"] else []
|
||||
inner += [f" mod {name};" for name in tests_extra_mods]
|
||||
body.append(
|
||||
"#[cfg(test)]\nmod tests {\n use super::super::test_support::*;\n use super::*;\n\n"
|
||||
+ "\n\n".join(inner)
|
||||
+ "\n}"
|
||||
)
|
||||
out.append("\n\n".join(body))
|
||||
return rewrite_s3s("\n".join(out) + "\n")
|
||||
|
||||
|
||||
def render_test_support(parsed, pieces, header, parent_bound):
|
||||
out = [header, "//! Fixtures and test-only imports shared by the multipart use-case tests.", "", "use super::*;"]
|
||||
reexports, notes = [], []
|
||||
for item in pieces["_uses"]:
|
||||
for path, name in use_paths(item):
|
||||
if name in parent_bound:
|
||||
notes.append((name, parent_bound[name], path))
|
||||
continue
|
||||
reexports.append(f"pub(super) use {path};")
|
||||
out.extend(reexports)
|
||||
out.append("")
|
||||
fixtures = []
|
||||
for item in pieces[SUPPORT]["tests"]:
|
||||
lines = list(item.lines)
|
||||
for index, line in enumerate(lines):
|
||||
if FN_HEAD.match(line.strip()) and not line.startswith(" #"):
|
||||
lines[index] = re.sub(r"^(\s*)", r"\1pub(super) ", line, count=1)
|
||||
break
|
||||
fixtures.append("\n".join(line[4:] if line.startswith(" ") else line for line in lines))
|
||||
out.append("\n\n".join(fixtures))
|
||||
return rewrite_s3s("\n".join(out) + "\n"), notes
|
||||
|
||||
|
||||
def render_parent(parsed, pieces, header):
|
||||
out = [header, "\n".join(line for line in parsed.preamble if line.startswith("//!")), ""]
|
||||
out.append("\n".join(i.text for i in parsed.imports))
|
||||
out.append("")
|
||||
mods = [f"mod {op};" for op in sorted(OPS + (SUPPORT,))]
|
||||
mods = [("#[cfg(test)]\n" + m) if SUPPORT in m else m for m in mods]
|
||||
out.append("\n".join(mods))
|
||||
out.append("")
|
||||
before = [i for i in pieces[PARENT]["functions"] if not i.after_impl]
|
||||
after = [i for i in pieces[PARENT]["functions"] if i.after_impl]
|
||||
chunks = [join_items(before)] if before else []
|
||||
chunks.append(
|
||||
f"{parsed.struct.text}\n\n{parsed.impl.lines[0]}\n{join_items(pieces[PARENT]['methods'])}\n}}"
|
||||
)
|
||||
if after:
|
||||
chunks.append(join_items(after))
|
||||
if pieces[PARENT]["tests"]:
|
||||
chunks.append(
|
||||
rewrite_s3s(
|
||||
"#[cfg(test)]\nmod tests {\n use super::test_support::*;\n use super::*;\n\n"
|
||||
+ join_items(pieces[PARENT]["tests"])
|
||||
+ "\n}"
|
||||
)
|
||||
)
|
||||
out.append("\n\n".join(chunks))
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bound_names(imports):
|
||||
bound = {}
|
||||
for item in imports:
|
||||
for path, name in use_paths(item):
|
||||
if name not in ("*", "_"):
|
||||
bound[name] = path
|
||||
return bound
|
||||
|
||||
|
||||
def build(root):
|
||||
source_path = root / SOURCE
|
||||
if not source_path.is_file():
|
||||
raise SplitError(f"{SOURCE} is missing")
|
||||
text = source_path.read_text(encoding="utf-8")
|
||||
parsed = parse(text)
|
||||
pieces, (functions, methods, tests) = plan(parsed)
|
||||
|
||||
extra = {}
|
||||
old_texts = {}
|
||||
for name, (path, op) in OLD_TEST_FILES.items():
|
||||
file = root / path
|
||||
if not file.is_file():
|
||||
raise SplitError(f"{path} is missing")
|
||||
old_texts[name] = file.read_text(encoding="utf-8")
|
||||
extra[op] = extra.get(op, "") + "\n" + code_only(old_texts[name])
|
||||
texts = per_file_code(parsed, pieces, extra)
|
||||
problems = reference_check(pieces, texts)
|
||||
if problems:
|
||||
raise SplitError("cross-file references:\n " + "\n ".join(problems))
|
||||
|
||||
header = licence(parsed.preamble) + "\n"
|
||||
files = {}
|
||||
parent_bound = bound_names(parsed.imports)
|
||||
support, notes = render_test_support(parsed, pieces, header, parent_bound)
|
||||
files[f"{OUT_DIR}/test_support.rs"] = support
|
||||
for op in OPS:
|
||||
mods = [name for name, (_, owner) in OLD_TEST_FILES.items() if owner == op]
|
||||
files[f"{OUT_DIR}/{op}.rs"] = render_operation(op, pieces, header, mods)
|
||||
for name, (path, op) in OLD_TEST_FILES.items():
|
||||
files[f"{OUT_DIR}/{op}/tests/{name}.rs"] = old_texts[name]
|
||||
files[SOURCE] = render_parent(parsed, pieces, header)
|
||||
return parsed, pieces, files, notes, (functions, methods, tests)
|
||||
|
||||
|
||||
def check_footprint(files):
|
||||
for path, text in files.items():
|
||||
if path == SOURCE:
|
||||
continue
|
||||
for number, line in enumerate(text.split("\n"), 1):
|
||||
if S3S_PATH.search(line):
|
||||
raise SplitError(f"{path}:{number} still names s3s directly: {line.strip()}")
|
||||
return None
|
||||
|
||||
|
||||
FUNNEL_NAMES = {"dto", "xml", "S3", "Body", "S3Request", "S3ErrorCode", "S3Response", "S3Error", "S3Result"}
|
||||
|
||||
|
||||
def check_funnel_names(parsed_sources, files):
|
||||
seen = set()
|
||||
original = "\n".join(parsed_sources)
|
||||
for match in S3S_PATH.finditer(original):
|
||||
tail = re.match(r"[A-Za-z_][A-Za-z0-9_]*", original[match.end() :])
|
||||
if tail:
|
||||
seen.add(tail.group(0))
|
||||
unknown = sorted(seen - FUNNEL_NAMES)
|
||||
if unknown:
|
||||
raise SplitError(f"s3s paths with no funnel re-export: {', '.join(unknown)}")
|
||||
|
||||
|
||||
def edit_storage_api(text):
|
||||
if "pub(crate) use s3s::dto;" in text:
|
||||
raise SplitError(f"{STORAGE_API} already re-exports s3s::dto")
|
||||
anchor = " #[cfg(test)]\n pub(crate) use s3s::dto::{GetObjectInput, HeadObjectInput, ListObjectsV2Input, ListObjectsV2Output};\n"
|
||||
if text.count(anchor) != 1:
|
||||
raise SplitError(f"{STORAGE_API}: the s3 module's dto re-export anchor was not found exactly once")
|
||||
text = text.replace(anchor, " pub(crate) use s3s::dto;\n" + anchor)
|
||||
anchor = " #[cfg(test)]\n pub(crate) use s3s::xml::{Serialize as XmlSerialize, Serializer as XmlSerializer};\n"
|
||||
if text.count(anchor) != 1:
|
||||
raise SplitError(f"{STORAGE_API}: the s3 module's xml re-export anchor was not found exactly once")
|
||||
return text.replace(anchor, " #[cfg(test)]\n pub(crate) use s3s::xml;\n" + anchor)
|
||||
|
||||
|
||||
def edit_nextest(text, tests):
|
||||
"""Point the filter that pins one test at the module the test now lives in."""
|
||||
replaced = 0
|
||||
for name, (target, _) in tests.items():
|
||||
needle = f"app::multipart_usecase::tests::{name}"
|
||||
if needle in text:
|
||||
moved = "app::multipart_usecase::tests" if target == PARENT else f"app::multipart_usecase::{target}::tests"
|
||||
replaced += text.count(needle)
|
||||
text = text.replace(needle, f"{moved}::{name}")
|
||||
if replaced == 0:
|
||||
raise SplitError(f"{NEXTEST} names no multipart_usecase test: has the filter moved?")
|
||||
return text
|
||||
|
||||
|
||||
def run(root, fmt, report):
|
||||
parsed, pieces, files, notes, (functions, methods, tests) = build(root)
|
||||
check_funnel_names([i.text for i in parsed.functions + parsed.impl_methods + parsed.test_items], files)
|
||||
check_footprint(files)
|
||||
if report:
|
||||
print_report(parsed, pieces, files, notes, (functions, methods, tests))
|
||||
return 0
|
||||
|
||||
storage = edit_storage_api((root / STORAGE_API).read_text(encoding="utf-8"))
|
||||
nextest = edit_nextest((root / NEXTEST).read_text(encoding="utf-8"), tests)
|
||||
|
||||
for path, text in files.items():
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
for name, (path, op) in OLD_TEST_FILES.items():
|
||||
(root / path).unlink()
|
||||
old_dir = root / OUT_DIR / "tests"
|
||||
if old_dir.is_dir() and not any(old_dir.iterdir()):
|
||||
old_dir.rmdir()
|
||||
(root / STORAGE_API).write_text(storage, encoding="utf-8")
|
||||
(root / NEXTEST).write_text(nextest, encoding="utf-8")
|
||||
|
||||
if fmt:
|
||||
written = [str(root / p) for p in files if p != SOURCE] + [str(root / SOURCE), str(root / STORAGE_API)]
|
||||
result = subprocess.run(["rustfmt", "--edition", "2024", *written], check=False, cwd=root)
|
||||
if result.returncode != 0:
|
||||
raise SplitError("rustfmt failed on the written files")
|
||||
summary(files, pieces)
|
||||
return 0
|
||||
|
||||
|
||||
def print_report(parsed, pieces, files, notes, assigned):
|
||||
for target in (PARENT, SUPPORT, *OPS):
|
||||
counts = {k: len(pieces[target][k]) for k in ("functions", "methods", "tests")}
|
||||
print(f"{target}: {counts}")
|
||||
for name, parent_path, test_path in notes:
|
||||
print(f"note: test import {name} is already bound in the parent ({parent_path}); test path {test_path}")
|
||||
for path, text in sorted(files.items()):
|
||||
print(f"{path}: {len(text.splitlines())} lines")
|
||||
|
||||
|
||||
def summary(files, pieces):
|
||||
for path, text in sorted(files.items()):
|
||||
print(f"wrote {path}: {len(text.splitlines())} lines")
|
||||
for target in (PARENT, SUPPORT, *OPS):
|
||||
fns = len(pieces[target]["functions"]) + len(pieces[target]["methods"])
|
||||
tests = sum(1 for i in pieces[target]["tests"] if re.search(r"#\[(tokio::)?test", i.text))
|
||||
print(f"{target}: {fns} production fns, {tests} tests")
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||||
parser.add_argument("--no-fmt", action="store_true", help="do not run rustfmt on the written files")
|
||||
parser.add_argument("--report", action="store_true", help="print the assignment and write nothing")
|
||||
parser.add_argument("--self-test", action="store_true", help="run the script's own tests")
|
||||
args = parser.parse_args(argv)
|
||||
if args.self_test:
|
||||
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SelfTest)
|
||||
return 0 if unittest.TextTestRunner(verbosity=1).run(suite).wasSuccessful() else 1
|
||||
try:
|
||||
return run(ROOT, not args.no_fmt, args.report)
|
||||
except SplitError as error:
|
||||
print(f"split_multipart_usecase: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
class SelfTest(unittest.TestCase):
|
||||
SAMPLE = "\n".join(
|
||||
[
|
||||
"// licence",
|
||||
"",
|
||||
"//! doc",
|
||||
"",
|
||||
"use a::b;",
|
||||
"",
|
||||
"/// doc",
|
||||
"fn one(",
|
||||
" x: u8,",
|
||||
") -> u8 {",
|
||||
' let s = "}";',
|
||||
" x",
|
||||
"}",
|
||||
"",
|
||||
"#[cfg(test)]",
|
||||
"mod tests {",
|
||||
" use super::*;",
|
||||
"",
|
||||
" #[test]",
|
||||
" fn t() {}",
|
||||
"}",
|
||||
]
|
||||
)
|
||||
|
||||
def test_items_split_on_balance_not_on_braces_in_strings(self):
|
||||
lines = self.SAMPLE.split("\n")[6:]
|
||||
items = split_items(lines, code_only("\n".join(lines)).split("\n"), "sample")
|
||||
self.assertEqual([i.name() for i in items], ["one", "tests"])
|
||||
self.assertTrue(items[1].cfg_test)
|
||||
|
||||
def test_use_tree_flattening(self):
|
||||
paths = flatten_use("a::{b, c::{d as e, f}, g::*}")
|
||||
self.assertEqual(paths, [("a::b", "b"), ("a::c::d", "e"), ("a::c::f", "f"), ("a::g::*", "*")])
|
||||
|
||||
def test_s3s_rewrite_skips_strings_and_identifiers(self):
|
||||
text = 'let a = s3s::dto::X; let b = "s3s::dto"; let c = rs3s::y;'
|
||||
self.assertEqual(rewrite_s3s(text), f'let a = {FUNNEL}dto::X; let b = "s3s::dto"; let c = rs3s::y;')
|
||||
|
||||
def test_unassigned_item_fails(self):
|
||||
lines = self.SAMPLE.split("\n")[6:13]
|
||||
items = split_items(lines, code_only("\n".join(lines)).split("\n"), "sample")
|
||||
with self.assertRaises(SplitError):
|
||||
assign(items, {}, "functions")
|
||||
with self.assertRaises(SplitError):
|
||||
assign(items, {"one": PARENT, "ghost": PARENT}, "functions")
|
||||
|
||||
def test_unclosed_item_fails(self):
|
||||
with self.assertRaises(SplitError):
|
||||
split_items(["fn a() {"], ["fn a() {"], "sample")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+60
-168
@@ -296,111 +296,10 @@ class PlannerTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.plan('--malicious')
|
||||
|
||||
def gateway_base(self):
|
||||
"""A base revision carrying the gateway pins and a lockfile."""
|
||||
manifest = (self.repo.root / 'Cargo.toml').read_text()
|
||||
self.repo.write('Cargo.toml', manifest + 'rustfs-gateway = { git = "https://example.invalid/gateway.git", rev = "aaaa", version = "0.1.0" }\n'
|
||||
'unrelated = { version = "1.0.0" }\n')
|
||||
self.repo.write('Cargo.lock', 'version = 4\n\n[[package]]\nname = "rustfs-gateway"\nversion = "0.1.0"\nsource = "git+https://example.invalid/gateway.git?rev=aaaa#aaaa"\n\n'
|
||||
'[[package]]\nname = "unrelated"\nversion = "1.0.0"\n')
|
||||
return self.repo.commit()
|
||||
|
||||
def test_gateway_smoke_is_skipped_on_pull_requests_without_gateway_stack_inputs(self):
|
||||
base = self.gateway_base()
|
||||
# Every path here changes the product or CI, and none is a gateway-stack input.
|
||||
for path, content in (
|
||||
('leaf/src/lib.rs', '// kms change\n'),
|
||||
('rustfs/src/admin/handlers/site_replication.rs', '// admin\n'),
|
||||
('rustfs/src/server/http.rs', '// shared listener\n'),
|
||||
('rustfs/src/server/stackless.rs', '// not the stack module\n'),
|
||||
('crates/gateway-adapterx/src/lib.rs', '// sibling name\n'),
|
||||
('README.md', '# docs\n'),
|
||||
('.config/e2e-full-selection.txt', 'other profile\n'),
|
||||
# The suite's contents are not gateway-stack inputs before T2.10:
|
||||
# an e2e-only pull request does not run the lane.
|
||||
('crates/e2e_test/src/lib.rs', '// new smoke test\n'),
|
||||
('crates/e2e_test/src/sts_query_compat_test.rs', '// new module\n'),
|
||||
('crates/e2e_test/Cargo.toml', '[package]\nname = "e2e_test"\nversion = "0.1.1"\n'),
|
||||
('.config/e2e-smoke-selection.txt', 'e2e_test::new_case\n'),
|
||||
('.config/nextest.toml', '[profile.e2e-smoke]\n'),
|
||||
('.github/workflows/build.yml', 'name: other\n'),
|
||||
('scripts/run.sh', 'echo\n'),
|
||||
('Cargo.toml', (self.repo.root / 'Cargo.toml').read_text().replace('unrelated = { version = "1.0.0" }', 'unrelated = { version = "1.0.1" }')),
|
||||
('Cargo.lock', (self.repo.root / 'Cargo.lock').read_text().replace('name = "unrelated"\nversion = "1.0.0"', 'name = "unrelated"\nversion = "1.0.1"')),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.repo.write(path, content)
|
||||
plan = self.repo.plan(self.repo.commit(), base=base)
|
||||
self.assertEqual(plan['gateway_smoke'], {'run': False, 'reason': 'no gateway-stack input changed'})
|
||||
base = plan['head']
|
||||
|
||||
def test_gateway_smoke_runs_on_pull_requests_that_change_a_gateway_stack_input(self):
|
||||
base = self.gateway_base()
|
||||
for path in (
|
||||
'crates/gateway-adapter/src/lib.rs', 'crates/gateway-adapter/Cargo.toml',
|
||||
'rustfs/src/server/gateway_stack.rs', 'rustfs/src/server/gateway_stack/authorizer.rs',
|
||||
'rustfs/src/server/gateway_handlers/bucket/list_buckets.rs',
|
||||
'rustfs/src/server/stack.rs', 'rustfs/src/server/stack/website.rs',
|
||||
'rustfs/src/config/config_struct.rs', 'rustfs/Cargo.toml',
|
||||
'.github/workflows/ci.yml', '.github/actions/test-runtime/action.yml',
|
||||
'scripts/ci_gate.py', 'scripts/e2e_binary.py',
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
target = self.repo.root / path
|
||||
self.repo.write(path, (target.read_text() if target.exists() else '') + '\n# changed\n')
|
||||
plan = self.repo.plan(self.repo.commit(), base=base)
|
||||
self.assertEqual(plan['gateway_smoke'], {'run': True, 'reason': f'gateway-stack input changed: {path}'})
|
||||
base = plan['head']
|
||||
# One gateway input among unrelated paths is enough.
|
||||
self.repo.write('leaf/src/lib.rs', '// unrelated\n')
|
||||
self.repo.write('rustfs/src/server/stack/metadata.rs', '// stack\n')
|
||||
self.assertTrue(self.repo.plan(self.repo.commit(), base=base)['gateway_smoke']['run'])
|
||||
|
||||
def test_gateway_smoke_follows_the_gateway_pins_inside_shared_manifests(self):
|
||||
self.gateway_base()
|
||||
manifest, lock = (self.repo.root / 'Cargo.toml').read_text(), (self.repo.root / 'Cargo.lock').read_text()
|
||||
for path, content in (
|
||||
('Cargo.toml', manifest.replace('rev = "aaaa"', 'rev = "bbbb"')),
|
||||
('Cargo.toml', manifest + 'rustfs-gateway-http = { git = "https://example.invalid/gateway.git", rev = "aaaa" }\n'),
|
||||
('Cargo.toml', manifest.replace('rustfs-gateway = ', 'renamed-gw = { package = "rustfs-gateway-types", version = "1" }\nrustfs-gateway = ')),
|
||||
('Cargo.toml', manifest + '[patch."https://example.invalid/gateway.git"]\nrustfs-gateway = { path = "leaf" }\n'),
|
||||
('Cargo.lock', lock.replace('rev=aaaa#aaaa', 'rev=bbbb#bbbb')),
|
||||
('Cargo.lock', lock + '\n[[package]]\nname = "rustfs-gateway-types"\nversion = "0.1.0"\n'),
|
||||
# A lockfile that cannot be parsed is not evidence of an unrelated change.
|
||||
('Cargo.lock', 'not toml ['),
|
||||
):
|
||||
with self.subTest(path=path, content=content[-60:]):
|
||||
self.repo.write('Cargo.toml', manifest)
|
||||
self.repo.write('Cargo.lock', lock)
|
||||
self.repo.write('leaf/src/lib.rs', f'// {len(content)}\n')
|
||||
start = self.repo.commit()
|
||||
self.repo.write(path, content)
|
||||
plan = self.repo.plan(self.repo.commit(), base=start)
|
||||
self.assertEqual(plan['gateway_smoke'], {'run': True, 'reason': f'gateway pin changed: {path}'})
|
||||
# Removing the lockfile leaves nothing to compare.
|
||||
self.repo.write('Cargo.toml', manifest)
|
||||
self.repo.write('Cargo.lock', lock)
|
||||
start = self.repo.commit()
|
||||
(self.repo.root / 'Cargo.lock').unlink()
|
||||
self.assertTrue(self.repo.plan(self.repo.commit(), base=start)['gateway_smoke']['run'])
|
||||
|
||||
def test_gateway_smoke_runs_on_every_non_pr_event_and_when_the_change_set_is_unknown(self):
|
||||
self.repo.write('leaf/src/lib.rs', '// unrelated\n')
|
||||
head = self.repo.commit()
|
||||
self.assertFalse(self.repo.plan(head)['gateway_smoke']['run'])
|
||||
for event in ('push', 'merge_group', 'schedule', 'workflow_dispatch'):
|
||||
self.assertEqual(self.repo.plan(head, event=event)['gateway_smoke'],
|
||||
{'run': True, 'reason': f'{event} validates the complete candidate'})
|
||||
for base in ('0' * 40, '', '--output=bad'):
|
||||
plan = gate.select_plan('pull_request', base, head, 'refs/heads/main', self.repo.root)
|
||||
self.assertEqual(plan['gateway_smoke'], {'run': True, 'reason': 'change set unavailable'})
|
||||
with patch.object(gate, 'read_policy', side_effect=ValueError('broken policy')):
|
||||
self.assertTrue(self.repo.plan(head)['gateway_smoke']['run'])
|
||||
|
||||
def test_actual_workflow_bootstrap_never_executes_pr_policy_and_upgrades_legacy_output(self):
|
||||
workflow = gate.workflow_document((Path(gate.__file__).resolve().parent.parent / '.github/workflows/ci.yml').read_text())
|
||||
body = next(step['run'] for step in workflow['jobs']['classify-changes']['steps'] if step.get('id') == 'scope')
|
||||
self.repo.write('scripts/ci_gate.py', "import os\nwith open(os.environ['GITHUB_OUTPUT'], 'a') as output: output.write('mode=docs\\npackages=[\"core\"]\\nplan={}\\ngateway_smoke=false\\n')\n")
|
||||
self.repo.write('scripts/ci_gate.py', "import os\nwith open(os.environ['GITHUB_OUTPUT'], 'a') as output: output.write('mode=docs\\npackages=[\"core\"]\\nplan={}\\n')\n")
|
||||
base = self.repo.commit()
|
||||
self.repo.write('scripts/ci_gate.py', 'raise SystemExit(71)\n')
|
||||
head = self.repo.commit()
|
||||
@@ -417,8 +316,6 @@ class PlannerTests(unittest.TestCase):
|
||||
self.assertEqual(values['connect'], 'true')
|
||||
self.assertEqual(values['packages'], '')
|
||||
self.assertEqual(values['plan'], '')
|
||||
# A refused base plan cannot keep the report-only lane off either.
|
||||
self.assertEqual(values['gateway_smoke'], 'true')
|
||||
self.repo.write('scripts/ci_gate.py', 'raise SystemExit(29)\n')
|
||||
broken = self.repo.commit()
|
||||
env['CI_BASE_SHA'] = broken
|
||||
@@ -438,9 +335,7 @@ class PlannerTests(unittest.TestCase):
|
||||
result = subprocess.run([sys.executable, '-I', str(script), 'select'], cwd=self.repo.root, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
values = dict(line.split('=', 1) for line in output.read_text().splitlines())
|
||||
self.assertEqual(set(values), {'mode', 'plan', 'connect', 'packages', 'full_validation', 'rehearse_shards', 'gateway_smoke', 'plan_contract'})
|
||||
self.assertEqual(values['gateway_smoke'], 'false')
|
||||
self.assertFalse(json.loads(values['plan'])['gateway_smoke']['run'])
|
||||
self.assertEqual(set(values), {'mode', 'plan', 'connect', 'packages', 'full_validation', 'rehearse_shards', 'plan_contract'})
|
||||
self.assertEqual(values['plan_contract'], gate.PLAN_CONTRACT)
|
||||
plan = json.loads(values['plan'])
|
||||
self.assertEqual(plan['policy']['revision'], self.repo.base)
|
||||
@@ -553,18 +448,16 @@ class GateTests(unittest.TestCase):
|
||||
expression = "needs.classify-changes.outputs.mode == 'full'"
|
||||
expression = f"({expression}) && (github.event_name != 'pull_request' || github.event.action != 'closed')"
|
||||
jobs[job] = {'name': job, 'if': expression, 'steps': []}
|
||||
jobs['classify-changes']['outputs'] = {'mode': '${{ steps.scope.outputs.mode }}'}
|
||||
jobs['workspace-lint-and-build']['steps'] = [{'run': 'cargo clippy --all-targets -- -D warnings'}]
|
||||
jobs[gate.LINT_JOB]['steps'] = [{'run': 'cargo clippy --all-targets -- -D warnings'}]
|
||||
jobs['workspace-tests']['needs'] = [gate.ARCHIVE_PRODUCER]
|
||||
jobs['classify-changes']['steps'] = [{'id': 'scope', 'run': f"grep -q '^plan_contract={gate.PLAN_CONTRACT}$' \"$GITHUB_OUTPUT\""}]
|
||||
# The gateway integration branch reruns e2e-tests' suite as a report-only lane.
|
||||
for job, (mirror, steps, env, selection) in gate.REPORT_ONLY_JOBS.items():
|
||||
for job, (mirror, steps, env) in gate.REPORT_ONLY_JOBS.items():
|
||||
key, _, value = env.partition(': ')
|
||||
jobs[mirror]['env'] = {key: 'legacy'}
|
||||
jobs[mirror]['steps'] = [{'name': step, 'run': f'echo {step}'} for step in steps]
|
||||
jobs[job] = copy.deepcopy(jobs[mirror])
|
||||
jobs[job].update({'name': f'{job} (report-only)', 'continue-on-error': True, 'env': {key: value},
|
||||
'needs': ['classify-changes'],
|
||||
'if': f"({jobs[mirror]['if']}) && (github.event_name != 'pull_request' || needs.classify-changes.outputs.{selection} != 'false')"})
|
||||
jobs['classify-changes']['outputs'][selection] = f"${{{{ steps.scope.outputs.{selection} || 'true' }}}}"
|
||||
jobs[job].update({'name': f'{job} (report-only)', 'continue-on-error': True, 'env': {key: value}})
|
||||
jobs[job]['steps'].append({'name': 'Upload report', 'uses': 'actions/upload-artifact', 'with': {'name': job}})
|
||||
jobs['test-and-lint-protocols']['strategy'] = {'matrix': {'features': [
|
||||
{'name': 'swift', 'flags': '--features swift'}, {'name': 'sftp', 'flags': '--features sftp'}, {'name': 'http3', 'flags': '--features http3,swift'}]}}
|
||||
@@ -627,7 +520,7 @@ class GateTests(unittest.TestCase):
|
||||
def test_report_only_stack_lane_mirrors_e2e_smoke_outside_the_aggregate(self):
|
||||
workflow = self.workflow()
|
||||
self.assertEqual(self.check(workflow), [])
|
||||
job, (mirror, steps, env, _) = next(iter(gate.REPORT_ONLY_JOBS.items()))
|
||||
job, (mirror, steps, env) = next(iter(gate.REPORT_ONLY_JOBS.items()))
|
||||
key = env.partition(': ')[0]
|
||||
run = steps.index('Run e2e smoke suite')
|
||||
for case, mutate in enumerate((
|
||||
@@ -650,64 +543,63 @@ class GateTests(unittest.TestCase):
|
||||
changed['jobs'][job]['steps'][-1]['with']['name'] = 'renamed-report'
|
||||
self.assertEqual(self.check(changed), [], "only the mirrored steps are bound")
|
||||
|
||||
def test_report_only_lane_runs_only_where_its_selection_says_and_always_outside_pull_requests(self):
|
||||
def test_parallel_clippy_lane_still_gates_the_aggregate(self):
|
||||
lint = gate.LINT_JOB
|
||||
# The aggregate requires the lint wherever the archive producer runs.
|
||||
for event, mode, required in (('pull_request', 'full', 'success'), ('pull_request', 'e2e', 'success'),
|
||||
('pull_request', 'docs', 'skipped'), ('merge_group', 'full', 'success'),
|
||||
('push', 'full', 'success'), ('schedule', 'full', 'success'),
|
||||
('workflow_dispatch', 'full', 'success')):
|
||||
plan = self.plan(event, mode)
|
||||
self.assertEqual(plan['jobs'][lint], required, (event, mode))
|
||||
self.assertEqual(plan['jobs'][lint], plan['jobs'][gate.ARCHIVE_PRODUCER], (event, mode))
|
||||
self.assertEqual(gate.verify_results(self.needs(plan), event, plan['ref']), [])
|
||||
for bad in ({'failure', 'cancelled', 'skipped', 'success', None} - {required}):
|
||||
needs = self.needs(plan)
|
||||
needs[lint]['result'] = bad
|
||||
errors = gate.verify_results(needs, event, plan['ref'])
|
||||
self.assertTrue(any(error.startswith(f'{lint}: expected {required}') for error in errors), (event, mode, bad, errors))
|
||||
needs = self.needs(plan)
|
||||
del needs[lint]
|
||||
self.assertTrue(any(lint in error and 'job set differs' in error for error in gate.verify_results(needs, event, plan['ref'])))
|
||||
self.assertIn(lint, [item['job'] for item in gate.configurations(self.plan()['jobs']) if item['id'] == 'workspace-lint'])
|
||||
# A base planner that predates the lint job cannot describe the job set.
|
||||
stale = self.plan()
|
||||
del stale['jobs'][lint]
|
||||
needs = self.needs(stale)
|
||||
self.assertTrue(gate.verify_results(needs, 'pull_request', stale['ref']))
|
||||
|
||||
def test_clippy_lane_is_wired_beside_the_archive_producer(self):
|
||||
workflow = self.workflow()
|
||||
self.assertEqual(self.check(workflow), [])
|
||||
job, (mirror, _, _, selection) = next(iter(gate.REPORT_ONLY_JOBS.items()))
|
||||
output = f'needs.classify-changes.outputs.{selection}'
|
||||
scope = workflow['jobs'][mirror]['if']
|
||||
# Equivalent spellings stay green: the guard pins behaviour, not text.
|
||||
changed = copy.deepcopy(workflow)
|
||||
changed['jobs'][job]['if'] = f"${{{{ ({output} != 'false' || github.event_name != 'pull_request') && ({scope}) }}}}"
|
||||
self.assertEqual(self.check(changed), [])
|
||||
lint, producer = gate.LINT_JOB, gate.ARCHIVE_PRODUCER
|
||||
step = {'run': 'python3 scripts/ci_test_artifact.py cargo lint --configuration workspace'}
|
||||
for case, (mutate, message) in enumerate((
|
||||
# Condition removed: the lane is back on every pull request.
|
||||
(lambda w: w['jobs'][job].pop('if'), 'runs on pull requests that change no gateway-stack input'),
|
||||
(lambda w: w['jobs'][job].update({'if': scope}), 'runs on pull requests that change no gateway-stack input'),
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && ({output} != 'false' || true)"}), 'runs on pull requests that change no gateway-stack input'),
|
||||
# Condition that also skips pushes to main (and every other non-PR event).
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && {output} != 'false'"}), 'is skipped on push'),
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && github.event_name == 'pull_request' && {output} != 'false'"}), 'is skipped on push'),
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && github.event_name != 'push' && (github.event_name != 'pull_request' || {output} != 'false')"}), 'is skipped on push'),
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && {output} != 'false'))"}), 'is skipped on merge_group'),
|
||||
(lambda w: w['jobs'][job].update({'if': 'false'}), 'is skipped on push'),
|
||||
# A selected pull request must run it; an unknown selection is not a skip.
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && github.event_name != 'pull_request'"}), 'is skipped on a pull request that changes a gateway-stack input'),
|
||||
(lambda w: w['jobs'][job].update({'if': f"({scope}) && (github.event_name != 'pull_request' || {output} == 'true')"}), 'is skipped on a pull request whose selection is unknown'),
|
||||
# The scope narrows the required lane's schedule; it never widens it.
|
||||
(lambda w: w['jobs'][job].update({'if': f"github.event_name != 'pull_request' || {output} != 'false'"}), f'runs where {mirror} does not'),
|
||||
# The expression is inert unless the output really reaches the job.
|
||||
(lambda w: w['jobs'][job].update({'needs': []}), 'must need classify-changes'),
|
||||
(lambda w: w['jobs']['classify-changes']['outputs'].pop(selection), f'classify-changes must publish {selection}'),
|
||||
(lambda w: w['jobs']['classify-changes']['outputs'].update({selection: 'false'}), f'classify-changes must publish {selection}'),
|
||||
# A status function would detach the lane from its build prerequisite.
|
||||
(lambda w: w['jobs'][job].update({'if': f"always() && ({w['jobs'][job]['if']})"}), 'must not override its prerequisites'),
|
||||
# Report-only means the job says so, and no step may turn a failed suite green.
|
||||
(lambda w: w['jobs'][job].pop('continue-on-error'), 'must stay report-only'),
|
||||
(lambda w: w['jobs'][job]['steps'][0].update({'continue-on-error': True}), 'cannot hide a failed step'),
|
||||
# The aggregate must keep depending on the lint, and the lint must keep running.
|
||||
(lambda w: w['jobs']['required-checks']['needs'].remove(lint), 'required-checks must directly depend on every verification job exactly once'),
|
||||
(lambda w: w['jobs'].pop(lint), 'CI verification jobs and the required gate contract differ'),
|
||||
(lambda w: w['jobs'][lint].update({'steps': []}), 'workspace Clippy must run on every code event, pull requests included'),
|
||||
(lambda w: w['jobs'][lint]['steps'][0].update({'if': "github.event_name != 'merge_group'"}), 'workspace Clippy must run on every code event, pull requests included'),
|
||||
(lambda w: w['jobs'][lint]['steps'][0].update({'run': 'cargo clippy --all-targets'}), 'workspace Clippy must run on every code event, pull requests included'),
|
||||
(lambda w: w['jobs'][lint].update({'continue-on-error': 'true'}), f'{lint} cannot convert a validation failure into success'),
|
||||
(lambda w: w['jobs'][lint]['steps'][0].update({'continue-on-error': 'true'}), f'{lint} cannot convert a validation failure into success'),
|
||||
# Its scope is the archive producer's scope on every event.
|
||||
(lambda w: w['jobs'][lint].update({'if': "github.event_name == 'merge_group'"}), f'{lint} must run under the same condition as {producer}'),
|
||||
(lambda w: w['jobs'][lint].update({'if': 'false'}), f'{lint} must run under the same condition as {producer}'),
|
||||
# One lint, in one place, off the path the workspace tests wait on.
|
||||
(lambda w: w['jobs'][producer]['steps'].append(dict(step)), f'workspace Clippy belongs to {lint} alone'),
|
||||
(lambda w: w['jobs'][producer].update({'needs': [lint]}), f'{producer} must not wait for {lint}'),
|
||||
(lambda w: w['jobs']['workspace-tests']['needs'].append(lint), f'workspace-tests must not wait for {lint}'),
|
||||
(lambda w: w['jobs']['workspace-tests'].update({'needs': ['classify-changes']}), f'workspace-tests must consume the archive of {producer}'),
|
||||
# The workflow fallback must name the contract this planner emits.
|
||||
(lambda w: w['jobs']['classify-changes']['steps'][0].update({'run': "grep -q '^plan_contract=3$' \"$GITHUB_OUTPUT\""}), f'classify-changes must fall back to the full matrix unless the plan carries contract {gate.PLAN_CONTRACT}'),
|
||||
(lambda w: w['jobs']['classify-changes'].update({'steps': []}), f'classify-changes must fall back to the full matrix unless the plan carries contract {gate.PLAN_CONTRACT}'),
|
||||
)):
|
||||
changed = copy.deepcopy(workflow)
|
||||
mutate(changed)
|
||||
errors = self.check(changed)
|
||||
self.assertTrue(any(message in error for error in errors), (case, message, errors))
|
||||
|
||||
def test_actual_workflow_schedules_the_gateway_lane_by_event_and_selection(self):
|
||||
workflow = gate.workflow_document((gate.ROOT / '.github/workflows/ci.yml').read_text())
|
||||
block = workflow['jobs']['e2e-tests-gateway']
|
||||
self.assertEqual(block['continue-on-error'], 'true')
|
||||
self.assertIn('classify-changes', block['needs'])
|
||||
self.assertIn('build-rustfs-debug-binary', block['needs'])
|
||||
|
||||
def runs(event, selected):
|
||||
return gate.condition(block['if'], {'github.event_name': event, 'needs.classify-changes.outputs.gateway_smoke': selected})
|
||||
|
||||
self.assertFalse(runs('pull_request', 'false'), 'unrelated pull request')
|
||||
self.assertTrue(runs('pull_request', 'true'), 'gateway-stack pull request')
|
||||
self.assertTrue(runs('pull_request', ''), 'selection unavailable')
|
||||
for event in ('push', 'merge_group', 'schedule', 'workflow_dispatch'):
|
||||
for selected in ('true', 'false', ''):
|
||||
self.assertTrue(runs(event, selected), (event, selected))
|
||||
self.assertEqual(workflow['jobs']['classify-changes']['outputs']['gateway_smoke'], "${{ steps.scope.outputs.gateway_smoke || 'true' }}")
|
||||
self.assertIn(message, self.check(changed), case)
|
||||
# The checked-in workflow satisfies the same contract.
|
||||
self.assertEqual(gate.check_workflow(Path(gate.__file__).resolve().parent.parent), [])
|
||||
|
||||
def test_requested_shards_and_union_proof_are_required_and_cannot_be_skipped(self):
|
||||
plan = self.plan('workflow_dispatch')
|
||||
@@ -738,7 +630,7 @@ class GateTests(unittest.TestCase):
|
||||
lambda w: w['jobs']['required-checks'].update({'if': "'always()' == 'always()'"}),
|
||||
lambda w: w['jobs']['required-checks']['steps'][0].update({'run': 'python3 scripts/ci_gate.py verify || true'}),
|
||||
lambda w: w['jobs']['typos'].update({'continue-on-error': 'true'}),
|
||||
lambda w: w['jobs']['workspace-lint-and-build']['steps'][0].update({'if': 'false'}),
|
||||
lambda w: w['jobs'][gate.LINT_JOB]['steps'][0].update({'if': 'false'}),
|
||||
lambda w: w['jobs']['test-and-lint-protocols']['strategy']['matrix']['features'].pop(),
|
||||
lambda w: w['jobs'].update({'unaccounted': {'name': 'New'}}),
|
||||
lambda w: w['on']['pull_request'].update({'paths': ['README.md']}),
|
||||
|
||||
Reference in New Issue
Block a user