Compare commits
322
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a814bf3ae3 | ||
|
|
d902ac4f34 | ||
|
|
80d0c51389 | ||
|
|
daeaf40e2c | ||
|
|
7b17d46ca9 | ||
|
|
c006f84461 | ||
|
|
9a1a15ca58 | ||
|
|
4bbc1d5640 | ||
|
|
8ddbf05924 | ||
|
|
0e27f57c40 | ||
|
|
e760d44e65 | ||
|
|
9e27ede8f0 | ||
|
|
5c40570edd | ||
|
|
a6c5d80069 | ||
|
|
5f3620a00d | ||
|
|
5d22fe0934 | ||
|
|
f1f8057154 | ||
|
|
7b7e5f38f7 | ||
|
|
52703e0da6 | ||
|
|
6ab14981ba | ||
|
|
12b5e69a7b | ||
|
|
c00e5491b5 | ||
|
|
a6ea4ac8f3 | ||
|
|
2739330971 | ||
|
|
dda841d8de | ||
|
|
09ec797a66 | ||
|
|
a169dd01a6 | ||
|
|
64739778c4 | ||
|
|
030a87013c | ||
|
|
94a61d789a | ||
|
|
b90443f697 | ||
|
|
ba7785d61d | ||
|
|
31031f2a46 | ||
|
|
61914ac4ad | ||
|
|
0e92eac2c2 | ||
|
|
3699b6b88d | ||
|
|
4154a3b7ca | ||
|
|
a42046b79c | ||
|
|
f4cc919401 | ||
|
|
3f9ec4275b | ||
|
|
d628a2f48b | ||
|
|
9cf276ed24 | ||
|
|
8f7ccab4ed | ||
|
|
78935c34c9 | ||
|
|
fda93adafe | ||
|
|
2bd1d70729 | ||
|
|
38e93f553d | ||
|
|
41d7db6d57 | ||
|
|
57b0a136d5 | ||
|
|
2ebf8bc138 | ||
|
|
0e56ef4f1c | ||
|
|
0b96a992d6 | ||
|
|
bbc96c43a2 | ||
|
|
ec9aabcf00 | ||
|
|
efcd960b65 | ||
|
|
9f245e3fd4 | ||
|
|
ba15588ce8 | ||
|
|
62a767a52d | ||
|
|
7c2361757e | ||
|
|
a96dd7d289 | ||
|
|
2ada8a5cfb | ||
|
|
f69087a457 | ||
|
|
6ef7bac071 | ||
|
|
9ea5cd59ca | ||
|
|
286626c1bd | ||
|
|
ff47714363 | ||
|
|
5a424219d2 | ||
|
|
2ef9519bea | ||
|
|
0c85dbd8e9 | ||
|
|
a45cf6b521 | ||
|
|
b059569744 | ||
|
|
7ecb44ea60 | ||
|
|
08d16d067d | ||
|
|
766d88cc89 | ||
|
|
3c1e172600 | ||
|
|
4cd38feae7 | ||
|
|
a4377b6351 | ||
|
|
51041e917e | ||
|
|
32346f159a | ||
|
|
45f706b274 | ||
|
|
a5386093d8 | ||
|
|
b1b3655bf8 | ||
|
|
b49c9a07d1 | ||
|
|
5e05bd5485 | ||
|
|
2b61990ec6 | ||
|
|
b610d5a55d | ||
|
|
8f0d4a20d1 | ||
|
|
65a7cc9cd4 | ||
|
|
aa56d4b847 | ||
|
|
1590d9107b | ||
|
|
c7f201e6cb | ||
|
|
a1ed41e109 | ||
|
|
4bc9dc482a | ||
|
|
c0c208d89a | ||
|
|
7cac528de3 | ||
|
|
eaf0d4da81 | ||
|
|
bab9049cb0 | ||
|
|
df5ae13fd0 | ||
|
|
37a50f1e5d | ||
|
|
4f4d268155 | ||
|
|
9db1d6f06b | ||
|
|
be22175035 | ||
|
|
45c03ca37f | ||
|
|
0ad6bf72cb | ||
|
|
5243bee746 | ||
|
|
42d47b5f1e | ||
|
|
9118a6e344 | ||
|
|
f469869620 | ||
|
|
5834949c56 | ||
|
|
f51b06f0ae | ||
|
|
a65b306fb0 | ||
|
|
b93e7b2355 | ||
|
|
8f196f2f20 | ||
|
|
59fd318192 | ||
|
|
75a71fe6d7 | ||
|
|
ba629bdae0 | ||
|
|
5cce18fef2 | ||
|
|
54e2dce495 | ||
|
|
6f0a371f01 | ||
|
|
1bcb396752 | ||
|
|
c0c5fc22f9 | ||
|
|
6886f7cac4 | ||
|
|
032c5f9ac6 | ||
|
|
4e749d7046 | ||
|
|
51449f0975 | ||
|
|
90f64c60af | ||
|
|
0f987714a1 | ||
|
|
2bd83a5276 | ||
|
|
6a99edab50 | ||
|
|
be1562e089 | ||
|
|
5a0367969a | ||
|
|
0c155b1656 | ||
|
|
9db29c8a6f | ||
|
|
b4a78fc907 | ||
|
|
02317dd36f | ||
|
|
b4e6c1b081 | ||
|
|
bcfed065c1 | ||
|
|
9a89434644 | ||
|
|
b15928220f | ||
|
|
cf37bc418c | ||
|
|
76a861f815 | ||
|
|
5ce884f605 | ||
|
|
0c4c1caef8 | ||
|
|
d9cd04e94e | ||
|
|
017ffb92f7 | ||
|
|
40f1356831 | ||
|
|
619f0fd9e8 | ||
|
|
9d68d63802 | ||
|
|
f41014ede7 | ||
|
|
68dd5bfb9f | ||
|
|
77d7404d77 | ||
|
|
97d2d344b2 | ||
|
|
3da319624f | ||
|
|
bb9491f782 | ||
|
|
9f12f344c9 | ||
|
|
82df9ec4fa | ||
|
|
40e6decc93 | ||
|
|
116119d93a | ||
|
|
6f39765498 | ||
|
|
d63ca1f5f5 | ||
|
|
4d43f1ea8a | ||
|
|
5c6e1abe7e | ||
|
|
3b0a28dd9b | ||
|
|
7be0d56be8 | ||
|
|
dcdaa37b84 | ||
|
|
ea07c781c4 | ||
|
|
7a7871ca67 | ||
|
|
c80d970d58 | ||
|
|
1f40c3ecd0 | ||
|
|
102fb767ce | ||
|
|
afb2e4f728 | ||
|
|
3d75e7b51f | ||
|
|
e4dfc6f45b | ||
|
|
c1c6a1e23f | ||
|
|
a8be4f2695 | ||
|
|
a1ebe9a3b3 | ||
|
|
e2193cc42c | ||
|
|
6f14a79089 | ||
|
|
de9e8faa27 | ||
|
|
2251f22c1a | ||
|
|
70a6a9e8dc | ||
|
|
fe453b7f5b | ||
|
|
ade7e320da | ||
|
|
f6d69ce643 | ||
|
|
762431c0c7 | ||
|
|
1fb5d5a19d | ||
|
|
8bd6d8c4db | ||
|
|
8f2b91f79b | ||
|
|
c2b2b4ffd4 | ||
|
|
4ceed58be4 | ||
|
|
507faf3a6a | ||
|
|
1607e9a376 | ||
|
|
f06b004f2d | ||
|
|
41546dee5d | ||
|
|
7d3f5545e7 | ||
|
|
d293ed71e5 | ||
|
|
114bb4acec | ||
|
|
29272480bd | ||
|
|
e136e95a20 | ||
|
|
f4ce1a8b3a | ||
|
|
9681f19bec | ||
|
|
20a9c12f86 | ||
|
|
86e969f63c | ||
|
|
51272d34dd | ||
|
|
cd1363d519 | ||
|
|
5142775387 | ||
|
|
2ed08c8bad | ||
|
|
60a0b1d6e7 | ||
|
|
170a4c7640 | ||
|
|
fc98dbb654 | ||
|
|
e091a7e702 | ||
|
|
eec0e0e056 | ||
|
|
1c5c28842a | ||
|
|
16ca65ccab | ||
|
|
ecefb5644b | ||
|
|
f473b6dbf8 | ||
|
|
af6545b689 | ||
|
|
a66b568ba0 | ||
|
|
232190a205 | ||
|
|
f52a389652 | ||
|
|
73cd1b5be2 | ||
|
|
40bf9f0425 | ||
|
|
eb0384c225 | ||
|
|
0e015360cc | ||
|
|
2bd1df3075 | ||
|
|
9935911e93 | ||
|
|
99fb77b164 | ||
|
|
ebff02304d | ||
|
|
57eaa8228d | ||
|
|
aa0a374aa4 | ||
|
|
1ec1a8d90d | ||
|
|
28b3ecf547 | ||
|
|
33341d5fcf | ||
|
|
2a43e021c9 | ||
|
|
8bf4f20890 | ||
|
|
d269b90201 | ||
|
|
83aa9c221b | ||
|
|
7f32c675ac | ||
|
|
8ccf7151f3 | ||
|
|
8c3aeaebed | ||
|
|
201c653dcd | ||
|
|
3ce01dcc73 | ||
|
|
0d15ce1865 | ||
|
|
415427f99d | ||
|
|
18cde00fad | ||
|
|
2788ef7229 | ||
|
|
5cedf73d7c | ||
|
|
f694a0000a | ||
|
|
ba8f2e90be | ||
|
|
43a10e3c24 | ||
|
|
11a90ce843 | ||
|
|
b52cdd69ae | ||
|
|
06ef472def | ||
|
|
d3c0714b3a | ||
|
|
b5b060a9a1 | ||
|
|
5bb9ffffcd | ||
|
|
6f6dd19cc7 | ||
|
|
31933c32f9 | ||
|
|
0e88a27d05 | ||
|
|
35e264a9f5 | ||
|
|
38d37121ca | ||
|
|
3f3b9fd426 | ||
|
|
a8e4b67d99 | ||
|
|
b2e60be647 | ||
|
|
14e3eb787d | ||
|
|
76a863b3ea | ||
|
|
e196a134cc | ||
|
|
9cda615519 | ||
|
|
9a8ca3a7a9 | ||
|
|
32cc7c8fcf | ||
|
|
2bb0ab18b2 | ||
|
|
8dc2537178 | ||
|
|
0e70dbd511 | ||
|
|
34bbc1adb3 | ||
|
|
450ec7f66a | ||
|
|
4ddc728c9d | ||
|
|
dc8177c2b8 | ||
|
|
c442c543d3 | ||
|
|
0d30c69e5f | ||
|
|
ba4cd69438 | ||
|
|
ab8f8b94dc | ||
|
|
66da8565c9 | ||
|
|
17d7145e3c | ||
|
|
23a2c7d776 | ||
|
|
5f72209446 | ||
|
|
b6ba89d9e4 | ||
|
|
648d5166e2 | ||
|
|
84eb5aebef | ||
|
|
20d1266496 | ||
|
|
f7003dfddd | ||
|
|
2d7120460b | ||
|
|
b91845c98c | ||
|
|
7ba6f8cb33 | ||
|
|
f44b30c61a | ||
|
|
c6590182ed | ||
|
|
6d85a9c6a8 | ||
|
|
26e6508b64 | ||
|
|
51e369be6c | ||
|
|
ddc4120c82 | ||
|
|
87235ffd28 | ||
|
|
62c465ecef | ||
|
|
f1b92af4a3 | ||
|
|
7c1a76dfd9 | ||
|
|
3ddf1a81ac | ||
|
|
cc412914d5 | ||
|
|
2fccfdeabe | ||
|
|
9815694301 | ||
|
|
0e79106c2f | ||
|
|
12a9e654b5 | ||
|
|
6b5e0feef6 | ||
|
|
da90d02c15 | ||
|
|
a930152d5a | ||
|
|
f9d45e41e1 | ||
|
|
04e1ea227a | ||
|
|
1a3be70d98 | ||
|
|
8679570c2a | ||
|
|
7143697a5f | ||
|
|
a34310a58f | ||
|
|
2e60029079 | ||
|
|
2c3e68ad89 | ||
|
|
2f0918f60b | ||
|
|
5b951de2b7 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: rustfs-release-publish
|
||||
description: "Run the end-to-end RustFS console gate, version bump, preview validation, and final-tag publication pipeline. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
||||
description: "Run the end-to-end RustFS console gate, version bump, preview validation, human confirmation, and final-tag publication pipeline. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
||||
---
|
||||
# RustFS Release Publish (preview-validated pipeline)
|
||||
|
||||
@@ -17,6 +17,7 @@ check console main against its latest Release
|
||||
-> tag <preview-tag> at that commit -> CI green
|
||||
-> verify preview Release assets -> run binary locally + console checks
|
||||
-> validate with latest rc client
|
||||
-> report preview acceptance results -> STOP for explicit human confirmation
|
||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||
```
|
||||
|
||||
@@ -60,6 +61,8 @@ Rules:
|
||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
||||
- Completing preview acceptance does not authorize the final tag. After Phases 3–5 pass, report the acceptance evidence and stop until the user explicitly confirms continuation. The original release request, an earlier confirmation, silence, or an automated follow-up does not satisfy this gate.
|
||||
- Confirmation is scoped to the reported `<target>`, `<preview-tag>`, and `PREVIEW_HASH`. A failed or repeated acceptance cycle, including any new preview iteration, invalidates prior confirmation and requires a new one.
|
||||
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
||||
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
||||
|
||||
@@ -207,6 +210,12 @@ rc alias remove preview
|
||||
|
||||
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
|
||||
|
||||
### Manual confirmation gate
|
||||
|
||||
After every Phase 3–5 check passes, report the target, preview tag, `PREVIEW_HASH`, preview Release URL, console result, and rc matrix, then explicitly ask the user whether to publish the final tag. End the turn without creating or pushing `<target>`.
|
||||
|
||||
Continue to Phase 6 only after a new user reply explicitly confirms the reported target, preview tag, and commit. A clear affirmative reply to that exact report, such as `确认继续`, is sufficient; if the reply is ambiguous or any reported value changed, ask again.
|
||||
|
||||
## Phase 6 — Publish the final tag on the validated commit
|
||||
|
||||
No second version bump, no release branch. The final tag goes on the exact commit the preview validated:
|
||||
@@ -229,5 +238,6 @@ Always report:
|
||||
|
||||
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||
- Manual confirmation gate status (`WAITING_FOR_CONFIRMATION` or `CONFIRMED`) and its exact target, preview tag, and `PREVIEW_HASH`.
|
||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, and the rc command matrix.
|
||||
- Any deviation from this pipeline and why the user approved it.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Report-only calibration baseline from https://github.com/rustfs/rustfs/actions/runs/29394996173.
|
||||
# Update counts only with a linked coverage run and a reviewed explanation.
|
||||
phase = "report-only"
|
||||
allowed_drop_percentage_points = 1.0
|
||||
|
||||
[crates."crates/iam"]
|
||||
covered = 5149
|
||||
count = 8131
|
||||
|
||||
[crates."crates/kms"]
|
||||
covered = 2950
|
||||
count = 4200
|
||||
|
||||
[crates."crates/policy"]
|
||||
covered = 4636
|
||||
count = 5464
|
||||
|
||||
[crates."crates/crypto"]
|
||||
covered = 469
|
||||
count = 494
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=b4ae71aa894e5c7795ae3eb8116f1777a7601d0f5db3898be2e48faf3329bd9b
|
||||
sha256-linux=433debd9d9defa832986269abdf0f1d131597b2d7a417ce930e17c1fd47d85ba
|
||||
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
|
||||
sha256-linux=c8315465f50c194faee36141cdbb1e15e59271e524d948564a69e2d5eb408f2a
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=ec27cde6ce6400723c4b372bfbd2ac61709c744294e4810af765e8a808d8e31d
|
||||
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
|
||||
|
||||
@@ -45,6 +45,11 @@ logging-guardrails-check: ## Check logging guardrails for redaction and noise re
|
||||
@echo "🪵 Checking logging guardrails..."
|
||||
./scripts/check_logging_guardrails.sh
|
||||
|
||||
.PHONY: error-other-ratchet-check
|
||||
error-other-ratchet-check: ## Check the ecstore ::other(format!) quorum-bucketing ratchet stays shrink-only
|
||||
@echo "🪣 Checking error other(format!) ratchet..."
|
||||
./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
.PHONY: tokio-io-uring-check
|
||||
tokio-io-uring-check: ## Check tokio io-uring runtime feature stays removed
|
||||
@echo "🚫 Checking tokio io-uring feature guard..."
|
||||
@@ -75,6 +80,11 @@ embedded-secrets-check: ## Check no private key material or credential literal i
|
||||
@echo "🔑 Checking embedded secret material guard..."
|
||||
./scripts/check_embedded_secrets.sh
|
||||
|
||||
.PHONY: offline-enrollment-e2e-check
|
||||
offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offline enrollment E2E root
|
||||
@echo "🔐 Checking the offline enrollment E2E root boundary..."
|
||||
./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
.PHONY: test-wiring-check
|
||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||
@echo "🧪 Checking test wiring..."
|
||||
|
||||
@@ -19,13 +19,13 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||
@echo "✅ Fast development checks passed!"
|
||||
|
||||
@@ -34,8 +34,11 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_exact_1mib_handoff_abba.sh
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
./scripts/test_fuzz_runner.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_security_coverage.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/s3-tests/test_report_compat.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
|
||||
+58
-12
@@ -1,5 +1,7 @@
|
||||
# nextest configuration for RustFS.
|
||||
#
|
||||
experimental = ["setup-scripts"]
|
||||
|
||||
# Serialize the ecstore tests that share the process-wide disk registry or
|
||||
# exercise a multi-disk commit handoff across nextest process boundaries.
|
||||
#
|
||||
@@ -44,7 +46,27 @@ e2e-reliability = { max-threads = 1 }
|
||||
e2e-inline-boundaries = { max-threads = 1 }
|
||||
e2e-cluster-nightly = { max-threads = 1 }
|
||||
|
||||
# These exact regression scenarios build deep async storage futures that exceed
|
||||
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
|
||||
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
|
||||
[scripts.setup.ecstore-large-stack]
|
||||
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
|
||||
# The serial ILM selection builds the same deep storage futures in both the
|
||||
# lifecycle transition module and scanner integration binary. Different tests
|
||||
# in each have overflowed first across otherwise unrelated CI runs.
|
||||
[scripts.setup.lifecycle-large-stack]
|
||||
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
|
||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
@@ -78,6 +100,12 @@ test-group = 'embedded-test-ports'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# The durable ILM decommission regressions build isolated multi-pool stores and
|
||||
# deliberately take source or target disks offline while checking fencing.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
||||
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
||||
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
||||
@@ -107,9 +135,10 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||
# does not cross nextest process boundaries, so keep these tests in one group.
|
||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||
# one group.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,6 +156,14 @@ fail-fast = false
|
||||
# marker is the observable signal the flake policy is built around.
|
||||
path = "junit.xml"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
|
||||
# ===========================================================================
|
||||
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
|
||||
#
|
||||
@@ -159,6 +196,15 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
|
||||
retries = 2
|
||||
|
||||
# Serialize the relocated-pool GET resume regression under the ci profile too
|
||||
# (see the matching default-profile override near the top). No longer a
|
||||
# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707,
|
||||
# which made the staging tolerate quorum-tolerated disk gaps; only the 8-disk
|
||||
# cross-disk-IO serialization remains.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
||||
# profile too (see the e2e-reliability test-group note near the top). Not a
|
||||
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
||||
@@ -174,10 +220,6 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Match the default-profile embedded test isolation without quarantining or
|
||||
# retrying failures in CI.
|
||||
[[profile.ci.overrides]]
|
||||
@@ -190,6 +232,10 @@ test-group = 'embedded-test-ports'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
||||
# too (see the matching default-profile override near the top). No retries.
|
||||
[[profile.ci.overrides]]
|
||||
@@ -305,7 +351,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
# the target incl. multipart and the resync path, SSE-C and
|
||||
# target-without-KMS stay fail-closed), and one guards event/history
|
||||
# observers.
|
||||
# * 12 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# * 13 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# servers and drives the cross-process site-replication control plane.
|
||||
# * 1 `_real_three_node` site-replication test.
|
||||
# * 1 `_real_single_node` service-account round-trip test.
|
||||
@@ -325,8 +371,8 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
#
|
||||
# Wired by .github/workflows/e2e-replication-nightly.yml (schedule +
|
||||
# workflow_dispatch), which builds the rustfs binary once, installs awscurl so
|
||||
# the STS dual-node test actually exercises its path (it skips gracefully with
|
||||
# a visible log line when awscurl is absent), and routes scheduled failures
|
||||
# the STS dual-node test actually exercises its path (the test fails when
|
||||
# awscurl is absent), and routes scheduled failures
|
||||
# through .github/actions/schedule-failure-issue (ci-8). Explicit division of
|
||||
# labor with e2e-full: these tests run only in the consolidated nightly
|
||||
# workflow, not in the merge/main lane.
|
||||
@@ -396,10 +442,10 @@ path = "junit.xml"
|
||||
# object_lambda) — too heavy for the merge budget; they run in the
|
||||
# e2e-nightly serial cluster-fault lane.
|
||||
# * replication_extension_test — repl-1 already splits it into the PR
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (55 slow) lanes and reserves
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||
# it for those, so e2e-full does not double-run it.
|
||||
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
||||
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
||||
# manual-localhost:9000 reliant tests are ci-13's migration.
|
||||
#
|
||||
# Each e2e test spawns its own single-node rustfs server on a random port with
|
||||
# an isolated temp dir (crates/e2e_test/src/common.rs), so the set is
|
||||
@@ -443,5 +489,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
# if the selected count drops below this number, so a rename or removal that
|
||||
# thins the security smoke gate must update this file in the same PR.
|
||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||
16
|
||||
18
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
|
||||
global:
|
||||
scrape_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
cluster: 'rustfs-dev' # Label to identify the cluster
|
||||
replica: '1' # Replica identifier
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/rules/*.yml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'otel-collector'
|
||||
static_configs:
|
||||
- targets: [ 'otel-collector:8888' ] # Scrape metrics from Collector
|
||||
scrape_interval: 10s
|
||||
|
||||
- job_name: 'rustfs-app-metrics'
|
||||
static_configs:
|
||||
- targets: [ 'otel-collector:8889' ] # Application indicators
|
||||
scrape_interval: 15s
|
||||
metric_relabel_configs:
|
||||
- source_labels: [ __name__ ]
|
||||
regex: 'go_.*'
|
||||
action: drop # Drop Go runtime metrics if not needed
|
||||
|
||||
- job_name: 'tempo'
|
||||
static_configs:
|
||||
- targets: [ 'tempo:3200' ] # Scrape metrics from Tempo
|
||||
|
||||
- job_name: 'jaeger'
|
||||
static_configs:
|
||||
- targets: [ 'jaeger:14269' ] # Jaeger admin port (14269 is standard for admin/metrics)
|
||||
|
||||
- job_name: 'loki'
|
||||
static_configs:
|
||||
- targets: [ 'loki:3100' ]
|
||||
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: [ 'localhost:9090' ]
|
||||
|
||||
- job_name: 'vulture'
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'vulture:8080'
|
||||
|
||||
otlp:
|
||||
promote_resource_attributes:
|
||||
- service.instance.id
|
||||
- service.name
|
||||
- service.namespace
|
||||
- cloud.availability_zone
|
||||
- cloud.region
|
||||
- container.name
|
||||
- deployment.environment.name
|
||||
- k8s.cluster.name
|
||||
- k8s.container.name
|
||||
- k8s.cronjob.name
|
||||
- k8s.daemonset.name
|
||||
- k8s.deployment.name
|
||||
- k8s.job.name
|
||||
- k8s.namespace.name
|
||||
- k8s.pod.name
|
||||
- k8s.replicaset.name
|
||||
- k8s.statefulset.name
|
||||
translation_strategy: NoUTF8EscapingWithSuffixes
|
||||
|
||||
storage:
|
||||
tsdb:
|
||||
out_of_order_time_window: 30m
|
||||
@@ -14,9 +14,10 @@
|
||||
|
||||
name: "Schedule Failure Issue"
|
||||
description: >-
|
||||
Open (or update) a tracking issue when a scheduled workflow run fails.
|
||||
Open (or update) a tracking issue when a scheduled workflow run fails or
|
||||
does not complete normally.
|
||||
Dedupes by workflow name: if an open issue titled
|
||||
"[scheduled-failure] <workflow name>" already exists, the failure is
|
||||
"[scheduled-failure] <workflow name>" already exists, the result is
|
||||
appended as a comment; otherwise a new issue is created. This is the
|
||||
single alerting mechanism for all scheduled pipelines (backlog#1149 ci-8).
|
||||
|
||||
@@ -38,6 +39,30 @@ inputs:
|
||||
Set to an empty string to skip labeling.
|
||||
required: false
|
||||
default: "infrastructure"
|
||||
source-run-id:
|
||||
description: "Run ID to report. Defaults to the current workflow run."
|
||||
required: false
|
||||
default: ${{ github.run_id }}
|
||||
source-run-attempt:
|
||||
description: "Run attempt to report. Defaults to the current attempt."
|
||||
required: false
|
||||
default: ${{ github.run_attempt }}
|
||||
source-event:
|
||||
description: "Trigger event of the run being reported."
|
||||
required: false
|
||||
default: ${{ github.event_name }}
|
||||
source-ref-name:
|
||||
description: "Ref name of the run being reported."
|
||||
required: false
|
||||
default: ${{ github.ref_name }}
|
||||
source-sha:
|
||||
description: "Commit SHA of the run being reported."
|
||||
required: false
|
||||
default: ${{ github.sha }}
|
||||
details-file:
|
||||
description: "Optional Markdown file appended to the issue body."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -48,17 +73,22 @@ runs:
|
||||
GH_TOKEN: ${{ inputs.github-token }}
|
||||
WORKFLOW_NAME: ${{ inputs.workflow-name }}
|
||||
ISSUE_LABEL: ${{ inputs.label }}
|
||||
SOURCE_RUN_ID: ${{ inputs.source-run-id }}
|
||||
SOURCE_RUN_ATTEMPT: ${{ inputs.source-run-attempt }}
|
||||
SOURCE_EVENT: ${{ inputs.source-event }}
|
||||
SOURCE_REF_NAME: ${{ inputs.source-ref-name }}
|
||||
SOURCE_SHA: ${{ inputs.source-sha }}
|
||||
DETAILS_FILE: ${{ inputs.details-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
title="[scheduled-failure] ${WORKFLOW_NAME}"
|
||||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}"
|
||||
|
||||
# Failed job names for this run attempt. The alert job runs while the
|
||||
# run as a whole is still in progress, so inspect the jobs that have
|
||||
# already completed with a non-success conclusion.
|
||||
# Inspect the reported run attempt. It can be the current in-workflow
|
||||
# failure or a completed run observed by the external watchdog.
|
||||
failed_jobs="$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/attempts/${SOURCE_RUN_ATTEMPT}/jobs" \
|
||||
--paginate \
|
||||
--jq '.jobs[]
|
||||
| select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "cancelled")
|
||||
@@ -67,15 +97,26 @@ runs:
|
||||
failed_jobs="- (failed job not recorded yet — see the run page)"
|
||||
fi
|
||||
|
||||
details=""
|
||||
if [ -n "${DETAILS_FILE}" ]; then
|
||||
if [ -f "${DETAILS_FILE}" ]; then
|
||||
details="$(cat "${DETAILS_FILE}")"
|
||||
else
|
||||
details="Details file was not available: \`${DETAILS_FILE}\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
body="$(cat <<EOF
|
||||
Scheduled run of **${WORKFLOW_NAME}** failed.
|
||||
Run of **${WORKFLOW_NAME}** did not complete successfully.
|
||||
|
||||
- Run: ${run_url} (attempt ${GITHUB_RUN_ATTEMPT})
|
||||
- Event: \`${GITHUB_EVENT_NAME}\`
|
||||
- Ref: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA}\`
|
||||
- Run: ${run_url} (attempt ${SOURCE_RUN_ATTEMPT})
|
||||
- Event: \`${SOURCE_EVENT}\`
|
||||
- Ref: \`${SOURCE_REF_NAME}\` @ \`${SOURCE_SHA}\`
|
||||
|
||||
Failed jobs:
|
||||
Non-success jobs:
|
||||
${failed_jobs}
|
||||
|
||||
${details}
|
||||
EOF
|
||||
)"
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
||||
{
|
||||
"workflow": ".github/workflows/runner-hygiene.yml",
|
||||
"max_age_hours": 792,
|
||||
"never_ran_grace_until": "2026-09-02T06:37:00Z"
|
||||
}
|
||||
]
|
||||
@@ -46,7 +46,7 @@ on:
|
||||
# advisory could sit unnoticed for seven days. The check list is unchanged —
|
||||
# splitting it into a light daily advisories-only run and a weekly full run
|
||||
# would create runs where sources/bans/licenses go unverified.
|
||||
- cron: '0 3 * * *' # Daily 03:00 UTC (staggered after the midnight ci/build crons)
|
||||
- cron: '23 3 * * *' # Daily 03:23 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -52,7 +52,7 @@ on:
|
||||
- ".dockerignore"
|
||||
- "flake.lock"
|
||||
schedule:
|
||||
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
||||
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_docker:
|
||||
@@ -1032,3 +1032,23 @@ jobs:
|
||||
|
||||
echo "🎉 Released $TAG successfully!"
|
||||
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [build-check, prepare-platform-matrix, build-rustfs, build-summary]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -94,6 +94,9 @@ concurrency:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Swatinem/rust-cache hashes every RUST* variable. Keep this aligned with
|
||||
# ci.yml or the writer and readers use disjoint cache keys.
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||
@@ -101,7 +104,7 @@ jobs:
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -191,7 +194,7 @@ jobs:
|
||||
warm-ci-feat-rio:
|
||||
name: Warm ci-feat-rio
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -209,6 +212,9 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build ci-feat-rio superset
|
||||
env:
|
||||
# --all-targets links the same test binaries as the reader lane.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo build -p rustfs -p rustfs-ecstore --all-targets --features rio-v2
|
||||
cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||
@@ -219,7 +225,7 @@ jobs:
|
||||
warm-ci-feat-proto:
|
||||
name: Warm ci-feat-proto
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -237,6 +243,9 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build ci-feat-proto superset
|
||||
env:
|
||||
# Avoid an unbounded burst of concurrent test-binary links (#5394).
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo build -p rustfs -p rustfs-protocols --all-targets --features swift
|
||||
cargo build -p rustfs -p rustfs-protocols --all-targets --features sftp
|
||||
|
||||
@@ -126,7 +126,10 @@ jobs:
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: python3 ./scripts/check_test_wiring.py
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
+218
-51
@@ -59,7 +59,7 @@ on:
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC
|
||||
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -142,6 +142,9 @@ jobs:
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check error other(format!) ratchet
|
||||
run: ./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
@@ -161,7 +164,10 @@ jobs:
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: python3 ./scripts/check_test_wiring.py
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
@@ -178,22 +184,14 @@ jobs:
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
# Both lines are required. Job-level `permissions` replaces the workflow
|
||||
# block rather than merging with it, so declaring only `actions: write`
|
||||
# would drop `contents: read` and break this job's checkout and the
|
||||
# repo-token the setup action hands to setup-protoc.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
# This job's token can cancel runs and delete Actions caches. Checkout
|
||||
# otherwise writes it into .git/config, where a PR's own build.rs or
|
||||
# proc-macro could read it back out.
|
||||
# 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
|
||||
@@ -209,6 +207,9 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
|
||||
- name: Prepare test evidence
|
||||
run: |
|
||||
mkdir -p artifacts/test-and-lint
|
||||
@@ -307,6 +308,9 @@ jobs:
|
||||
} > artifacts/test-and-lint/doctest-diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
- name: Upload test reports and diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -341,41 +345,36 @@ jobs:
|
||||
- name: Run rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh
|
||||
|
||||
# Early stop. Once this job has failed the PR cannot merge, so the sibling
|
||||
# lanes are burning runners on a result nobody can act on: on run
|
||||
# 30674613104 three lanes had already failed while Test and Lint and the
|
||||
# rio-v2 variant kept going past 70 minutes.
|
||||
#
|
||||
# Only this job may cancel. The lanes that are NOT required checks
|
||||
# (protocols, ILM, e2e, s3-tests) must never hold that power: a flake in
|
||||
# one of them would turn the required "Test and Lint" into `cancelled`,
|
||||
# which blocks the merge. Today a maintainer can merge with sftp red, and
|
||||
# that has to stay true.
|
||||
#
|
||||
# These steps run last so the `if: always()` artifact upload above still
|
||||
# captures logs and diagnostics before the run goes away.
|
||||
# Record the reason before this job completes as FAILURE. A separate
|
||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
||||
# required check's failure verdict.
|
||||
- name: Annotate early-stop reason
|
||||
if: failure() && github.event_name == 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "## CI early-stop"
|
||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; cancelling run ${GITHUB_RUN_ID} to free runners."
|
||||
echo "Sibling jobs showing **cancelled** were stopped by this job, not by their own failure."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# curl rather than `gh`: every existing `gh` call in this repo runs on
|
||||
# ubuntu-latest, and the sm-standard-* images are custom and trimmed (they
|
||||
# ship no C toolchain, see the e2e job below), so `gh` is not known to
|
||||
# exist here.
|
||||
#
|
||||
# Fork PRs are excluded explicitly instead of relying on the error path:
|
||||
# their GITHUB_TOKEN is forced read-only and job-level permissions cannot
|
||||
# raise it, so the call would always 403. Skipping keeps their logs clean.
|
||||
- name: Cancel run on failure (same-repo PR only)
|
||||
if: >-
|
||||
failure() && github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
run: |
|
||||
{
|
||||
echo "## CI early-stop"
|
||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
||||
cancel-after-test-and-lint-failure:
|
||||
name: Cancel siblings after Test and Lint failure
|
||||
if: >-
|
||||
failure() && needs.test-and-lint.result == 'failure'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: [ test-and-lint ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Cancel remaining jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -383,7 +382,7 @@ jobs:
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel" || true
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
@@ -400,7 +399,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -430,17 +429,45 @@ jobs:
|
||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||
# noncurrent transition/expiry after an immediate compensation transition.
|
||||
- name: Run ignored ILM integration tests serially
|
||||
env:
|
||||
# Match the measured Test and Lint link budget. The default exposed
|
||||
# all 14 pod CPUs and a cold cache spent the full 80m compiling
|
||||
# without starting one ILM test (main run 32982910990).
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
cargo nextest run -j1 --run-ignored ignored-only \
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run -j1 --run-ignored ignored-only \
|
||||
-p rustfs-scanner -p rustfs \
|
||||
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))'
|
||||
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||
} > artifacts/ilm-integration/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload ILM test diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/ilm-integration
|
||||
target/nextest/ci/junit.xml
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -457,20 +484,89 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
|
||||
- name: Run rio-v2 clippy lints
|
||||
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
||||
|
||||
- name: Run rio-v2 feature tests
|
||||
env:
|
||||
# Match the main nextest lane's #5394 link-I/O guard. A cold feature
|
||||
# cache otherwise fans out enough rust-lld processes to exhaust this
|
||||
# job's 90-minute budget before any test starts.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2
|
||||
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||
# covers this leg too; the default profile is the local no-retry
|
||||
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2
|
||||
cargo test -p rustfs --doc --features rio-v2
|
||||
|
||||
connect-short-credential-boundary:
|
||||
name: Connect Short Credential Boundary
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Run short credential behavior tests
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
registration_enforces_build_profile_credential_lifetime -- --exact
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
|
||||
|
||||
- name: Reject short credentials in release builds
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
log="$(mktemp)"
|
||||
set +e
|
||||
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
|
||||
--features connect-e2e-short-credentials >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
cat "$log"
|
||||
expected='error: connect-e2e-short-credentials is restricted to debug builds'
|
||||
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
|
||||
expected_count="$(grep -Fxc "$expected" "$log" || true)"
|
||||
summary_count="$(grep -Fc "$summary" "$log" || true)"
|
||||
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
|
||||
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|
||||
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
|
||||
echo "release feature gate did not fail solely at the expected compile_error" >&2
|
||||
rm -f "$log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$log"
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
# On a PR, one failing protocol leg is enough to know the PR is not ready,
|
||||
# so stop the sibling leg instead of paying another ~40 minutes for it.
|
||||
@@ -501,13 +597,23 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
|
||||
- name: Run clippy with ${{ matrix.features.name }}
|
||||
run: |
|
||||
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
|
||||
|
||||
- name: Run tests with ${{ matrix.features.name }}
|
||||
env:
|
||||
# Keep feature-test linking under the same bounded concurrency as the
|
||||
# main nextest lane; Clippy is metadata-only and needs no such limit.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
||||
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||
# covers this leg too; the default profile is the local no-retry
|
||||
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
||||
|
||||
build-rustfs-debug-binary:
|
||||
name: Build RustFS Debug Binary
|
||||
@@ -678,6 +784,19 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
# Download after the cache restore so the freshly built binary from the
|
||||
# build job always wins over anything restored into target/debug.
|
||||
- name: Download debug binary
|
||||
@@ -800,6 +919,20 @@ jobs:
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
- name: Install mc
|
||||
env:
|
||||
MC_VERSION: RELEASE.2025-08-13T08-35-41Z
|
||||
MC_SHA256: 01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891
|
||||
run: |
|
||||
MC_BINARY="mc.linux-amd64.${MC_VERSION}"
|
||||
curl -fsSLo "$RUNNER_TEMP/mc" "https://github.com/minio/mc/releases/download/${MC_VERSION}/${MC_BINARY}"
|
||||
echo "${MC_SHA256} $RUNNER_TEMP/mc" | sha256sum --check --status
|
||||
chmod +x "$RUNNER_TEMP/mc"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify mc
|
||||
run: mc --version
|
||||
|
||||
- name: Install Vault
|
||||
run: |
|
||||
VAULT_VERSION="1.17.6"
|
||||
@@ -1032,3 +1165,37 @@ jobs:
|
||||
path: artifacts/s3tests-single/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs:
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
- build-rustfs-debug-binary-rio-v2
|
||||
- uring-integration
|
||||
- e2e-tests
|
||||
- e2e-full
|
||||
- e2e-tests-rio-v2
|
||||
- s3-implemented-tests
|
||||
- s3-lifecycle-behavior-tests
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -12,14 +12,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Weekly workspace line-coverage baseline (backlog#1153 infra-5).
|
||||
# Workspace line-coverage baseline and security-crate calibration
|
||||
# (backlog#1153 infra-5/infra-6).
|
||||
#
|
||||
# NON-BLOCKING by design: this workflow only runs on schedule and manual
|
||||
# dispatch, so it never attaches a status to a PR and must never be made a
|
||||
# required check. It exists to give coverage a visible baseline and trend
|
||||
# (per-crate table in the job summary, lcov artifact kept 90 days) — the
|
||||
# per-crate ratchet for the security-critical crates builds on it later
|
||||
# (backlog#1153 infra-6, report-only first per the ci-11 ladder).
|
||||
# NON-BLOCKING by design: the weekly job gives coverage a visible baseline and
|
||||
# trend, while relevant pull requests run a report-only security-crate
|
||||
# comparison. Neither job is a required check during calibration.
|
||||
#
|
||||
# Measurement scope matches the PR test gate (ci.yml "Run tests"):
|
||||
# `--workspace --exclude e2e_test` with the `ci` nextest profile. Doctests are
|
||||
@@ -31,13 +29,28 @@
|
||||
name: coverage
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "crates/iam/**"
|
||||
- "crates/kms/**"
|
||||
- "crates/policy/**"
|
||||
- "crates/crypto/**"
|
||||
- ".config/coverage-baselines.toml"
|
||||
- "scripts/coverage_per_crate.py"
|
||||
- "scripts/check_security_coverage.py"
|
||||
- ".github/workflows/coverage.yml"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
|
||||
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
||||
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
||||
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
||||
- cron: "0 7 * * 0"
|
||||
- cron: "43 7 * * 0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
# Only alert-on-failure needs more than read access; it declares its own
|
||||
# job-level `issues: write`.
|
||||
@@ -46,12 +59,14 @@ permissions:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Workspace coverage (weekly)
|
||||
name: Workspace line coverage
|
||||
runs-on: sm-standard-4
|
||||
# The instrumented build cannot reuse the regular CI cache (different
|
||||
# RUSTFLAGS), so a cold week rebuilds the workspace before running the
|
||||
# full suite; give it double the test job's 60-minute budget.
|
||||
timeout-minutes: 120
|
||||
# RUSTFLAGS), so a cold run rebuilds the workspace before running the
|
||||
# full suite. Two later exact-head runs exhausted 150 minutes before the
|
||||
# report steps, so allow one additional 90-minute cold-run margin while
|
||||
# keeping the calibration job bounded.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
# Match the PR gate's nextest semantics (ci.yml runs `--profile ci`):
|
||||
@@ -91,7 +106,9 @@ jobs:
|
||||
cargo llvm-cov report --json --output-path target/llvm-cov/coverage.json
|
||||
|
||||
- name: Write per-crate summary
|
||||
run: python3 scripts/coverage_per_crate.py target/llvm-cov/coverage.json >> "$GITHUB_STEP_SUMMARY"
|
||||
run: |
|
||||
python3 scripts/coverage_per_crate.py target/llvm-cov/coverage.json >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 scripts/check_security_coverage.py target/llvm-cov/coverage.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
|
||||
@@ -40,7 +40,7 @@ on:
|
||||
schedule:
|
||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
||||
# stale (01:30) and performance-ab (06:00).
|
||||
- cron: "0 4 * * *"
|
||||
- cron: "29 4 * * *"
|
||||
|
||||
# Only alert-on-failure needs more than read access; it declares its own
|
||||
# job-level `issues: write`.
|
||||
@@ -75,11 +75,7 @@ jobs:
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# awscurl lets the STS dual-node test actually exercise its path. Without
|
||||
# it the test skips gracefully with a visible log line
|
||||
# (`awscurl_available()` in crates/e2e_test/src/common.rs), so the lane
|
||||
# still passes — installing it just upgrades that one test from skip to
|
||||
# real coverage.
|
||||
# The STS dual-node test requires awscurl and fails if it is unavailable.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
@@ -87,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip awscurl
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify awscurl
|
||||
@@ -196,6 +192,12 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Install and verify protocol socket oracle
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq iproute2
|
||||
ss -tn state CLOSE-WAIT >/dev/null
|
||||
|
||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||
- name: Verify protocol e2e membership
|
||||
env:
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
# suite and reports promotion candidates. Regressions, unclassified tests,
|
||||
# incomplete execution, and infrastructure errors fail the job; classified
|
||||
# failures for not-yet-implemented features remain informational.
|
||||
# - Non-blocking upstream HEAD canary: collects current upstream node IDs and
|
||||
# reports new, removed, duplicate, or overlapping classifications without
|
||||
# making upstream drift a release gate.
|
||||
# - Manual runs (workflow_dispatch): same, with configurable mode/scope.
|
||||
#
|
||||
# All test execution is delegated to scripts/s3-tests/run.sh (single source of
|
||||
@@ -90,10 +93,14 @@ on:
|
||||
description: "Optional pytest -m expression"
|
||||
required: false
|
||||
default: ""
|
||||
testexpr:
|
||||
description: "Optional pytest -k expression"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# Weekly full sweep (Sunday 02:00 UTC): full suite, run against BOTH the
|
||||
# single-node and the 4-node distributed topologies (matrix below).
|
||||
- cron: "0 2 * * 0"
|
||||
- cron: "19 2 * * 0"
|
||||
|
||||
env:
|
||||
# main user
|
||||
@@ -116,6 +123,7 @@ env:
|
||||
XDIST: ${{ github.event.inputs.xdist || '4' }}
|
||||
MAXFAIL: ${{ github.event.inputs.maxfail || '0' }}
|
||||
MARKEXPR: ${{ github.event.inputs.markexpr || '' }}
|
||||
TESTEXPR: ${{ github.event.inputs.testexpr || '' }}
|
||||
S3_SHARD_COUNT: ${{ github.event_name == 'schedule' && '4' || github.event.inputs.shard-count || '1' }}
|
||||
TEST_TIMEOUT: "300"
|
||||
|
||||
@@ -173,9 +181,14 @@ jobs:
|
||||
|
||||
- name: Install Python tools
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip awscurl tox
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44" "tox==4.60.0"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify Python tools
|
||||
run: |
|
||||
test "$(python3 -c 'import importlib.metadata as m; print(m.version("awscurl"))')" = "0.44"
|
||||
test "$(python3 -c 'import importlib.metadata as m; print(m.version("tox"))')" = "4.60.0"
|
||||
|
||||
- name: Enable buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
@@ -269,14 +282,20 @@ jobs:
|
||||
EOF
|
||||
|
||||
cat > haproxy.cfg <<'EOF'
|
||||
global
|
||||
log stdout format raw local0 info
|
||||
|
||||
defaults
|
||||
mode http
|
||||
log global
|
||||
log-format '%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %tsc %HM %HP'
|
||||
timeout connect 5s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
|
||||
frontend fe_s3
|
||||
bind *:9000
|
||||
option http-buffer-request
|
||||
default_backend be_s3
|
||||
|
||||
backend be_s3
|
||||
@@ -292,7 +311,7 @@ jobs:
|
||||
- name: Wait for RustFS ready
|
||||
run: |
|
||||
for _ in {1..120}; do
|
||||
if curl -sf "http://${S3_HOST}:${S3_PORT}/health" >/dev/null 2>&1; then
|
||||
if curl -sf "http://${S3_HOST}:${S3_PORT}/health/ready" >/dev/null 2>&1; then
|
||||
echo "RustFS is ready"
|
||||
exit 0
|
||||
fi
|
||||
@@ -314,6 +333,7 @@ jobs:
|
||||
XDIST="${XDIST}" \
|
||||
MAXFAIL="${MAXFAIL}" \
|
||||
MARKEXPR="${MARKEXPR}" \
|
||||
TESTEXPR="${TESTEXPR}" \
|
||||
./scripts/s3-tests/run.sh
|
||||
|
||||
- name: Publish compatibility report
|
||||
@@ -342,6 +362,85 @@ jobs:
|
||||
name: s3tests-${{ env.TEST_MODE }}-shard-${{ matrix.shard-index }}
|
||||
path: artifacts/**
|
||||
|
||||
upstream-head-canary:
|
||||
name: Upstream HEAD classification canary
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install collection tool
|
||||
run: |
|
||||
python3 -m pip install --user "tox==4.60.0"
|
||||
python3 - <<'PY'
|
||||
from importlib.metadata import version
|
||||
|
||||
assert version("tox") == "4.60.0"
|
||||
PY
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Compare upstream HEAD classifications
|
||||
id: upstream-compare
|
||||
run: |
|
||||
ARTIFACT_DIR="artifacts/s3tests-upstream-head"
|
||||
UPSTREAM_DIR="${RUNNER_TEMP}/s3-tests-upstream"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
git clone --depth 1 https://github.com/ceph/s3-tests.git "${UPSTREAM_DIR}"
|
||||
git -C "${UPSTREAM_DIR}" rev-parse HEAD > "${ARTIFACT_DIR}/upstream-sha.txt"
|
||||
cp "${UPSTREAM_DIR}/s3tests.conf.SAMPLE" "${UPSTREAM_DIR}/s3tests.conf"
|
||||
(
|
||||
cd "${UPSTREAM_DIR}"
|
||||
S3TEST_CONF="${UPSTREAM_DIR}/s3tests.conf" tox -- \
|
||||
-q --collect-only s3tests/functional/test_s3.py \
|
||||
-m "not rustfs_never_marker"
|
||||
) 2>&1 | tee "${ARTIFACT_DIR}/collect.log"
|
||||
grep -E '^s3tests/functional/test_s3\.py::' \
|
||||
"${ARTIFACT_DIR}/collect.log" > "${ARTIFACT_DIR}/collected-nodeids.txt"
|
||||
python3 scripts/s3-tests/report_compat.py \
|
||||
--lists-dir scripts/s3-tests \
|
||||
--collected-nodeids "${ARTIFACT_DIR}/collected-nodeids.txt" \
|
||||
--check-classifications-only 2>&1 | tee "${ARTIFACT_DIR}/classification-drift.txt"
|
||||
|
||||
- name: Publish canary report
|
||||
if: always()
|
||||
env:
|
||||
CANARY_OUTCOME: ${{ steps.upstream-compare.outcome }}
|
||||
run: |
|
||||
{
|
||||
echo "## ceph/s3-tests upstream HEAD canary"
|
||||
echo
|
||||
if [ -f artifacts/s3tests-upstream-head/upstream-sha.txt ]; then
|
||||
echo "Upstream HEAD: $(cat artifacts/s3tests-upstream-head/upstream-sha.txt)"
|
||||
fi
|
||||
echo
|
||||
echo '```text'
|
||||
if [ -s artifacts/s3tests-upstream-head/classification-drift.txt ]; then
|
||||
cat artifacts/s3tests-upstream-head/classification-drift.txt
|
||||
elif [ "${CANARY_OUTCOME}" != "success" ]; then
|
||||
echo "Canary did not complete; inspect the collection log artifact."
|
||||
else
|
||||
echo "No classification drift detected."
|
||||
fi
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload canary artifacts
|
||||
if: always() && env.ACT != 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: s3tests-upstream-head
|
||||
path: artifacts/s3tests-upstream-head/**
|
||||
retention-days: 14
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [s3tests]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Upgrade Compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/e2e-upgrade.yml"
|
||||
- "crates/e2e_test/src/common.rs"
|
||||
- "crates/e2e_test/src/lib.rs"
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/kms/**"
|
||||
- "crates/storage-api/**"
|
||||
- "rustfs/**"
|
||||
- "Cargo.lock"
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]*.[0-9]*.[0-9]*"
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
|
||||
jobs:
|
||||
direct-upgrade:
|
||||
name: Direct upgrade from rc.2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: e2e-direct-upgrade
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
|
||||
- name: Download pinned previous release
|
||||
env:
|
||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||
chmod +x "$SOURCE_DIR/rustfs"
|
||||
test -x "$SOURCE_DIR/rustfs"
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build current RustFS binary
|
||||
run: |
|
||||
cargo build --locked -p rustfs --bin rustfs
|
||||
: > target/debug/rustfs.features
|
||||
|
||||
- name: Run direct-upgrade compatibility test
|
||||
run: |
|
||||
cargo test --locked -p e2e_test \
|
||||
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
- name: Upload server logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: direct-upgrade-server-logs-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -30,7 +30,7 @@ on:
|
||||
- "Cargo.lock"
|
||||
- ".github/workflows/fuzz.yml"
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
- cron: "17 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
path: |
|
||||
fuzz/artifacts/**
|
||||
fuzz/corpus/${{ matrix.target }}/**
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
path: |
|
||||
fuzz/artifacts/**
|
||||
fuzz/corpus/${{ matrix.target }}/**
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||
#
|
||||
# While disabled, this workflow is deliberately absent from
|
||||
# .github/scheduled-validations.json — a disabled workflow can never satisfy the
|
||||
# freshness check. Whoever re-enables it must re-add the entry in the same
|
||||
# change so the freshness gate covers it again.
|
||||
#
|
||||
name: minio-interop
|
||||
|
||||
on:
|
||||
@@ -121,3 +126,21 @@ jobs:
|
||||
cargo nextest run --run-ignored ignored-only --no-tests=fail \
|
||||
-p "$INTEROP_PACKAGE" --features "$INTEROP_FEATURES" \
|
||||
-E "$INTEROP_FILTER"
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [minio-interop]
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -45,13 +45,6 @@
|
||||
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
||||
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
||||
|
||||
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||
# reading this file, which has already misled at least one audit — hence this
|
||||
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||
#
|
||||
name: mint
|
||||
|
||||
on:
|
||||
@@ -70,13 +63,13 @@ on:
|
||||
- core
|
||||
- full
|
||||
mint-image:
|
||||
description: "Mint image reference"
|
||||
description: "Mint image reference (empty = pinned default)"
|
||||
required: false
|
||||
default: "minio/mint:edge"
|
||||
default: ""
|
||||
schedule:
|
||||
# Weekly, after the Sunday s3-tests full sweep (starts 02:00 UTC, up to
|
||||
# 3h) has finished, so the two never contend for the same runner pool.
|
||||
- cron: "0 6 * * 0"
|
||||
- cron: "41 6 * * 0"
|
||||
|
||||
env:
|
||||
S3_ACCESS_KEY: rustfsadmin-ci
|
||||
@@ -162,7 +155,7 @@ jobs:
|
||||
- name: Wait for RustFS ready
|
||||
run: |
|
||||
for _ in {1..60}; do
|
||||
if curl -sf http://127.0.0.1:9000/health >/dev/null 2>&1; then
|
||||
if curl -sf http://127.0.0.1:9000/health/ready >/dev/null 2>&1; then
|
||||
echo "RustFS is ready"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -16,7 +16,7 @@ name: Nightly GNU Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
- cron: "7 0 * * *"
|
||||
timezone: "Asia/Shanghai"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -34,16 +34,15 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build x86_64 GNU
|
||||
runs-on: sm-standard-2
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -56,6 +55,155 @@ jobs:
|
||||
- name: Build RustFS
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Nightly snapshot name: rustfs-nightly-<YYYY-MM-DD> (Asia/Shanghai,
|
||||
# matching the schedule timezone so the file date always matches the
|
||||
# cron's intended day).
|
||||
DEB_DATE="$(TZ=Asia/Shanghai date +%Y-%m-%d)"
|
||||
DEB_FILE="rustfs-nightly-${DEB_DATE}.deb"
|
||||
PKG_DIR="rustfs-nightly-${DEB_DATE}"
|
||||
|
||||
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
|
||||
|
||||
BIN="target/x86_64-unknown-linux-gnu/release/rustfs"
|
||||
test -x "${BIN}" || { echo "rustfs binary not found: ${BIN}"; exit 1; }
|
||||
|
||||
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||
mkdir -p "${PKG_DIR}/usr/bin"
|
||||
mkdir -p "${PKG_DIR}/etc/default"
|
||||
mkdir -p "${PKG_DIR}/lib/systemd/system"
|
||||
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
|
||||
|
||||
cp "${BIN}" "${PKG_DIR}/usr/bin/rustfs"
|
||||
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
|
||||
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
|
||||
|
||||
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# See https://rustfs.com/docs/ for more information
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ROOT_USER=""
|
||||
# RUSTFS_ROOT_PASSWORD=""
|
||||
ENVEOF
|
||||
|
||||
# dpkg versions must start with a digit and cannot contain hyphens;
|
||||
# a date-based snapshot version keeps the nightly installable
|
||||
# alongside release packages.
|
||||
DEB_VERSION="${DEB_DATE//-/.}~nightly"
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/control" << EOF
|
||||
Package: rustfs
|
||||
Version: ${DEB_VERSION}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends: libc6 (>= 2.31)
|
||||
Maintainer: RustFS Team <[email protected]>
|
||||
Description: High-performance distributed object storage
|
||||
RustFS is a high-performance distributed object storage software
|
||||
built using Rust. It is compatible with MinIO and S3 API.
|
||||
Homepage: https://rustfs.com
|
||||
EOF
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
|
||||
/etc/default/rustfs
|
||||
CONFFILES
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||
fi
|
||||
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
|
||||
POSTINST
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||
systemctl stop rustfs
|
||||
fi
|
||||
PRERM
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
POSTRM
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
|
||||
|
||||
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
|
||||
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||
ls -lh "${DEB_FILE}"
|
||||
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload DEB artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ steps.deb.outputs.deb_file }}
|
||||
path: ${{ steps.deb.outputs.deb_file }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
||||
# so it can be downloaded later with a stable, unauthenticated URL —
|
||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||
- name: Upload DEB to Cloudflare R2
|
||||
if: env.R2_ACCESS_KEY_ID != ''
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "⚠️ R2 credentials missing, skipping upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||
|
||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
# Stable "latest" alias so tests can fetch the newest nightly
|
||||
# without knowing today's date.
|
||||
echo "📤 Uploading latest alias"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
echo "✅ R2 upload complete"
|
||||
|
||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||
#
|
||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||
@@ -89,11 +237,10 @@ jobs:
|
||||
# either casing.
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -178,11 +325,10 @@ jobs:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -194,3 +340,23 @@ jobs:
|
||||
|
||||
- name: Run HA leader failover live checks (three-node Raft cluster in Docker)
|
||||
run: bash scripts/test/vault_ha_kms_live.sh
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [build, kms-vault-lane, kms-vault-ha-failover]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: OIDC Keycloak Live
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
schedule:
|
||||
- cron: "23 2 * * 1"
|
||||
timezone: "Asia/Shanghai"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: oidc-keycloak-live-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
oidc-keycloak-live:
|
||||
name: OIDC Keycloak live gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: oidc-keycloak-live
|
||||
cache-save-if: "true"
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
- name: Build RustFS
|
||||
run: cargo build --locked -p rustfs --bin rustfs
|
||||
|
||||
- name: Install pinned request signer
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Run live Keycloak discovery, JWT and STS checks
|
||||
run: bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs
|
||||
|
||||
- name: Upload service logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: oidc-keycloak-live-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-keycloak-live-*/**/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: oidc-keycloak-live
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(needs.oidc-keycloak-live.result == 'failure' || needs.oidc-keycloak-live.result == 'cancelled')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -22,22 +22,15 @@
|
||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||
# blocked (rustfs/backlog#935 correction 1).
|
||||
|
||||
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||
# reading this file, which has already misled at least one audit — hence this
|
||||
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||
#
|
||||
name: Performance A/B
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # 06:00 UTC nightly, against main
|
||||
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
duration:
|
||||
description: "warp duration per round (short by default to fit the double-build budget)"
|
||||
description: "warp duration per round"
|
||||
required: false
|
||||
default: "12s"
|
||||
type: string
|
||||
@@ -46,12 +39,8 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
# Every main commit pre-builds and caches its release binary (perf-3) so the
|
||||
# nightly A/B restores a ready baseline instead of paying the double build.
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
env:
|
||||
@@ -59,83 +48,19 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# perf-3: on every push to main, build the release binary once and cache it
|
||||
# keyed by commit SHA (rustfs-baseline-<sha>). The warp-ab measurements
|
||||
# restore this instead of paying the ~32min-per-side source
|
||||
# build. That double build is what pushed the expanded 24-cell nightly past its
|
||||
# ceiling — 2026-07-11..07-14 all cancelled on the 120min timeout. Incremental
|
||||
# builds off the shared cargo cache keep each push cheap, and building on the
|
||||
# same sm-standard-2 runner the A/B measures on guarantees the cached binary is
|
||||
# ABI-identical. Do NOT source this from build.yml's per-merge artifact: those
|
||||
# are cancelled ~7/8 of the time and are not a reliable baseline.
|
||||
build-baseline-cache:
|
||||
name: Build + cache baseline binary
|
||||
if: github.event_name == 'push'
|
||||
runs-on: sm-standard-2
|
||||
# Latest-wins: consumers only ever restore the binary for the *current*
|
||||
# origin/main tip, so when pushes land faster than the ~65min build, a
|
||||
# superseded build's output is dead weight — cancel it instead of stacking
|
||||
# hour-long jobs on the shared runner pool. A skipped intermediate SHA at
|
||||
# most costs one same-commit self-heal in the A/B job.
|
||||
concurrency:
|
||||
group: perf-baseline-build-main
|
||||
cancel-in-progress: true
|
||||
# #4806 put thin LTO + codegen-units=1 on [profile.release], pushing a
|
||||
# single release build past 60min on this runner — every cache build on
|
||||
# 2026-07-15 died on the old 60min ceiling ("exceeded the maximum execution
|
||||
# time of 1h0m0s") and the cache never populated. The measured binary must
|
||||
# keep the production profile, so the budget absorbs the build instead.
|
||||
timeout-minutes: 100
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build release rustfs
|
||||
run: cargo build --release --bin rustfs
|
||||
|
||||
- name: Stage binary for cache
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p baseline-bin
|
||||
cp target/release/rustfs baseline-bin/rustfs
|
||||
|
||||
- name: Cache baseline binary by SHA
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ github.sha }}
|
||||
|
||||
warp-ab:
|
||||
name: Warp A/B budget gate
|
||||
# Always run on schedule / manual dispatch. Never on push — that event only
|
||||
# feeds build-baseline-cache above.
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: sm-standard-2
|
||||
# With perf-3's cached baseline binary the common (cache-hit) nightly is
|
||||
# measurement-only and finishes well under 50min. This ceiling stays
|
||||
# generous only to absorb the same-commit cache-miss self-heal (~65min
|
||||
# single build with the post-#4806 LTO profile + measurement). A timeout
|
||||
# surfaces via the alert-on-failure job (it fires on cancelled/timed-out,
|
||||
# not just failure). perf-6 recalibrates the budget once the noise study
|
||||
# lands.
|
||||
timeout-minutes: 120
|
||||
# A normal nightly restores the last successful binary and builds only the
|
||||
# candidate; daily access keeps that cache warm. A cache miss may build both
|
||||
# and needs room for the A/B run plus artifact and cache publication.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # baseline is built from origin/main
|
||||
fetch-depth: 0 # baseline may be an earlier successful scheduled head
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -163,24 +88,55 @@ jobs:
|
||||
fi
|
||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# perf-3: resolve the commits so the cache can be keyed by SHA. The
|
||||
# baseline is origin/main; the candidate is the checked-out ref. On the
|
||||
# nightly (checkout == main) they are the same commit, so one cached binary
|
||||
# serves both phases and the run does zero source builds.
|
||||
# A failed regression run must keep comparing against the last known-good
|
||||
# scheduled head. Otherwise the next nightly would absorb the regression
|
||||
# into its baseline and turn green without a fix.
|
||||
- name: Find last successful scheduled baseline
|
||||
id: scheduled_baseline
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "performance-ab.yml",
|
||||
event: "schedule",
|
||||
status: "success",
|
||||
per_page: 1,
|
||||
});
|
||||
return data.workflow_runs[0]?.head_sha ?? "";
|
||||
|
||||
# Manual runs compare a selected ref with current main. Scheduled runs
|
||||
# compare current main with the last successful scheduled head. With no
|
||||
# history, the first run measures the candidate against itself and seeds
|
||||
# that head only if the complete rig succeeds.
|
||||
- name: Resolve baseline / candidate commits
|
||||
id: commits
|
||||
env:
|
||||
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
baseline_sha="$(git rev-parse origin/main)"
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
|
||||
else
|
||||
baseline_sha="$(git rev-parse origin/main)"
|
||||
fi
|
||||
git cat-file -e "${baseline_sha}^{commit}"
|
||||
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
|
||||
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "baseline commit: $baseline_sha"
|
||||
echo "candidate commit: $candidate_sha"
|
||||
|
||||
# Exact-key restore of the baseline binary built by build-baseline-cache
|
||||
# when origin/main last landed. A miss (binary evicted or not built yet)
|
||||
# leaves cache-hit unset and the rig falls back to a source build.
|
||||
# Exact-key restore of the candidate binary saved by its successful
|
||||
# scheduled run. A miss leaves cache-hit unset and falls back to a source
|
||||
# build of that known-good head.
|
||||
- name: Restore cached baseline binary
|
||||
id: baseline_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
@@ -270,11 +226,11 @@ jobs:
|
||||
elif [[ "$selfheal_built" == "true" ]]; then
|
||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
||||
else
|
||||
base_src="isolated origin/main source build (saved as rustfs-baseline-$baseline_sha)"
|
||||
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
|
||||
fi
|
||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
||||
# Nightly on main: the candidate is the same commit as the baseline,
|
||||
# so reuse the one binary for both phases and skip all builds.
|
||||
# No commits landed since the last successful baseline, so reuse
|
||||
# the one binary for both phases and measure only rig drift.
|
||||
args+=(--candidate-bin "$base_bin")
|
||||
cand_src="same binary as baseline (same commit)"
|
||||
elif [[ "$candidate_built" == "true" ]]; then
|
||||
@@ -362,6 +318,23 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Stage successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp candidate-bin/rustfs baseline-bin/rustfs
|
||||
|
||||
- name: Cache successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
|
||||
|
||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||
|
||||
@@ -369,6 +342,10 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
if [[ -z "$status" ]]; then
|
||||
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$status" != "0" ]]; then
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
||||
exit "$status"
|
||||
|
||||
@@ -30,7 +30,7 @@ name: Runner Hygiene
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 1 * *" # Monthly, 1st at 06:00 UTC (after the daily audit cron)
|
||||
- cron: "37 6 1 * *" # Monthly, 1st at 06:37 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
name: RustFS Pool Expansion / Decommission Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.3)'
|
||||
required: false
|
||||
default: '1.0.0-rc.3'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
pools:
|
||||
description: 'Number of pools to expand to (2 = first rebalance only)'
|
||||
type: choice
|
||||
options:
|
||||
- '2'
|
||||
- '3'
|
||||
default: '3'
|
||||
storage_threshold:
|
||||
description: 'Stop writing when storage usage reaches N%'
|
||||
required: false
|
||||
default: '50'
|
||||
warp_duration:
|
||||
description: 'warp write duration (e.g. 5m, 10m)'
|
||||
required: false
|
||||
default: '10m'
|
||||
run_decommission:
|
||||
description: 'Run the pool decommission step (3-pool topology only)'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_before:
|
||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_after:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
schedule:
|
||||
# Nightly regression run; remove if you do not want a schedule.
|
||||
- cron: '0 21 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Only one pool-expansion test at a time: the workflow mutates a shared
|
||||
# test environment, so concurrent runs must not clobber each other.
|
||||
concurrency:
|
||||
group: rustfs-pool-expansion-test
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
# Package used by the scheduled run (workflow_dispatch inputs are empty for
|
||||
# schedule events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
|
||||
jobs:
|
||||
pool-expansion-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
warp --version || true
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Reset test environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x scripts/test/rustfs_pool_expand.sh
|
||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||
|
||||
- name: Install RustFS package & start first pool
|
||||
run: |
|
||||
ARGS=(--steps 1,2,3 -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
- name: Run pool expansion & decommission test
|
||||
id: pool_test
|
||||
run: |
|
||||
set -o pipefail
|
||||
STEPS="4,5,6"
|
||||
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||
STEPS="$STEPS,7,8"
|
||||
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||
STEPS="$STEPS,9"
|
||||
fi
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh \
|
||||
--steps "$STEPS" --with-warp -y \
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
||||
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
||||
--log-file /tmp/rustfs-pool-test.log
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-pool-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-pool-test.log
|
||||
/tmp/rustfs-warp.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Reset test environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS pool expansion test failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Scheduled Validation Freshness
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 23 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: scheduled-validation-freshness
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-freshness:
|
||||
name: Check scheduled validation freshness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check latest scheduled runs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/check_scheduled_validation_freshness.py \
|
||||
--report "${RUNNER_TEMP}/scheduled-validation-freshness.md"
|
||||
status=$?
|
||||
cat "${RUNNER_TEMP}/scheduled-validation-freshness.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit "${status}"
|
||||
- name: Open or update freshness issue
|
||||
if: failure()
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
details-file: ${{ runner.temp }}/scheduled-validation-freshness.md
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Scheduled Validation Watchdog
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Security Audit"
|
||||
- "Build and Release"
|
||||
- "Continuous Integration"
|
||||
- "coverage"
|
||||
- "e2e-nightly"
|
||||
- "e2e-s3tests"
|
||||
- "Fuzz"
|
||||
- "mint"
|
||||
- "minio-interop"
|
||||
- "Nightly GNU Build"
|
||||
- "Performance A/B"
|
||||
- "Runner Hygiene"
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
alert-on-incomplete-run:
|
||||
name: Alert on incomplete scheduled run
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'schedule' &&
|
||||
github.event.workflow_run.conclusion != 'success' &&
|
||||
github.event.workflow_run.conclusion != 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update incomplete-run issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workflow-name: ${{ github.event.workflow_run.name }}
|
||||
source-run-id: ${{ github.event.workflow_run.id }}
|
||||
source-run-attempt: ${{ github.event.workflow_run.run_attempt }}
|
||||
source-event: ${{ github.event.workflow_run.event }}
|
||||
source-ref-name: ${{ github.event.workflow_run.head_branch }}
|
||||
source-sha: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Targets Integration
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/actions/setup/**"
|
||||
- ".github/workflows/targets-integration.yml"
|
||||
- "crates/targets/**"
|
||||
- "Cargo.lock"
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
timezone: "Asia/Shanghai"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: targets-integration-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
targets-live:
|
||||
name: PostgreSQL, MySQL, AMQP, and NATS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
RUSTFS_TEST_PG_DSN: postgres://postgres:[email protected]:5432/rustfs_events
|
||||
RUSTFS_TEST_MYSQL_DSN: root:testpass@tcp(127.0.0.1:3306)/testdb
|
||||
RUSTFS_TEST_AMQP_URL: amqp://rustfs:[email protected]:5672/%2f
|
||||
RUSTFS_TEST_NATS_URL: nats://127.0.0.1:4222
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: targets-live-lane
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Start target services
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/targets-live/services
|
||||
docker run -d --name rustfs-targets-postgres \
|
||||
-e POSTGRES_PASSWORD=rustfs \
|
||||
-e POSTGRES_DB=rustfs_events \
|
||||
-p 5432:5432 postgres:16
|
||||
docker run -d --name rustfs-targets-mysql \
|
||||
-e MYSQL_ROOT_PASSWORD=testpass \
|
||||
-e MYSQL_DATABASE=testdb \
|
||||
-p 3306:3306 mysql:8.0.36
|
||||
docker run -d --name rustfs-targets-rabbitmq \
|
||||
-e RABBITMQ_DEFAULT_USER=rustfs \
|
||||
-e RABBITMQ_DEFAULT_PASS=rustfs \
|
||||
-p 5672:5672 rabbitmq:3
|
||||
docker run -d --name rustfs-targets-nats \
|
||||
-p 4222:4222 -p 8222:8222 nats:2 -js -m 8222
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
docker exec rustfs-targets-postgres pg_isready -U postgres -d rustfs_events >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
docker exec rustfs-targets-postgres pg_isready -U postgres -d rustfs_events
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
docker exec rustfs-targets-mysql mysqladmin ping -h 127.0.0.1 -uroot -ptestpass --silent >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
docker exec rustfs-targets-mysql mysqladmin ping -h 127.0.0.1 -uroot -ptestpass --silent
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
docker exec rustfs-targets-rabbitmq rabbitmq-diagnostics -q ping >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
docker exec rustfs-targets-rabbitmq rabbitmq-diagnostics -q ping
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
curl -fsS http://127.0.0.1:8222/healthz >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:8222/healthz
|
||||
|
||||
- name: Run live target tests
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
set +e
|
||||
timeout --verbose --signal=TERM --kill-after=30s 75m bash <<'TESTS' \
|
||||
2>&1 | tee artifacts/targets-live/tests.log
|
||||
result=0
|
||||
|
||||
echo "::group::PostgreSQL"
|
||||
cargo test --locked -p rustfs-targets --test postgres_integration -- --ignored --test-threads=1 || result=1
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::MySQL"
|
||||
cargo test --locked -p rustfs-targets --test mysql_integration -- --ignored --test-threads=1 || result=1
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::AMQP"
|
||||
cargo test --locked -p rustfs-targets --test amqp_integration -- --ignored --test-threads=1 || result=1
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::NATS integration"
|
||||
cargo test --locked -p rustfs-targets --test nats_jetstream_validation_integration -- --ignored --test-threads=1 || result=1
|
||||
cargo test --locked -p rustfs-targets --test nats_jetstream_regression_guards -- --ignored --test-threads=1 || result=1
|
||||
cargo test --locked -p rustfs-targets --lib target::nats::jetstream -- --ignored --test-threads=1 || result=1
|
||||
echo "::endgroup::"
|
||||
|
||||
exit "${result}"
|
||||
TESTS
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|target/.*/deps/' || true
|
||||
} > artifacts/targets-live/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Collect service logs
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p artifacts/targets-live/services
|
||||
for container in postgres mysql rabbitmq nats; do
|
||||
docker logs --tail 500 "rustfs-targets-${container}" \
|
||||
> "artifacts/targets-live/services/${container}.log" 2>&1 || true
|
||||
done
|
||||
|
||||
- name: Stop target services
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f \
|
||||
rustfs-targets-postgres \
|
||||
rustfs-targets-mysql \
|
||||
rustfs-targets-rabbitmq \
|
||||
rustfs-targets-nats >/dev/null 2>&1 || true
|
||||
|
||||
- name: Upload target integration diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: targets-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: artifacts/targets-live
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [targets-live]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+28
-15
@@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom:
|
||||
|-------|-----------|----------------|
|
||||
| **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown |
|
||||
| **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console |
|
||||
| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase |
|
||||
| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase |
|
||||
| **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency |
|
||||
| **Auth** | `auth.rs` | S3 signature verification, credential validation |
|
||||
| **Config** | `config/` | CLI parsing, config struct, workload profiles |
|
||||
@@ -92,8 +92,8 @@ refactors.
|
||||
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `utils` | Shared configuration, data-usage models, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
|
||||
@@ -115,8 +115,15 @@ default build (lifecycle:
|
||||
1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core.
|
||||
No upward imports.
|
||||
|
||||
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
||||
`io-metrics`, and `madmin` should depend only on external crates.
|
||||
2. **Leaf crates depend only on external crates, with adjudicated exceptions
|
||||
pinned by a guard.** `config`, `credentials`, and `crypto` take no internal
|
||||
dependency. `io-metrics` takes exactly `rustfs-s3-ops` (transitively
|
||||
`rustfs-s3-types`), a pure contract crate with no I/O and no global state —
|
||||
adjudicated in rustfs/backlog#1834. `madmin` left the leaf set when #6166 made
|
||||
it the SigV4-signed admin SDK client; its internal dependency surface is pinned
|
||||
to exactly `rustfs-signer`. Both pins live in the leaf allowlist in
|
||||
`scripts/check_architecture_migration_rules.sh`; any other internal dependency
|
||||
fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)).
|
||||
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
||||
edges were removed; do not reintroduce them (see Known Structural Issues).
|
||||
|
||||
@@ -128,7 +135,7 @@ default build (lifecycle:
|
||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
||||
per-second bucketed accumulator in `crates/common/src/last_minute.rs` and
|
||||
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
|
||||
the in-memory endpoint-health sample tracker in
|
||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||
why it stays local).
|
||||
@@ -138,15 +145,19 @@ default build (lifecycle:
|
||||
`BackpressureSettings` copy that lingered in io-metrics was removed
|
||||
(rustfs/backlog#1833).
|
||||
|
||||
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
||||
storage-level abstractions (objects, buckets, disks, pools).
|
||||
- ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s`
|
||||
(`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/`
|
||||
is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml`
|
||||
depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and
|
||||
`reqwest`. Target state: the engine's need to act as an S3 client
|
||||
(tiering, replication targets) is served by an extracted client crate,
|
||||
and ecstore holds no wire or DTO types.
|
||||
4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on
|
||||
storage-level abstractions (objects, buckets, disks, pools) and holds no
|
||||
wire or DTO types of the serving surface. *Consuming* remote S3-compatible
|
||||
endpoints (ILM tier warm backends, transition targets) is a legitimate
|
||||
engine capability, but it lives in the dedicated `rustfs-s3-client` crate
|
||||
(`crates/s3-client`, extracted from the formerly embedded
|
||||
`crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore.
|
||||
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
|
||||
(bucket metadata/replication/lifecycle DTOs and error mapping). The
|
||||
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
|
||||
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
|
||||
storage-level types as the first ratchet step). Target state: the
|
||||
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
|
||||
|
||||
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
||||
Individual crates should be testable in isolation.
|
||||
@@ -321,6 +332,8 @@ The binary (`main.rs`) boots in this order:
|
||||
|
||||
- **"Where is replication configured?"**
|
||||
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
||||
`rustfs/src/site_replication/` for the site-replication service subsystem
|
||||
(state, peer transport, retry queue, repair, hooks),
|
||||
`ecstore/src/bucket/replication/` for engine
|
||||
|
||||
- **"Where do I add a new admin endpoint?"**
|
||||
|
||||
@@ -30,7 +30,8 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
- CI gates: `.github/workflows/ci.yml` (source of truth; never copy its steps into docs)
|
||||
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
|
||||
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
|
||||
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
|
||||
policy: [docs/testing/README.md](docs/testing/README.md)
|
||||
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
|
||||
|
||||
@@ -70,6 +70,8 @@ make pre-pr
|
||||
|
||||
> For the full test-layer taxonomy (unit / ecstore black-box / e2e / s3s-e2e / S3 compatibility / chaos / fuzz / bench), each layer's entry command, the naming conventions the migration gate depends on, and the serial/nextest rules, see [docs/testing/README.md](docs/testing/README.md).
|
||||
|
||||
> For the event, timeout, required-status, and local reproduction matrix, see [docs/testing/ci-gates.md](docs/testing/ci-gates.md).
|
||||
|
||||
### 🔒 Automated Pre-commit Hooks
|
||||
#### What `make pre-commit` and `make pre-pr` actually run
|
||||
|
||||
|
||||
Generated
+635
-280
File diff suppressed because it is too large
Load Diff
+84
-69
@@ -26,6 +26,7 @@ members = [
|
||||
"crates/e2e_test", # End-to-end test suite
|
||||
"crates/filemeta", # File metadata management
|
||||
"crates/heal", # Erasure set and object healing
|
||||
"crates/heal-contracts", # Heal request/response channel contracts
|
||||
"crates/iam", # Identity and Access Management
|
||||
"crates/keystone", # OpenStack Keystone integration
|
||||
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
||||
@@ -44,11 +45,13 @@ members = [
|
||||
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
||||
"crates/replication", # Replication contracts and wire formats
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
|
||||
"crates/s3-types", # S3 event type definitions
|
||||
"crates/s3-ops", # S3 operation definitions and mapping
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||
"crates/scanner-contracts", # Scanner metrics and cycle contracts
|
||||
"crates/security-governance", # Security governance contracts
|
||||
"crates/extension-schema", # Extension schema contracts
|
||||
"crates/signer", # client signer
|
||||
@@ -69,7 +72,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-rc.3"
|
||||
version = "1.0.0-rc.4"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -86,56 +89,59 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.3" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.18" }
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37" }
|
||||
async-compression = { version = "0.4.43" }
|
||||
async-recursion = "1.1.1"
|
||||
@@ -147,7 +153,7 @@ futures-core = "0.3.34"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.34"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "6.8.0" }
|
||||
pulsar = { default-features = false, version = "6.9.0" }
|
||||
lapin = { default-features = false, version = "4.10.0" }
|
||||
hyper = { version = "1.11.0" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||
@@ -178,7 +184,7 @@ byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.4"
|
||||
quick-xml = "0.41.0"
|
||||
quick-xml = "0.42.0"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
@@ -191,15 +197,16 @@ serde_urlencoded = "0.7.1"
|
||||
# matching stable releases are not available yet, while previous stable lines
|
||||
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
||||
# releases.
|
||||
aes-gcm = { version = "=0.11.0" }
|
||||
aes-gcm = { version = "=0.11.1" }
|
||||
argon2 = { version = "=0.6.0-rc.8" }
|
||||
blake2 = "=0.11.0-rc.6"
|
||||
blake2 = "=0.11.0"
|
||||
chacha20poly1305 = { version = "=0.11.0" }
|
||||
crc-fast = "1.10.0"
|
||||
hmac = { version = "0.13.0" }
|
||||
jsonwebtoken = { version = "11.0.0" }
|
||||
openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.43" }
|
||||
rustls-native-certs = "0.8"
|
||||
@@ -209,6 +216,7 @@ sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.0" }
|
||||
proptest = "1"
|
||||
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
@@ -227,15 +235,14 @@ arc-swap = "1.9.2"
|
||||
astral-tokio-tar = "0.6.4"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.10.1" }
|
||||
aws-config = { version = "1.11.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.115.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.142.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.111.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
base64 = "0.23.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
clap = { version = "4.6.6" }
|
||||
@@ -252,10 +259,12 @@ enumset = "1.1.14"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.9"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.17.0"
|
||||
google-cloud-auth = "1.15.0"
|
||||
google-cloud-storage = "1.18.0"
|
||||
google-cloud-auth = "1.16.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
hex = "0.4.3"
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
data-encoding = "2.11.1"
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
@@ -277,6 +286,10 @@ nvml-wrapper = "0.12.1"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
# CLI needs its own QR encoder. No default features: the image/render backends
|
||||
# pull in an image stack this only needs SVG and text output from.
|
||||
qrcode-rs = { version = "2.0.0", default-features = false, features = ["std", "svg"] }
|
||||
pin-project-lite = "0.2.17"
|
||||
pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
@@ -291,14 +304,14 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "ed70cb048cc4be168419d461cb9ac3c2c7fa6d5a" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.2.2" }
|
||||
starshard = { version = "2.3.0" }
|
||||
strum = { version = "0.28.0" }
|
||||
sysinfo = "0.39.6"
|
||||
temp-env = "0.3.6"
|
||||
@@ -314,7 +327,7 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.24.1" }
|
||||
uuid = { version = "1.26.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -328,7 +341,7 @@ zstd = "0.13.3"
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9-tokio-telemetry = "0.3"
|
||||
dial9-tokio-telemetry = "0.5.0"
|
||||
opentelemetry = { version = "0.32.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||
opentelemetry-otlp = { version = "0.32.0" }
|
||||
@@ -343,19 +356,21 @@ libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "10.0.2" }
|
||||
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.62.7" }
|
||||
russh = { version = "0.63.1" }
|
||||
russh-sftp = "2.4.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] }
|
||||
hotpath = { version = "0.23.3", default-features = false }
|
||||
rustfs-mimalloc = { version = "0.5.1" }
|
||||
hotpath = { version = "0.24.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
# High-performance hashing
|
||||
ahash = { version = "0.8", default-features = false, features = ["std", "runtime-rng", "serde"] }
|
||||
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = ["hotpath", "rustfs"]
|
||||
|
||||
@@ -371,8 +386,8 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
debug = 0
|
||||
split-debuginfo = "off"
|
||||
strip = "symbols"
|
||||
split-debuginfo = "off"
|
||||
|
||||
[profile.production]
|
||||
inherits = "release"
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -116,7 +115,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+2
-3
@@ -12,8 +12,7 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -113,7 +112,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -67,7 +67,7 @@ tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time",
|
||||
tracing = { workspace = true, features = ["std", "attributes"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
rustfs-targets = { workspace = true, features = ["test-support"] }
|
||||
temp-env = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
|
||||
@@ -564,88 +564,21 @@ impl AuditRuntimeFacade {
|
||||
mod tests {
|
||||
use super::AuditPipeline;
|
||||
use crate::{AuditEntry, AuditError, AuditRegistry};
|
||||
use async_trait::async_trait;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
|
||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
||||
/// force full-success / full-failure / partial-failure fan-outs.
|
||||
#[derive(Clone)]
|
||||
struct MockTarget {
|
||||
id: TargetID,
|
||||
fail: bool,
|
||||
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
||||
}
|
||||
|
||||
impl MockTarget {
|
||||
fn new(id: &str, fail: bool) -> Self {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
||||
fail,
|
||||
health_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
|
||||
self.health_gate = Some((started, release));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for MockTarget
|
||||
where
|
||||
E: rustfs_targets::PluginEvent,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
if let Some((started, release)) = &self.health_gate {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
if self.fail {
|
||||
Err(TargetError::Configuration("forced save failure".to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// Builds a mock target whose `save()` outcome is fixed at construction so tests can force
|
||||
/// full-success / full-failure / partial-failure fan-outs.
|
||||
fn mock_target(id: &str, fail: bool) -> MockTarget {
|
||||
let target = MockTarget::new(id, "webhook");
|
||||
if fail { target.with_save_failures(usize::MAX) } else { target }
|
||||
}
|
||||
|
||||
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
|
||||
let mut registry = AuditRegistry::new();
|
||||
for target in targets {
|
||||
registry.add_target(target.id.to_string(), Box::new(target));
|
||||
registry.add_target(target.target_id().to_string(), Box::new(target));
|
||||
}
|
||||
AuditPipeline::new(Arc::new(Mutex::new(registry)))
|
||||
}
|
||||
@@ -658,7 +591,7 @@ mod tests {
|
||||
// dispatch must return Err rather than swallowing the failures as Ok.
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_err_when_all_targets_fail() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", true), mock_target("b:webhook", true)]);
|
||||
let result = pipeline.dispatch(entry()).await;
|
||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||
}
|
||||
@@ -667,13 +600,13 @@ mod tests {
|
||||
// so dispatch reports success (degradation is logged, not propagated).
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_ok_on_partial_failure() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("ok:webhook", false), mock_target("bad:webhook", true)]);
|
||||
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_ok_when_all_targets_succeed() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
|
||||
}
|
||||
|
||||
@@ -686,9 +619,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||
let started = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
|
||||
let target = mock_target("blocked", false).with_health_gate(release.clone());
|
||||
let started = target.health_started();
|
||||
let pipeline = pipeline_with(vec![target]);
|
||||
let registry = Arc::clone(&pipeline.registry);
|
||||
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||
started.notified().await;
|
||||
@@ -706,14 +640,14 @@ mod tests {
|
||||
// whole-batch loss instead of returning Ok.
|
||||
#[tokio::test]
|
||||
async fn dispatch_batch_returns_err_when_all_targets_fail() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", true)]);
|
||||
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
|
||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||
pipeline
|
||||
.dispatch_batch(vec![entry(), entry()])
|
||||
.await
|
||||
|
||||
@@ -286,70 +286,10 @@ impl AuditRegistry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AuditRegistry;
|
||||
use crate::{AuditEntry, AuditError};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CloseTestTarget {
|
||||
id: TargetID,
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
fail_on_close: bool,
|
||||
}
|
||||
|
||||
impl CloseTestTarget {
|
||||
fn new(id: TargetID, close_calls: Arc<AtomicUsize>, fail_on_close: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
close_calls,
|
||||
fail_on_close,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Target<AuditEntry> for CloseTestTarget {
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<AuditEntry>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail_on_close {
|
||||
Err(TargetError::Unknown("close failed".to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<AuditEntry> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
use crate::AuditError;
|
||||
use rustfs_targets::TargetError;
|
||||
use rustfs_targets::target::ChannelTargetType;
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
|
||||
#[test]
|
||||
fn registry_registers_amqp_factory() {
|
||||
@@ -361,23 +301,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn close_all_returns_first_error_and_clears_targets() {
|
||||
let mut registry = AuditRegistry::new();
|
||||
let ok_calls = Arc::new(AtomicUsize::new(0));
|
||||
let fail_calls = Arc::new(AtomicUsize::new(0));
|
||||
let ok = MockTarget::new("ok", "webhook");
|
||||
let ok_observer = ok.clone();
|
||||
let fail = MockTarget::new("fail", "webhook")
|
||||
.with_close_failures(usize::MAX)
|
||||
.with_close_failure_error(|| TargetError::Unknown("close failed".to_string()));
|
||||
let fail_observer = fail.clone();
|
||||
|
||||
let ok_id = TargetID::new("ok".to_string(), "webhook".to_string());
|
||||
let fail_id = TargetID::new("fail".to_string(), "webhook".to_string());
|
||||
|
||||
registry.add_target(ok_id.to_string(), Box::new(CloseTestTarget::new(ok_id, Arc::clone(&ok_calls), false)));
|
||||
registry.add_target(
|
||||
fail_id.to_string(),
|
||||
Box::new(CloseTestTarget::new(fail_id, Arc::clone(&fail_calls), true)),
|
||||
);
|
||||
registry.add_target(ok.target_id().to_string(), Box::new(ok));
|
||||
registry.add_target(fail.target_id().to_string(), Box::new(fail));
|
||||
|
||||
let result = registry.close_all().await;
|
||||
|
||||
assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_)))));
|
||||
assert_eq!(ok_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fail_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ok_observer.close_call_count(), 1);
|
||||
assert_eq!(fail_observer.close_call_count(), 1);
|
||||
assert!(registry.list_targets().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+11
-70
@@ -577,76 +577,17 @@ fn warn_audit_state(state: &str, reason: Option<&str>) {
|
||||
mod tests {
|
||||
use super::{AuditSystem, AuditSystemState};
|
||||
use crate::{AuditEntry, AuditError};
|
||||
use async_trait::async_trait;
|
||||
use rustfs_targets::ReplayWorkerManager;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestTarget {
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
id: TargetID,
|
||||
}
|
||||
|
||||
impl TestTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for TestTarget
|
||||
where
|
||||
E: rustfs_targets::PluginEvent,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_with_empty_config_stops_existing_runtime() {
|
||||
let system = AuditSystem::new();
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let close_calls = Arc::clone(&target.close_calls);
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let observer = target.clone();
|
||||
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
@@ -671,7 +612,7 @@ mod tests {
|
||||
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
|
||||
assert!(system.list_targets().await.is_empty());
|
||||
assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0));
|
||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.close_call_count(), 1);
|
||||
assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new())));
|
||||
}
|
||||
|
||||
@@ -693,7 +634,7 @@ mod tests {
|
||||
// Seed a target + replay worker so both critical sections touch real state.
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
registry.add_target("primary:webhook".to_string(), Box::new(TestTarget::new("primary", "webhook")));
|
||||
registry.add_target("primary:webhook".to_string(), Box::new(MockTarget::new("primary", "webhook")));
|
||||
}
|
||||
{
|
||||
let mut replay_workers = system.stream_cancellers.write().await;
|
||||
@@ -793,8 +734,8 @@ mod tests {
|
||||
async fn commit_closes_old_targets_before_installing_new() {
|
||||
let system = AuditSystem::new();
|
||||
|
||||
let old = TestTarget::new("old", "webhook");
|
||||
let old_close = Arc::clone(&old.close_calls);
|
||||
let old = MockTarget::new("old", "webhook");
|
||||
let old_observer = old.clone();
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
registry.add_target("old:webhook".to_string(), Box::new(old));
|
||||
@@ -809,17 +750,17 @@ mod tests {
|
||||
*state = AuditSystemState::Running;
|
||||
}
|
||||
|
||||
let new = TestTarget::new("new", "webhook");
|
||||
let new_close = Arc::clone(&new.close_calls);
|
||||
let new = MockTarget::new("new", "webhook");
|
||||
let new_observer = new.clone();
|
||||
system
|
||||
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
|
||||
.await
|
||||
.expect("commit should succeed");
|
||||
|
||||
// Old target closed exactly once during the pre-install shutdown.
|
||||
assert_eq!(old_close.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(old_observer.close_call_count(), 1);
|
||||
// New target installed and left open.
|
||||
assert_eq!(new_close.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(new_observer.close_call_count(), 0);
|
||||
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
|
||||
// Old replay worker stopped; the store-less new target adds none.
|
||||
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
|
||||
|
||||
@@ -12,136 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{SharedTarget, StoreError, Target, TargetError};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use rustfs_targets::SharedTarget;
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestTarget {
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
id: TargetID,
|
||||
init_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TestTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
init_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for TestTarget
|
||||
where
|
||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), TargetError> {
|
||||
self.init_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A target whose `save()` always fails, used to exercise the dispatch
|
||||
/// Builds a target whose `save()` always fails, used to exercise the dispatch
|
||||
/// failure-propagation paths.
|
||||
#[derive(Clone)]
|
||||
struct FailingTarget {
|
||||
id: TargetID,
|
||||
save_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FailingTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
save_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for FailingTarget
|
||||
where
|
||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
self.save_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(TargetError::Storage("disk full".to_string()))
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn failing_target(id: &str, name: &str) -> MockTarget {
|
||||
MockTarget::new(id, name).with_save_failures(usize::MAX)
|
||||
}
|
||||
|
||||
fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> AuditPipeline {
|
||||
@@ -154,8 +34,8 @@ fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> Audi
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_propagates_total_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let save_calls = Arc::clone(&failing.save_calls);
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let observer = failing.clone();
|
||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||
|
||||
let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await;
|
||||
@@ -164,13 +44,13 @@ async fn audit_pipeline_dispatch_propagates_total_failure() {
|
||||
matches!(result, Err(AuditError::Target(_))),
|
||||
"dispatch must surface an error when every target fails, got {result:?}"
|
||||
);
|
||||
assert_eq!(save_calls.load(Ordering::SeqCst), 1, "the failing target should have been invoked");
|
||||
assert_eq!(observer.save_call_count(), 1, "the failing target should have been invoked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let healthy = TestTarget::new("secondary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let healthy = MockTarget::new("secondary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![
|
||||
("primary:webhook", Arc::new(failing)),
|
||||
("secondary:webhook", Arc::new(healthy)),
|
||||
@@ -186,7 +66,7 @@ async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||
|
||||
let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())];
|
||||
@@ -200,8 +80,8 @@ async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let healthy = TestTarget::new("secondary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let healthy = MockTarget::new("secondary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![
|
||||
("primary:webhook", Arc::new(failing)),
|
||||
("secondary:webhook", Arc::new(healthy)),
|
||||
@@ -266,9 +146,8 @@ async fn audit_runtime_facade_activates_empty_target_list() {
|
||||
async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||
let runtime_view = AuditRuntimeView::new(registry.clone());
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let init_calls = Arc::clone(&target.init_calls);
|
||||
let close_calls = Arc::clone(&target.close_calls);
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let observer = target.clone();
|
||||
|
||||
runtime_view
|
||||
.upsert_target("primary:webhook".to_string(), Box::new(target))
|
||||
@@ -276,7 +155,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
.expect("upsert should succeed");
|
||||
|
||||
assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]);
|
||||
assert_eq!(init_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.init_call_count(), 1);
|
||||
|
||||
runtime_view
|
||||
.remove_target("primary:webhook")
|
||||
@@ -284,7 +163,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
.expect("remove should succeed");
|
||||
|
||||
assert!(runtime_view.list_targets().await.is_empty());
|
||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.close_call_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -292,7 +171,7 @@ async fn audit_runtime_facade_replace_targets_commits_runtime_state() {
|
||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
||||
let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone());
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let activation = rustfs_targets::RuntimeActivation {
|
||||
replay_workers: rustfs_targets::ReplayWorkerManager::new(),
|
||||
targets: vec![Arc::new(target) as rustfs_targets::SharedTarget<rustfs_audit::AuditEntry>],
|
||||
|
||||
+165
-8
@@ -41,14 +41,22 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||
pub const MD5_NAME: &str = "md5";
|
||||
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
||||
/// bits are append-only), and the MinIO-port client keeps its own
|
||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
||||
/// each other.
|
||||
/// The canonical checksum-algorithm registry (backlog#1833, backlog#1844):
|
||||
/// this enum owns the streaming-hash implementations and, via the exhaustive
|
||||
/// per-algorithm metadata methods below, the wire names, header names, digest
|
||||
/// lengths, and checksum-type capabilities — including the RustFS extensions
|
||||
/// (sha512, xxhash3/64/128). The MinIO-port client's `ChecksumMode`
|
||||
/// (crates/s3-client/src/checksum.rs) delegates all per-algorithm dispatch
|
||||
/// here through its `algorithm()` bridge. The on-disk xl.meta bitset remains
|
||||
/// deliberately separate in `rustfs_rio::ChecksumType`
|
||||
/// (crates/rio/src/checksum.rs, varint bits are append-only), and rio also
|
||||
/// keeps its own hot-path hasher shells — equivalence with this crate's
|
||||
/// hashers is enforced by both test suites pinning the same official
|
||||
/// known-answer vectors (backlog#1844 PR3 verdict, recorded on
|
||||
/// `rustfs_rio::ChecksumType`). When adding an algorithm: add the variant
|
||||
/// here (the exhaustive matches force every metadata decision), bridge it in
|
||||
/// the client, and allocate an xl.meta bit + hasher + shared vector in rio
|
||||
/// (or record why not).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ChecksumAlgorithm {
|
||||
@@ -120,6 +128,84 @@ impl ChecksumAlgorithm {
|
||||
Self::Xxhash128 => XXHASH_128_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
// Per-algorithm wire metadata. These matches are deliberately exhaustive
|
||||
// (no `_` arm): adding a ChecksumAlgorithm variant without deciding its
|
||||
// name, header, digest length, and checksum-type support must fail to
|
||||
// compile rather than silently inherit a default (backlog#1844).
|
||||
|
||||
/// The canonical `x-amz-checksum-algorithm` wire value (uppercase), as
|
||||
/// carried in S3 requests/responses and stored checksum maps.
|
||||
pub fn s3_algorithm_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Crc32 => "CRC32",
|
||||
Self::Crc32c => "CRC32C",
|
||||
Self::Crc64Nvme => "CRC64NVME",
|
||||
Self::Sha1 => "SHA1",
|
||||
Self::Sha256 => "SHA256",
|
||||
Self::Sha512 => "SHA512",
|
||||
Self::Xxhash3 => "XXHASH3",
|
||||
Self::Xxhash64 => "XXHASH64",
|
||||
Self::Xxhash128 => "XXHASH128",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `x-amz-checksum-*` HTTP header that carries this algorithm's
|
||||
/// base64-encoded digest.
|
||||
pub fn http_header_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Crc32 => http::CRC_32_HEADER_NAME,
|
||||
Self::Crc32c => http::CRC_32_C_HEADER_NAME,
|
||||
Self::Crc64Nvme => http::CRC_64_NVME_HEADER_NAME,
|
||||
Self::Sha1 => http::SHA_1_HEADER_NAME,
|
||||
Self::Sha256 => http::SHA_256_HEADER_NAME,
|
||||
Self::Sha512 => http::SHA_512_HEADER_NAME,
|
||||
Self::Xxhash3 => http::XXHASH_3_HEADER_NAME,
|
||||
Self::Xxhash64 => http::XXHASH_64_HEADER_NAME,
|
||||
Self::Xxhash128 => http::XXHASH_128_HEADER_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw (unencoded) digest length in bytes.
|
||||
pub fn raw_len(&self) -> usize {
|
||||
match self {
|
||||
Self::Crc32 | Self::Crc32c => 4,
|
||||
Self::Crc64Nvme => 8,
|
||||
Self::Sha1 => 20,
|
||||
Self::Sha256 => 32,
|
||||
Self::Sha512 => 64,
|
||||
Self::Xxhash3 | Self::Xxhash64 => 8,
|
||||
Self::Xxhash128 => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the algorithm supports the S3 COMPOSITE multipart checksum
|
||||
/// type. Per the AWS registry, every algorithm does except CRC64NVME,
|
||||
/// which is FULL_OBJECT-only.
|
||||
pub fn supports_composite(&self) -> bool {
|
||||
match self {
|
||||
Self::Crc64Nvme => false,
|
||||
Self::Crc32
|
||||
| Self::Crc32c
|
||||
| Self::Sha1
|
||||
| Self::Sha256
|
||||
| Self::Sha512
|
||||
| Self::Xxhash3
|
||||
| Self::Xxhash64
|
||||
| Self::Xxhash128 => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the algorithm supports the S3 FULL_OBJECT checksum type, i.e.
|
||||
/// part digests can be linearly combined into the whole-object digest.
|
||||
/// Only the CRC family has this property; the hash algorithms are
|
||||
/// COMPOSITE-only.
|
||||
pub fn supports_full_object(&self) -> bool {
|
||||
match self {
|
||||
Self::Crc32 | Self::Crc32c | Self::Crc64Nvme => true,
|
||||
Self::Sha1 | Self::Sha256 | Self::Sha512 | Self::Xxhash3 | Self::Xxhash64 | Self::Xxhash128 => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Checksum: Send + Sync {
|
||||
@@ -731,6 +817,77 @@ mod tests {
|
||||
assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_algorithm_metadata_is_consistent_for_every_variant() {
|
||||
use crate::Checksum;
|
||||
|
||||
// Cross-checks the per-algorithm metadata methods against the hasher
|
||||
// implementations themselves, so the registry cannot drift from the
|
||||
// code that computes digests (backlog#1844). The list must cover every
|
||||
// variant; the metadata methods use exhaustive matches, so a new
|
||||
// variant that is missing here still fails to compile there first.
|
||||
let all = [
|
||||
ChecksumAlgorithm::Crc32,
|
||||
ChecksumAlgorithm::Crc32c,
|
||||
ChecksumAlgorithm::Crc64Nvme,
|
||||
ChecksumAlgorithm::Sha1,
|
||||
ChecksumAlgorithm::Sha256,
|
||||
ChecksumAlgorithm::Sha512,
|
||||
ChecksumAlgorithm::Xxhash3,
|
||||
ChecksumAlgorithm::Xxhash64,
|
||||
ChecksumAlgorithm::Xxhash128,
|
||||
];
|
||||
|
||||
for algorithm in all {
|
||||
// Digest length must match what the hasher actually produces.
|
||||
let mut hasher = algorithm.into_impl();
|
||||
hasher.update(b"metadata consistency probe");
|
||||
assert_eq!(
|
||||
algorithm.raw_len(),
|
||||
Checksum::size(&*algorithm.into_impl()) as usize,
|
||||
"{algorithm:?} raw_len() != hasher size()"
|
||||
);
|
||||
assert_eq!(hasher.finalize().len(), algorithm.raw_len(), "{algorithm:?} finalize length != raw_len()");
|
||||
|
||||
// Header name must match the hasher's own header binding.
|
||||
assert_eq!(
|
||||
algorithm.http_header_name(),
|
||||
algorithm.into_impl().header_name(),
|
||||
"{algorithm:?} http_header_name() != HttpChecksum::header_name()"
|
||||
);
|
||||
assert_eq!(
|
||||
algorithm.http_header_name(),
|
||||
format!("x-amz-checksum-{}", algorithm.as_str()),
|
||||
"{algorithm:?} header must be x-amz-checksum-<name>"
|
||||
);
|
||||
|
||||
// The uppercase wire name and the lowercase parse name must be the
|
||||
// same word, and the wire name must parse back to the variant.
|
||||
assert!(
|
||||
algorithm.s3_algorithm_name().eq_ignore_ascii_case(algorithm.as_str()),
|
||||
"{algorithm:?} s3_algorithm_name() and as_str() diverge"
|
||||
);
|
||||
assert_eq!(algorithm.s3_algorithm_name().parse::<ChecksumAlgorithm>().unwrap(), algorithm);
|
||||
}
|
||||
|
||||
// AWS checksum-type support table: CRC64NVME is FULL_OBJECT-only, the
|
||||
// CRC family supports FULL_OBJECT, everything else is COMPOSITE-only.
|
||||
for algorithm in all {
|
||||
let composite = algorithm.supports_composite();
|
||||
let full_object = algorithm.supports_full_object();
|
||||
assert!(composite || full_object, "{algorithm:?} supports no checksum type at all");
|
||||
match algorithm {
|
||||
ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => {
|
||||
assert!(composite && full_object, "{algorithm:?} must support both checksum types")
|
||||
}
|
||||
ChecksumAlgorithm::Crc64Nvme => {
|
||||
assert!(!composite && full_object, "CRC64NVME must be FULL_OBJECT-only")
|
||||
}
|
||||
_ => assert!(composite && !full_object, "{algorithm:?} must be COMPOSITE-only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xxhash64_matches_direct_computation_big_endian_seed0() {
|
||||
use crate::Xxhash64;
|
||||
|
||||
@@ -38,16 +38,9 @@ hotpath.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
metrics = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
smallvec = { workspace = true }
|
||||
rmp-serde = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -27,8 +26,6 @@ pub static GLOBAL_CONN_MAP: LazyLock<RwLock<HashMap<String, Channel>>> = LazyLoc
|
||||
pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
||||
/// Global initialization time of the RustFS node.
|
||||
pub static GLOBAL_INIT_TIME: LazyLock<RwLock<Option<DateTime<Utc>>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
/// Log level to use when reporting cached gRPC connection eviction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -63,20 +60,6 @@ pub fn try_get_global_local_node_name() -> Option<String> {
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Set the global RustFS initialization time to the current UTC time.
|
||||
pub async fn set_global_init_time_now() {
|
||||
let now = Utc::now();
|
||||
*GLOBAL_INIT_TIME.write().await = Some(now);
|
||||
}
|
||||
|
||||
/// Get the global RustFS initialization time.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Option<DateTime<Utc>>` - The initialization time if set.
|
||||
pub async fn get_global_init_time() -> Option<DateTime<Utc>> {
|
||||
*GLOBAL_INIT_TIME.read().await
|
||||
}
|
||||
|
||||
/// Set the global RustFS address used for gRPC connections.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
|
||||
// pub mod error;
|
||||
pub mod globals;
|
||||
pub mod heal_channel;
|
||||
pub mod last_minute;
|
||||
pub mod metrics;
|
||||
pub mod mrf_channel;
|
||||
mod readiness;
|
||||
pub mod table_catalog;
|
||||
|
||||
@@ -23,21 +23,32 @@
|
||||
//! unconsumed intents is the consumer's job (see `rustfs-heal`
|
||||
//! `heal::mrf_queue`), mirroring MinIO's `.heal/mrf/list.bin`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hash};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Bounded capacity of the global MRF channel. Backpressure is resolved by
|
||||
/// dropping (and counting) intents, never by blocking the producer.
|
||||
const MRF_CHANNEL_CAPACITY: usize = 8192;
|
||||
const MRF_COALESCER_SHARDS: usize = 16;
|
||||
const MRF_COALESCER_MAX_KEYS: usize = 8192;
|
||||
const MRF_COALESCER_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MRF_COALESCER_TTL: Duration = Duration::from_secs(60);
|
||||
const MRF_MAX_IDENTITY_COMPONENT: usize = 1024;
|
||||
|
||||
/// Why an intent was produced. Drives the heal priority mapping on the
|
||||
/// consumer side (DecodeFailure -> Urgent, MetadataCorruption -> High,
|
||||
/// PartialWrite -> Normal).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum MrfKind {
|
||||
/// Erasure decode failed while serving a read (read path).
|
||||
DecodeFailure,
|
||||
@@ -67,12 +78,52 @@ pub struct MrfIntent {
|
||||
/// Version the intent targets, as raw UUID bytes.
|
||||
pub version_id: Option<[u8; 16]>,
|
||||
pub kind: MrfKind,
|
||||
/// Stable erasure-set scope when the producer has it. Kept optional so
|
||||
/// metadata corruption and legacy producers do not invent a scope.
|
||||
pub scope: Option<MrfScope>,
|
||||
/// Generation of the node-local ingress lease. It is not persisted in
|
||||
/// the journal; replayed records acquire a fresh lease when re-enqueued.
|
||||
pub lease: Option<MrfIngressLease>,
|
||||
pub enqueued_at_ms: u64,
|
||||
/// Times this intent has already been offered to the heal manager.
|
||||
/// Dropped by the consumer once it reaches `MRF_MAX_ATTEMPTS`.
|
||||
pub attempts: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MrfScope {
|
||||
pub pool_index: u32,
|
||||
pub set_index: u32,
|
||||
}
|
||||
|
||||
/// Opaque generation used to release exactly the admission that created an
|
||||
/// ingress entry. A generation prevents a late terminal callback from
|
||||
/// deleting a newer retry for the same identity (ABA).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MrfIngressLease(u64);
|
||||
|
||||
impl MrfIngressLease {
|
||||
const fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MrfDropReason {
|
||||
Disabled,
|
||||
Uninitialized,
|
||||
Full,
|
||||
OversizedIdentity,
|
||||
CoalescerFull,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MrfIngressResult {
|
||||
Enqueued,
|
||||
Coalesced,
|
||||
Dropped(MrfDropReason),
|
||||
}
|
||||
|
||||
/// Consumer-side retry ceiling before an intent is given up on.
|
||||
pub const MRF_MAX_ATTEMPTS: u8 = 3;
|
||||
|
||||
@@ -87,6 +138,159 @@ impl MrfIntent {
|
||||
|
||||
static GLOBAL_MRF_SENDER: OnceLock<mpsc::Sender<MrfIntent>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct MrfIdentityKey {
|
||||
kind: MrfKind,
|
||||
bucket: Arc<str>,
|
||||
object: Arc<str>,
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IngressEntry {
|
||||
lease: MrfIngressLease,
|
||||
expires_at: Instant,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
type MrfCoalescerShard = Mutex<HashMap<MrfIdentityKey, IngressEntry>>;
|
||||
type MrfCoalescer = Box<[MrfCoalescerShard]>;
|
||||
|
||||
static MRF_COALESCER: OnceLock<MrfCoalescer> = OnceLock::new();
|
||||
static NEXT_MRF_LEASE: AtomicU64 = AtomicU64::new(1);
|
||||
static MRF_COALESCER_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static MRF_COALESCER_BYTES: AtomicUsize = AtomicUsize::new(0);
|
||||
static MRF_HASH_STATE: OnceLock<RandomState> = OnceLock::new();
|
||||
|
||||
fn coalescer() -> &'static [MrfCoalescerShard] {
|
||||
MRF_COALESCER.get_or_init(|| {
|
||||
(0..MRF_COALESCER_SHARDS)
|
||||
.map(|_| Mutex::new(HashMap::new()))
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice()
|
||||
})
|
||||
}
|
||||
|
||||
fn key_shard(key: &MrfIdentityKey) -> usize {
|
||||
let hash = MRF_HASH_STATE.get_or_init(RandomState::new).hash_one(key);
|
||||
usize::try_from(hash).unwrap_or(0) % MRF_COALESCER_SHARDS
|
||||
}
|
||||
|
||||
fn canonical_version(version_id: Option<Uuid>) -> Option<[u8; 16]> {
|
||||
version_id
|
||||
.filter(|version| !version.is_nil())
|
||||
.map(|version| *version.as_bytes())
|
||||
}
|
||||
|
||||
fn canonical_identity(
|
||||
kind: MrfKind,
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
) -> (Option<[u8; 16]>, Option<MrfScope>) {
|
||||
let version_id = version_id.filter(|bytes| *bytes != [0; 16]);
|
||||
match kind {
|
||||
MrfKind::MetadataCorruption => (None, None),
|
||||
MrfKind::DecodeFailure | MrfKind::PartialWrite => (version_id, scope),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_estimated_bytes(key: &MrfIdentityKey) -> usize {
|
||||
64usize
|
||||
.saturating_add(key.bucket.len())
|
||||
.saturating_add(key.object.len())
|
||||
.saturating_add(key.version_id.map_or(0, |_| 16))
|
||||
.saturating_add(key.scope.map_or(0, |_| 8))
|
||||
}
|
||||
|
||||
fn reserve(counter: &AtomicUsize, limit: usize, amount: usize) -> bool {
|
||||
let mut current = counter.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let Some(next) = current.checked_add(amount) else {
|
||||
return false;
|
||||
};
|
||||
if next > limit {
|
||||
return false;
|
||||
}
|
||||
match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return true,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn coalescer_admit(key: MrfIdentityKey) -> Result<MrfIngressLease, MrfIngressResult> {
|
||||
let shard = key_shard(&key);
|
||||
let mut entries = coalescer()[shard]
|
||||
.lock()
|
||||
.map_err(|_| MrfIngressResult::Dropped(MrfDropReason::CoalescerFull))?;
|
||||
let now = Instant::now();
|
||||
let before = entries.len();
|
||||
let mut expired_bytes = 0usize;
|
||||
entries.retain(|_, entry| {
|
||||
if entry.expires_at > now {
|
||||
true
|
||||
} else {
|
||||
expired_bytes = expired_bytes.saturating_add(entry.bytes);
|
||||
false
|
||||
}
|
||||
});
|
||||
let evicted = before.saturating_sub(entries.len());
|
||||
if evicted > 0 {
|
||||
MRF_COALESCER_COUNT.fetch_sub(evicted, Ordering::Relaxed);
|
||||
MRF_COALESCER_BYTES.fetch_sub(expired_bytes, Ordering::Relaxed);
|
||||
let evicted = u64::try_from(evicted).unwrap_or(u64::MAX);
|
||||
metrics::counter!("rustfs_heal_mrf_coalescer_expired_total").increment(evicted);
|
||||
metrics::counter!("rustfs_heal_mrf_coalescer_evictions_total").increment(evicted);
|
||||
}
|
||||
if entries.contains_key(&key) {
|
||||
metrics::counter!("rustfs_heal_mrf_coalesced_total").increment(1);
|
||||
return Err(MrfIngressResult::Coalesced);
|
||||
}
|
||||
let bytes = identity_estimated_bytes(&key);
|
||||
let count_reserved = reserve(&MRF_COALESCER_COUNT, MRF_COALESCER_MAX_KEYS, 1);
|
||||
let bytes_reserved = count_reserved && reserve(&MRF_COALESCER_BYTES, MRF_COALESCER_MAX_BYTES, bytes);
|
||||
if !count_reserved || !bytes_reserved {
|
||||
if count_reserved {
|
||||
MRF_COALESCER_COUNT.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
metrics::counter!("rustfs_heal_mrf_dropped_total", "reason" => "coalescer_full").increment(1);
|
||||
return Err(MrfIngressResult::Dropped(MrfDropReason::CoalescerFull));
|
||||
}
|
||||
let lease = MrfIngressLease::new(NEXT_MRF_LEASE.fetch_add(1, Ordering::Relaxed));
|
||||
if entries
|
||||
.insert(
|
||||
key,
|
||||
IngressEntry {
|
||||
lease,
|
||||
expires_at: now + MRF_COALESCER_TTL,
|
||||
bytes,
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
MRF_COALESCER_COUNT.fetch_sub(1, Ordering::Relaxed);
|
||||
MRF_COALESCER_BYTES.fetch_sub(bytes, Ordering::Relaxed);
|
||||
metrics::counter!("rustfs_heal_mrf_coalesced_total").increment(1);
|
||||
return Err(MrfIngressResult::Coalesced);
|
||||
}
|
||||
Ok(lease)
|
||||
}
|
||||
|
||||
fn coalescer_release(key: &MrfIdentityKey, lease: Option<MrfIngressLease>) {
|
||||
let Some(lease) = lease else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut entries) = coalescer()[key_shard(key)].lock() {
|
||||
let should_remove = entries.get(key).is_some_and(|entry| entry.lease == lease);
|
||||
if should_remove {
|
||||
let bytes = entries.remove(key).map(|entry| entry.bytes).unwrap_or(0);
|
||||
MRF_COALESCER_COUNT.fetch_sub(1, Ordering::Relaxed);
|
||||
MRF_COALESCER_BYTES.fetch_sub(bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivery kill-switch, set from `RUSTFS_HEAL_MRF_ENABLE`. Producers check
|
||||
/// this before touching the channel so the disabled path stays allocation- and
|
||||
/// sync-free.
|
||||
@@ -122,21 +326,90 @@ pub fn init_mrf_channel() -> Result<mpsc::Receiver<MrfIntent>, &'static str> {
|
||||
/// This runs on IO error paths, so it stays synchronous and cheap: one
|
||||
/// bounded allocation for the two `Arc<str>` handles plus the channel slot.
|
||||
pub fn try_send_mrf_intent(kind: MrfKind, bucket: &str, object: &str, version_id: Option<Uuid>) -> bool {
|
||||
matches!(
|
||||
try_send_mrf_intent_typed(kind, bucket, object, version_id, None),
|
||||
MrfIngressResult::Enqueued
|
||||
)
|
||||
}
|
||||
|
||||
/// Typed ingress result. `Coalesced` means an equivalent in-flight channel
|
||||
/// intent already exists; it is not a second executable or durable admission.
|
||||
pub fn try_send_mrf_intent_typed(
|
||||
kind: MrfKind,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<Uuid>,
|
||||
scope: Option<MrfScope>,
|
||||
) -> MrfIngressResult {
|
||||
if !mrf_delivery_enabled() {
|
||||
return false;
|
||||
return MrfIngressResult::Dropped(MrfDropReason::Disabled);
|
||||
}
|
||||
let Some(sender) = GLOBAL_MRF_SENDER.get() else {
|
||||
return false;
|
||||
return MrfIngressResult::Dropped(MrfDropReason::Uninitialized);
|
||||
};
|
||||
let intent = MrfIntent {
|
||||
if bucket.len() > MRF_MAX_IDENTITY_COMPONENT || object.len() > MRF_MAX_IDENTITY_COMPONENT {
|
||||
return MrfIngressResult::Dropped(MrfDropReason::OversizedIdentity);
|
||||
}
|
||||
let (version_id, scope) = canonical_identity(kind, canonical_version(version_id), scope);
|
||||
let key = MrfIdentityKey {
|
||||
kind,
|
||||
bucket: Arc::from(bucket),
|
||||
object: Arc::from(object),
|
||||
version_id: version_id.map(|vid| *vid.as_bytes()),
|
||||
version_id,
|
||||
scope,
|
||||
};
|
||||
let lease = match coalescer_admit(key.clone()) {
|
||||
Ok(lease) => lease,
|
||||
Err(result) => return result,
|
||||
};
|
||||
let intent = MrfIntent {
|
||||
bucket: key.bucket.clone(),
|
||||
object: key.object.clone(),
|
||||
version_id: key.version_id,
|
||||
kind,
|
||||
scope,
|
||||
lease: Some(lease),
|
||||
enqueued_at_ms: unix_now_ms(),
|
||||
attempts: 0,
|
||||
};
|
||||
sender.try_send(intent).is_ok()
|
||||
match sender.try_send(intent) {
|
||||
Ok(()) => MrfIngressResult::Enqueued,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
coalescer_release(&key, Some(lease));
|
||||
metrics::counter!("rustfs_heal_mrf_dropped_total", "reason" => "channel_full").increment(1);
|
||||
MrfIngressResult::Dropped(MrfDropReason::Full)
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
coalescer_release(&key, Some(lease));
|
||||
MrfIngressResult::Dropped(MrfDropReason::Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the ingress key once the consumer owns the intent.
|
||||
pub fn release_mrf_intent(intent: &MrfIntent) {
|
||||
release_mrf_identity(intent.kind, &intent.bucket, &intent.object, intent.version_id, intent.scope, intent.lease);
|
||||
}
|
||||
|
||||
pub fn release_mrf_identity(
|
||||
kind: MrfKind,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
lease: Option<MrfIngressLease>,
|
||||
) {
|
||||
let (version_id, scope) = canonical_identity(kind, version_id, scope);
|
||||
coalescer_release(
|
||||
&MrfIdentityKey {
|
||||
kind,
|
||||
bucket: Arc::from(bucket),
|
||||
object: Arc::from(object),
|
||||
version_id,
|
||||
scope,
|
||||
},
|
||||
lease,
|
||||
);
|
||||
}
|
||||
|
||||
fn unix_now_ms() -> u64 {
|
||||
@@ -144,7 +417,8 @@ fn unix_now_ms() -> u64 {
|
||||
// failure would be a bug rather than something to handle here.
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.ok()
|
||||
.and_then(|d| u64::try_from(d.as_millis()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -215,12 +489,60 @@ mod tests {
|
||||
object: Arc::from("object"),
|
||||
version_id: Some([0u8; 16]),
|
||||
kind: MrfKind::DecodeFailure,
|
||||
scope: None,
|
||||
lease: None,
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
assert!(intent.estimated_bytes() >= intent.bucket.len() + intent.object.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_duplicate_identity_coalesces_and_releases_for_retry() {
|
||||
let key = MrfIdentityKey {
|
||||
kind: MrfKind::DecodeFailure,
|
||||
bucket: Arc::from("ingress-test-bucket"),
|
||||
object: Arc::from("ingress-test-object"),
|
||||
version_id: Some([9; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 3,
|
||||
set_index: 4,
|
||||
}),
|
||||
};
|
||||
let lease = coalescer_admit(key.clone()).expect("first identity should be admitted");
|
||||
for _ in 0..999 {
|
||||
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
||||
}
|
||||
coalescer_release(&key, Some(lease));
|
||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||
coalescer_release(&key, Some(retry_lease));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_identity_preserves_kind_scope_and_version_boundaries() {
|
||||
let (nil_version, nil_scope) = canonical_identity(
|
||||
MrfKind::DecodeFailure,
|
||||
Some([0; 16]),
|
||||
Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
}),
|
||||
);
|
||||
assert_eq!(nil_version, None, "nil UUID is the unversioned identity");
|
||||
assert!(nil_scope.is_some());
|
||||
|
||||
let (metadata_version, metadata_scope) = canonical_identity(
|
||||
MrfKind::MetadataCorruption,
|
||||
Some([7; 16]),
|
||||
Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
}),
|
||||
);
|
||||
assert_eq!(metadata_version, None);
|
||||
assert_eq!(metadata_scope, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_send_delivers_and_respects_capacity() {
|
||||
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
||||
@@ -230,6 +552,7 @@ mod tests {
|
||||
let intent = receiver.recv().await.expect("intent should arrive");
|
||||
assert_eq!(intent.kind, MrfKind::DecodeFailure);
|
||||
assert_eq!(intent.bucket.as_ref(), "b");
|
||||
release_mrf_intent(&intent);
|
||||
|
||||
// Disable delivery: producers become no-ops.
|
||||
set_mrf_delivery_enabled(false);
|
||||
@@ -239,8 +562,8 @@ mod tests {
|
||||
// Fill the bounded channel past capacity: excess intents are dropped,
|
||||
// never blocking.
|
||||
let mut accepted = 0;
|
||||
for _ in 0..(MRF_CHANNEL_CAPACITY + 64) {
|
||||
if try_send_mrf_intent(MrfKind::PartialWrite, "b", "o", None) {
|
||||
for index in 0..(MRF_CHANNEL_CAPACITY + 64) {
|
||||
if try_send_mrf_intent(MrfKind::PartialWrite, "b", &format!("o-{index}"), None) {
|
||||
accepted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ Current guidance:
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
|
||||
|
||||
Scanner cycle budget controls:
|
||||
|
||||
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
|
||||
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
|
||||
- A timed-out cycle cancels cooperative scanner work, then fences its leader epoch before releasing the lease. An uncooperative I/O operation is dropped after the bounded shutdown window; its cursor is not claimed to be durable and the scanner reports `recovery-required` when the worker cannot stop cooperatively, the cycle state was not confirmed durable, or epoch fencing cannot be persisted.
|
||||
|
||||
## Mmap read environment aliases
|
||||
|
||||
- `RUSTFS_OBJECT_MMAP_READ_ENABLE` (canonical)
|
||||
@@ -144,9 +150,10 @@ Drive timeout health-action policy:
|
||||
Drive timeout profile preset:
|
||||
- `RUSTFS_DRIVE_TIMEOUT_PROFILE`
|
||||
- `default` (default): keep current timeout defaults.
|
||||
- `high_latency`: use 60s default timeout for scanner-sensitive operations when no per-operation timeout override is set (`read_metadata`, `disk_info`, `list_dir`, `walk_dir`, `walk_dir_stall`).
|
||||
- `high_latency`: use 60s default timeout for scanner-sensitive operations when no operation-specific override is set (`read_metadata`, `disk_info`, `list_dir`, `walk_dir`, `walk_dir_stall`, and object-capacity scan base/maximum budgets).
|
||||
- Precedence:
|
||||
- Explicit per-operation timeout env (`RUSTFS_DRIVE_*_TIMEOUT_SECS`) takes highest precedence.
|
||||
- Explicit object-capacity timeout env (`RUSTFS_CAPACITY_STAT_TIMEOUT`, `RUSTFS_CAPACITY_MAX_TIMEOUT`) takes precedence for capacity scans.
|
||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||
- Then the profile-derived default (`default` or `high_latency`).
|
||||
|
||||
|
||||
@@ -168,6 +168,35 @@ pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE);
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED);
|
||||
|
||||
/// Request writing pool metadata version 2.
|
||||
///
|
||||
/// This remains ineffective until [`ENV_POOL_META_V2_FLEET_CONFIRMED`] is also enabled.
|
||||
pub const ENV_POOL_META_V2_WRITE: &str = "RUSTFS_POOL_META_V2_WRITE";
|
||||
pub const DEFAULT_POOL_META_V2_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested confirmation that every pool metadata reader and writer understands version 2.
|
||||
pub const ENV_POOL_META_V2_FLEET_CONFIRMED: &str = "RUSTFS_POOL_META_V2_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_POOL_META_V2_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V2_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V2_FLEET_CONFIRMED);
|
||||
|
||||
/// Request writing pool metadata version 3 with durable generations.
|
||||
///
|
||||
/// Existing deployments remain on their observed version until
|
||||
/// [`ENV_POOL_META_V3_FLEET_CONFIRMED`] is also enabled. Fresh deployments may
|
||||
/// initialize directly at version 3 because they have no legacy readers.
|
||||
pub const ENV_POOL_META_V3_WRITE: &str = "RUSTFS_POOL_META_V3_WRITE";
|
||||
pub const DEFAULT_POOL_META_V3_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested confirmation that every pool metadata reader and writer
|
||||
/// understands the version 3 generation and recovery protocol.
|
||||
pub const ENV_POOL_META_V3_FLEET_CONFIRMED: &str = "RUSTFS_POOL_META_V3_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -736,4 +765,16 @@ mod remote_version_state_tests {
|
||||
"RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_v2_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_POOL_META_V2_WRITE, "RUSTFS_POOL_META_V2_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V2_FLEET_CONFIRMED, "RUSTFS_POOL_META_V2_FLEET_CONFIRMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_v3_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ pub const DEFAULT_MAX_IO_EVENTS_PER_TICK: usize = 1024;
|
||||
pub const DEFAULT_EVENT_INTERVAL: u32 = 61;
|
||||
pub const DEFAULT_RNG_SEED: Option<u64> = None; // None means random
|
||||
|
||||
/// Dedicated blocking thread pool for fsync/fdatasync operations.
|
||||
/// When > 1, fsync operations are isolated from the main blocking pool to
|
||||
/// prevent device-bound fsync from starving read operations (pread/stat/open).
|
||||
/// Default 64 isolates fsync from the main blocking pool to prevent device-bound fsync from starving read I/O.
|
||||
pub const ENV_FSYNC_BLOCKING_THREADS: &str = "RUSTFS_RUNTIME_FSYNC_BLOCKING_THREADS";
|
||||
pub const DEFAULT_FSYNC_BLOCKING_THREADS: usize = 64;
|
||||
|
||||
// Dial9 Tokio Telemetry Default values
|
||||
pub const DEFAULT_RUNTIME_DIAL9_ENABLED: bool = false; // Disabled by default
|
||||
pub const DEFAULT_RUNTIME_DIAL9_OUTPUT_DIR: &str = "/var/log/rustfs/telemetry";
|
||||
@@ -96,7 +103,7 @@ pub const ENV_ALLOCATOR_RECLAIM_ENABLED: &str = "RUSTFS_ALLOCATOR_RECLAIM_ENABLE
|
||||
pub const ENV_ALLOCATOR_RECLAIM_INTERVAL_SECS: &str = "RUSTFS_ALLOCATOR_RECLAIM_INTERVAL_SECS";
|
||||
pub const ENV_ALLOCATOR_RECLAIM_FORCE: &str = "RUSTFS_ALLOCATOR_RECLAIM_FORCE";
|
||||
pub const ENV_ALLOCATOR_RECLAIM_IDLE_INTERVALS: &str = "RUSTFS_ALLOCATOR_RECLAIM_IDLE_INTERVALS";
|
||||
pub const DEFAULT_ALLOCATOR_RECLAIM_ENABLED: bool = false;
|
||||
pub const DEFAULT_ALLOCATOR_RECLAIM_ENABLED: bool = true;
|
||||
pub const DEFAULT_ALLOCATOR_RECLAIM_INTERVAL_SECS: u64 = 30;
|
||||
pub const DEFAULT_ALLOCATOR_RECLAIM_FORCE: bool = true;
|
||||
pub const DEFAULT_ALLOCATOR_RECLAIM_IDLE_INTERVALS: u64 = 3;
|
||||
|
||||
@@ -143,9 +143,12 @@ pub const ENV_SCANNER_MAX_WAIT_SECS: &str = "RUSTFS_SCANNER_MAX_WAIT_SECS";
|
||||
/// Default scanner speed preset.
|
||||
pub const DEFAULT_SCANNER_SPEED: &str = "default";
|
||||
|
||||
/// Default scanner cycle runtime budget.
|
||||
/// `0` keeps the existing unbounded per-cycle behavior.
|
||||
pub const DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS: u64 = 0;
|
||||
/// Default scanner cycle runtime budget when no override is configured.
|
||||
///
|
||||
/// An explicit `0` remains the compatibility escape hatch for an unbounded
|
||||
/// cycle. Keeping the unset default finite prevents a stalled scanner I/O
|
||||
/// operation from holding the leader lease forever.
|
||||
pub const DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS: u64 = 30 * 60;
|
||||
|
||||
/// Default scanner per-cycle object budget.
|
||||
/// `0` keeps the existing unbounded per-cycle behavior.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,15 +109,22 @@ hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||
rustfs-signer.workspace = true
|
||||
|
||||
# The MFA e2e test computes RFC 6238 codes itself rather than calling the
|
||||
# server's implementation: a shared helper could agree with a bug on both sides.
|
||||
data-encoding = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
urlencoding.workspace = true
|
||||
walkdir.workspace = true
|
||||
base64 = { workspace = true }
|
||||
base64-simd = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
hex = { workspace = true }
|
||||
hex-simd = { workspace = true }
|
||||
md-5 = { workspace = true }
|
||||
opentelemetry-proto = { workspace = true }
|
||||
prost.workspace = true
|
||||
|
||||
+16
-11
@@ -27,6 +27,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
## How to run
|
||||
|
||||
@@ -72,7 +73,7 @@ The reason string on each attribute is the classifier. Current classes:
|
||||
|
||||
- **Needs a pre-started server** — `"requires running RustFS server at
|
||||
localhost:9000"` / `"Connects to existing rustfs server"`. These are the
|
||||
`reliant/*` and `policy/test_runner` tests; start a server first (e.g.
|
||||
`reliant/*` tests; start a server first (e.g.
|
||||
[`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh)) or use
|
||||
`--run-ignored`.
|
||||
- **Heavy / external tool** — `"Starts a rustfs server; enable when running
|
||||
@@ -123,7 +124,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
||||
| `find_available_port` | Random free port (isolation primitive) |
|
||||
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||
| `awscurl_available` + `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl` (skip gracefully when absent) |
|
||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||
| `local_http_client` / `init_logging` | Loopback HTTP client; idempotent tracing init |
|
||||
| `RustFSTestClusterEnvironment` (`new`/`start`/`start_node`/`stop_node`/`create_all_clients`) | Multi-node harness |
|
||||
@@ -168,6 +169,7 @@ the same profile for membership and execution with one nightly worker.
|
||||
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
||||
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||
| Direct upgrade from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||
@@ -189,7 +191,7 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
cargo nextest run --profile e2e-full -p e2e_test
|
||||
# Cluster fault nightly lane
|
||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
# Replication nightly lane; install awscurl so STS paths do not skip
|
||||
# Replication nightly lane; awscurl is required for STS paths
|
||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
# Fixed-port protocol nightly lane
|
||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
||||
@@ -221,9 +223,8 @@ The `s3s-e2e` CI job selects a random `RUSTFS_TEST_PORT` (see the `e2e-tests`
|
||||
job) to dodge this; local single-node tests already use random ports, so a
|
||||
lingering orphan is usually the cause of a spurious bind failure.
|
||||
|
||||
**`awscurl` not found.** `awscurl`-dependent tests skip gracefully with a
|
||||
visible log line (`awscurl_available()`); install `awscurl` to actually run
|
||||
them.
|
||||
**`awscurl` not found.** `awscurl`-dependent tests fail closed with a process
|
||||
spawn error. Install the pinned CI version before running their profiles.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -258,10 +259,9 @@ A test module may join the smoke filter only if every test in it is:
|
||||
2. **Single-node** — spawns its own server via
|
||||
`RustFSTestEnvironment`/`start_rustfs_server` on a random port with an
|
||||
isolated temp dir. No `RustFSTestClusterEnvironment`, no fixed ports.
|
||||
3. **Dependency-free** — no pre-started server at `localhost:9000`, no Vault,
|
||||
no fixed protocol ports. Tools that may be absent on the runner (e.g.
|
||||
`awscurl`) are acceptable only when the test skips gracefully with a
|
||||
visible log line (see `bucket_policy_check_test.rs`).
|
||||
3. **Hermetic dependencies** — no pre-started server at `localhost:9000`, no
|
||||
Vault, and no fixed protocol ports. Any required CLI must be pinned and
|
||||
installed by the workflow; a missing CLI must fail the test.
|
||||
4. **Not `#[ignore]`** — ignored tests are activation work (backlog#1149
|
||||
ci-13 / backlog#1148 ilm-3), not smoke candidates.
|
||||
|
||||
@@ -278,4 +278,9 @@ listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
||||
intentional, review its JSON listing before updating the matching
|
||||
`.config/e2e-*-selection.txt` test-ID digest.
|
||||
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
||||
produced the listing:
|
||||
|
||||
```bash
|
||||
python3 scripts/check_test_wiring.py --update-profile e2e-full /path/to/listing.json linux
|
||||
```
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client, rustfs_binary_path};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use http::header::HOST;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
@@ -368,10 +369,15 @@ mod tests {
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"stale root must be rejected on the admin API after rotation, body: {body}"
|
||||
);
|
||||
let s3_old = s3_client_with(&env, &old_ak, &old_sk).list_buckets().send().await;
|
||||
assert!(
|
||||
s3_old.is_err(),
|
||||
"stale root must be rejected on the S3 plane after rotation, got: {s3_old:?}"
|
||||
let s3_old = s3_client_with(&env, &old_ak, &old_sk)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("stale root must be rejected on the S3 plane after rotation");
|
||||
assert_eq!(
|
||||
s3_old.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidAccessKeyId"),
|
||||
"stale root must receive InvalidAccessKeyId after rotation: {s3_old:?}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::common::{
|
||||
RustFSTestEnvironment, admin_ok, admin_request, admin_request_with_session_token, build_test_sts_client, init_logging,
|
||||
};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use reqwest::StatusCode;
|
||||
@@ -411,8 +412,13 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.key("before-attach")
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
assert!(denied.is_err(), "user without a policy must not be able to write to {bucket}");
|
||||
.await
|
||||
.expect_err("user without a policy must not be able to write to the bucket");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"user without a policy must receive AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
// --- attach policy: the credential actually gains S3 access -----------------
|
||||
admin_ok(
|
||||
@@ -499,13 +505,19 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
if revoked.is_err() {
|
||||
break;
|
||||
match revoked {
|
||||
Ok(_) if tokio::time::Instant::now() >= deadline => {
|
||||
return Err("deleted service account credential still works".into());
|
||||
}
|
||||
Ok(_) => sleep(Duration::from_millis(500)).await,
|
||||
Err(error) => {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if matches!(code, Some("AccessDenied" | "InvalidAccessKeyId")) {
|
||||
break;
|
||||
}
|
||||
return Err(format!("deleted service account must fail with an authorization error, got {error:?}").into());
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err("deleted service account credential still works".into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Disable then remove the user; the credential must stop working.
|
||||
@@ -525,13 +537,19 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
if disabled.is_err() {
|
||||
break;
|
||||
match disabled {
|
||||
Ok(_) if tokio::time::Instant::now() >= deadline => {
|
||||
return Err("disabled user credential still works".into());
|
||||
}
|
||||
Ok(_) => sleep(Duration::from_millis(500)).await,
|
||||
Err(error) => {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if matches!(code, Some("AccessDenied" | "InvalidAccessKeyId")) {
|
||||
break;
|
||||
}
|
||||
return Err(format!("disabled user must fail with an authorization error, got {error:?}").into());
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err("disabled user credential still works".into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
admin_ok(
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! End-to-end coverage for the self-service account and two-factor surface.
|
||||
//!
|
||||
//! The unit tests cover the state machine at its edges; what only an end-to-end
|
||||
//! test can prove is that the pieces are wired together and that the *existing*
|
||||
//! authentication paths still behave. Specifically:
|
||||
//!
|
||||
//! 1. Enrollment is refused when `RUSTFS_IAM_MASTER_KEY` is absent, so a TOTP
|
||||
//! secret is never written where an attacker could read it off a disk.
|
||||
//! 2. With a master key, the full flow works: enroll, activate with a real
|
||||
//! RFC 6238 code, and receive single-use recovery codes.
|
||||
//! 3. Once a factor is enrolled, `AssumeRole` refuses to mint a session without
|
||||
//! one, and accepts a valid code — the actual login gate.
|
||||
//! 4. A direct SigV4 admin request keeps working with a factor enrolled. This is
|
||||
//! the regression that matters most: gating it would break every script and
|
||||
//! CLI the moment somebody enabled 2FA.
|
||||
//! 5. `AssumeRole` for an identity with no enrollment is byte-for-byte the old
|
||||
//! behaviour, so existing deployments are untouched.
|
||||
//! 6. Rotating a password through `/account/password` invalidates the sessions
|
||||
//! minted under the old secret.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use hmac::{Hmac, KeyInit as _, Mac};
|
||||
use http::header::HOST;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use sha1::Sha1;
|
||||
use std::error::Error;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const ACCOUNT_INFO_PATH: &str = "/rustfs/admin/v3/account/info";
|
||||
const ACCOUNT_PASSWORD_PATH: &str = "/rustfs/admin/v3/account/password";
|
||||
const ACCOUNT_MFA_PATH: &str = "/rustfs/admin/v3/account/mfa";
|
||||
const ACCOUNT_MFA_ENROLL_PATH: &str = "/rustfs/admin/v3/account/mfa/enroll";
|
||||
const ACCOUNT_MFA_ACTIVATE_PATH: &str = "/rustfs/admin/v3/account/mfa/activate";
|
||||
const MFA_CHALLENGE_PATH: &str = "/rustfs/admin/v3/mfa/challenge";
|
||||
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
||||
|
||||
/// A master key so the server will accept an enrollment. Test-only value.
|
||||
const TEST_MASTER_KEY: &str = "e2e-mfa-master-key-do-not-reuse";
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
/// One signed admin request, returning the status and the raw body.
|
||||
///
|
||||
/// Signs with `UNSIGNED_PAYLOAD` so the body does not participate in the
|
||||
/// hash, matching how the other admin e2e tests drive these routes.
|
||||
async fn signed_request(
|
||||
base_url: &str,
|
||||
method: http::Method,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{base_url}{path}");
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
||||
let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default();
|
||||
|
||||
let request = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let client = local_http_client();
|
||||
let mut builder = client.request(method, url.as_str());
|
||||
for (name, value) in signed.headers() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
if !body_bytes.is_empty() {
|
||||
builder = builder.body(body_bytes);
|
||||
}
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
/// A SigV4-signed `AssumeRole` form POST, optionally carrying a second factor.
|
||||
///
|
||||
/// Uses STS's own `SerialNumber`/`TokenCode` fields, which is the point: a
|
||||
/// script or SDK can present the factor without a RustFS-specific protocol.
|
||||
async fn assume_role(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
second_factor: Option<(&str, &str)>,
|
||||
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||
let mut form = vec![
|
||||
("Action", "AssumeRole".to_string()),
|
||||
("Version", "2011-06-15".to_string()),
|
||||
("RoleArn", "arn:aws:iam::*:role/Admin".to_string()),
|
||||
("RoleSessionName", "e2e".to_string()),
|
||||
("DurationSeconds", "3600".to_string()),
|
||||
];
|
||||
if let Some((challenge, code)) = second_factor {
|
||||
form.push(("SerialNumber", challenge.to_string()));
|
||||
form.push(("TokenCode", code.to_string()));
|
||||
}
|
||||
let body = serde_urlencoded::to_string(&form)?;
|
||||
|
||||
let uri = base_url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri(format!("{base_url}/"))
|
||||
.header(HOST, authority)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let client = local_http_client();
|
||||
let mut builder = client.request(http::Method::POST, format!("{base_url}/"));
|
||||
for (name, value) in signed.headers() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
let response = builder.body(body).send().await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
/// Generate the current RFC 6238 code for a base32 secret.
|
||||
///
|
||||
/// Computed independently of the server implementation: a shared helper
|
||||
/// could agree with a bug on both sides.
|
||||
fn totp_now(secret_base32: &str) -> String {
|
||||
let secret = data_encoding::BASE32_NOPAD
|
||||
.decode(secret_base32.as_bytes())
|
||||
.expect("server must return unpadded base32");
|
||||
let step = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock after the epoch")
|
||||
.as_secs()
|
||||
/ 30;
|
||||
|
||||
let mut mac = HmacSha1::new_from_slice(&secret).expect("HMAC accepts any key length");
|
||||
mac.update(&step.to_be_bytes());
|
||||
let digest = mac.finalize().into_bytes();
|
||||
|
||||
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
|
||||
let binary = u32::from_be_bytes([
|
||||
digest[offset] & 0x7f,
|
||||
digest[offset + 1],
|
||||
digest[offset + 2],
|
||||
digest[offset + 3],
|
||||
]);
|
||||
format!("{:06}", binary % 1_000_000)
|
||||
}
|
||||
|
||||
fn json(body: &str) -> serde_json::Value {
|
||||
serde_json::from_str(body).unwrap_or_else(|error| panic!("expected JSON, got {body}: {error}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enrollment_is_refused_without_at_rest_protection() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Deliberately no RUSTFS_IAM_MASTER_KEY.
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||
|
||||
// The account surface itself works.
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "account info must be reachable, body: {body}");
|
||||
let info = json(&body);
|
||||
assert_eq!(info["access_key"], access_key.as_str());
|
||||
assert_eq!(info["identity_type"], "root");
|
||||
assert_eq!(info["credentials_source"], "env");
|
||||
// Root credentials come from a process-wide OnceLock that also derives
|
||||
// the internode RPC secret, so they are immutable at runtime.
|
||||
assert_eq!(info["mutable"]["password"], false);
|
||||
|
||||
// Status reports the refusal rather than pretending enrollment is possible.
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, ACCOUNT_MFA_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "mfa status must be reachable, body: {body}");
|
||||
let mfa = json(&body);
|
||||
assert_eq!(mfa["enabled"], false);
|
||||
assert_eq!(mfa["enrollment_available"], false);
|
||||
assert!(
|
||||
mfa["enrollment_blocked_reason"]
|
||||
.as_str()
|
||||
.is_some_and(|reason| reason.contains("RUSTFS_IAM_MASTER_KEY")),
|
||||
"the refusal must name the variable an operator has to set, body: {body}"
|
||||
);
|
||||
|
||||
// And enrolling actually fails, rather than writing a plaintext secret.
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ACCOUNT_MFA_ENROLL_PATH,
|
||||
Some("{}"),
|
||||
&access_key,
|
||||
&secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
status.is_client_error() || status.is_server_error(),
|
||||
"enrollment must fail without a master key, status: {status}, body: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("RUSTFS_IAM_MASTER_KEY"),
|
||||
"the failure must explain the remedy, body: {body}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assume_role_is_unchanged_for_an_identity_with_no_second_factor() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
// The regression that protects every existing deployment: an identity
|
||||
// with no enrollment must take no new code path.
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||
.await?;
|
||||
|
||||
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "challenge must be reachable, body: {body}");
|
||||
let challenge = json(&body);
|
||||
assert_eq!(challenge["required"], false, "no enrollment means no challenge");
|
||||
assert!(challenge["challenge"].is_null());
|
||||
|
||||
let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "AssumeRole must still work, body: {body}");
|
||||
assert!(body.contains("<AccessKeyId>"), "expected STS credentials, body: {body}");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_full_second_factor_lifecycle_gates_only_session_minting() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||
.await?;
|
||||
|
||||
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||
|
||||
// --- Enroll ---
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ACCOUNT_MFA_ENROLL_PATH,
|
||||
Some("{}"),
|
||||
&access_key,
|
||||
&secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "enrollment must succeed, body: {body}");
|
||||
let enrollment = json(&body);
|
||||
let secret_base32 = enrollment["secret_base32"].as_str().expect("secret").to_string();
|
||||
assert!(
|
||||
enrollment["otpauth_uri"]
|
||||
.as_str()
|
||||
.is_some_and(|uri| uri.starts_with("otpauth://totp/RustFS:")),
|
||||
"body: {body}"
|
||||
);
|
||||
assert!(!enrollment["qr_svg"].as_str().unwrap_or_default().is_empty(), "expected an SVG");
|
||||
assert!(!enrollment["qr_utf8"].as_str().unwrap_or_default().is_empty(), "expected block art");
|
||||
|
||||
// A pending enrollment must not gate anything yet: a mis-scanned QR
|
||||
// cannot be allowed to lock the operator out.
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
assert_eq!(json(&body)["required"], false, "a pending enrollment must not gate login");
|
||||
|
||||
// --- Activate ---
|
||||
let code = totp_now(&secret_base32);
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ACCOUNT_MFA_ACTIVATE_PATH,
|
||||
Some(&format!(r#"{{"code":"{code}"}}"#)),
|
||||
&access_key,
|
||||
&secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "activation must succeed, body: {body}");
|
||||
let activated = json(&body);
|
||||
let recovery_codes = activated["recovery_codes"].as_array().expect("recovery codes").clone();
|
||||
assert_eq!(recovery_codes.len(), 10, "expected a full recovery set, body: {body}");
|
||||
|
||||
// --- The gate is now on for session minting ---
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK);
|
||||
let challenge_body = json(&body);
|
||||
assert_eq!(challenge_body["required"], true, "body: {body}");
|
||||
let challenge = challenge_body["challenge"].as_str().expect("challenge").to_string();
|
||||
|
||||
let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?;
|
||||
assert!(status.is_client_error(), "AssumeRole must refuse without a factor, body: {body}");
|
||||
assert!(
|
||||
body.contains("MultiFactorAuthRequired"),
|
||||
"clients match on this code to prompt instead of reporting a failed login, body: {body}"
|
||||
);
|
||||
|
||||
// --- ... but direct SigV4 access is untouched ---
|
||||
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &access_key, &secret_key).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::OK,
|
||||
"a direct admin request must keep working with a factor enrolled, body: {body}"
|
||||
);
|
||||
|
||||
// --- A valid factor mints the session ---
|
||||
// A fresh code: activation consumed the previous time step, so reusing
|
||||
// that code would be refused as a replay.
|
||||
let code = wait_for_a_fresh_code(&secret_base32).await;
|
||||
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &code))).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "a valid factor must mint a session, body: {body}");
|
||||
assert!(body.contains("<AccessKeyId>"), "expected STS credentials, body: {body}");
|
||||
|
||||
// --- A recovery code also works, once ---
|
||||
let recovery_code = recovery_codes[0].as_str().expect("recovery code").to_string();
|
||||
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "a recovery code must mint a session, body: {body}");
|
||||
|
||||
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"a spent recovery code must not work twice, status: {status}, body: {body}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_iam_user_can_rotate_its_own_password_and_lose_its_sessions() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||
.await?;
|
||||
|
||||
let (root_ak, root_sk) = (env.access_key.clone(), env.secret_key.clone());
|
||||
let user_ak = "mfarotationuser";
|
||||
let old_sk = "mfarotationsecret";
|
||||
let new_sk = "mfarotationsecret2";
|
||||
|
||||
// Root creates the user.
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={user_ak}"),
|
||||
Some(&format!(r#"{{"secretKey":"{old_sk}","status":"enabled"}}"#)),
|
||||
&root_ak,
|
||||
&root_sk,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "user creation must succeed, body: {body}");
|
||||
|
||||
// The user sees itself as mutable, unlike root.
|
||||
let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "body: {body}");
|
||||
let info = json(&body);
|
||||
assert_eq!(info["identity_type"], "iam");
|
||||
assert_eq!(info["credentials_source"], "iam");
|
||||
assert_eq!(info["mutable"]["password"], true);
|
||||
|
||||
// The wrong current secret is refused, so a live session alone cannot
|
||||
// rewrite the credential.
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ACCOUNT_PASSWORD_PATH,
|
||||
Some(&format!(r#"{{"current_secret_key":"wrong-secret","new_secret_key":"{new_sk}"}}"#)),
|
||||
user_ak,
|
||||
old_sk,
|
||||
)
|
||||
.await?;
|
||||
assert!(status.is_client_error(), "a wrong current secret must be refused, body: {body}");
|
||||
|
||||
// The correct one rotates it.
|
||||
let (status, body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ACCOUNT_PASSWORD_PATH,
|
||||
Some(&format!(r#"{{"current_secret_key":"{old_sk}","new_secret_key":"{new_sk}"}}"#)),
|
||||
user_ak,
|
||||
old_sk,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "rotation must succeed, body: {body}");
|
||||
|
||||
// The new secret works and the old one does not.
|
||||
let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, new_sk).await?;
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "the new secret must work, body: {body}");
|
||||
|
||||
let (status, _) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?;
|
||||
assert!(status.is_client_error(), "the old secret must stop working, status: {status}");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until the current time step differs from the one a code was just
|
||||
/// consumed in, then return a code for it.
|
||||
///
|
||||
/// Anti-replay burns the step, so a test that reuses a code inside its own
|
||||
/// window would fail for the right reason at the wrong moment.
|
||||
async fn wait_for_a_fresh_code(secret_base32: &str) -> String {
|
||||
let step_at_start = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock after the epoch")
|
||||
.as_secs()
|
||||
/ 30;
|
||||
|
||||
loop {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock after the epoch")
|
||||
.as_secs();
|
||||
if now / 30 > step_at_start {
|
||||
return totp_now(secret_base32);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::types::{
|
||||
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
||||
RequestPaymentConfiguration, WebsiteConfiguration,
|
||||
@@ -26,6 +28,26 @@ mod tests {
|
||||
use http::header::CONTENT_TYPE;
|
||||
use tracing::info;
|
||||
|
||||
fn assert_s3_error<T, E, R>(result: Result<T, R>, expected_status: u16, expected_code: &str, context: &str)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||
{
|
||||
let error = result.expect_err(context);
|
||||
let sdk_error = error.borrow();
|
||||
assert_eq!(
|
||||
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(expected_status),
|
||||
"{context}: expected HTTP {expected_status}, got: {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
sdk_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some(expected_code),
|
||||
"{context}: expected {expected_code}, got: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dummy_bucket_compatibility_endpoints() {
|
||||
init_logging();
|
||||
@@ -217,17 +239,11 @@ mod tests {
|
||||
.expect("DeleteBucketWebsite should return success");
|
||||
|
||||
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
|
||||
assert!(
|
||||
website_after_delete.is_err(),
|
||||
"GetBucketWebsite should return NoSuchWebsiteConfiguration after deletion"
|
||||
);
|
||||
let website_err = website_after_delete.err().unwrap();
|
||||
let website_code = website_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(website_code, Some("NoSuchWebsiteConfiguration")),
|
||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
||||
website_code,
|
||||
website_err
|
||||
assert_s3_error(
|
||||
website_after_delete,
|
||||
404,
|
||||
"NoSuchWebsiteConfiguration",
|
||||
"GetBucketWebsite after deleting the website configuration",
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
@@ -245,15 +261,7 @@ mod tests {
|
||||
let missing_bucket = "test-dummy-bucket-missing";
|
||||
|
||||
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
|
||||
assert!(get_logging.is_err(), "GetBucketLogging should fail for missing bucket");
|
||||
let get_logging_err = get_logging.err().unwrap();
|
||||
let get_logging_code = get_logging_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(get_logging_code, Some("NoSuchBucket")),
|
||||
"Unexpected GetBucketLogging error code: {:?}, err: {:?}",
|
||||
get_logging_code,
|
||||
get_logging_err
|
||||
);
|
||||
assert_s3_error(get_logging, 404, "NoSuchBucket", "GetBucketLogging for a missing bucket");
|
||||
|
||||
let put_logging = client
|
||||
.put_bucket_logging()
|
||||
@@ -261,41 +269,22 @@ mod tests {
|
||||
.bucket_logging_status(BucketLoggingStatus::builder().build())
|
||||
.send()
|
||||
.await;
|
||||
assert!(put_logging.is_err(), "PutBucketLogging should fail for missing bucket");
|
||||
let put_logging_err = put_logging.err().unwrap();
|
||||
let put_logging_code = put_logging_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(put_logging_code, Some("NoSuchBucket")),
|
||||
"Unexpected PutBucketLogging error code: {:?}, err: {:?}",
|
||||
put_logging_code,
|
||||
put_logging_err
|
||||
);
|
||||
assert_s3_error(put_logging, 404, "NoSuchBucket", "PutBucketLogging for a missing bucket");
|
||||
|
||||
let get_accelerate = client
|
||||
.get_bucket_accelerate_configuration()
|
||||
.bucket(missing_bucket)
|
||||
.send()
|
||||
.await;
|
||||
assert!(get_accelerate.is_err(), "GetBucketAccelerateConfiguration should fail for missing bucket");
|
||||
let get_accelerate_err = get_accelerate.err().unwrap();
|
||||
let get_accelerate_code = get_accelerate_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(get_accelerate_code, Some("NoSuchBucket")),
|
||||
"Unexpected GetBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
||||
get_accelerate_code,
|
||||
get_accelerate_err
|
||||
assert_s3_error(
|
||||
get_accelerate,
|
||||
404,
|
||||
"NoSuchBucket",
|
||||
"GetBucketAccelerateConfiguration for a missing bucket",
|
||||
);
|
||||
|
||||
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
|
||||
assert!(get_request_payment.is_err(), "GetBucketRequestPayment should fail for missing bucket");
|
||||
let get_request_payment_err = get_request_payment.err().unwrap();
|
||||
let get_request_payment_code = get_request_payment_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(get_request_payment_code, Some("NoSuchBucket")),
|
||||
"Unexpected GetBucketRequestPayment error code: {:?}, err: {:?}",
|
||||
get_request_payment_code,
|
||||
get_request_payment_err
|
||||
);
|
||||
assert_s3_error(get_request_payment, 404, "NoSuchBucket", "GetBucketRequestPayment for a missing bucket");
|
||||
|
||||
let put_accelerate = client
|
||||
.put_bucket_accelerate_configuration()
|
||||
@@ -307,14 +296,11 @@ mod tests {
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
assert!(put_accelerate.is_err(), "PutBucketAccelerateConfiguration should fail for missing bucket");
|
||||
let put_accelerate_err = put_accelerate.err().unwrap();
|
||||
let put_accelerate_code = put_accelerate_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(put_accelerate_code, Some("NoSuchBucket")),
|
||||
"Unexpected PutBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
||||
put_accelerate_code,
|
||||
put_accelerate_err
|
||||
assert_s3_error(
|
||||
put_accelerate,
|
||||
404,
|
||||
"NoSuchBucket",
|
||||
"PutBucketAccelerateConfiguration for a missing bucket",
|
||||
);
|
||||
|
||||
let put_request_payment = client
|
||||
@@ -328,15 +314,7 @@ mod tests {
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
assert!(put_request_payment.is_err(), "PutBucketRequestPayment should fail for missing bucket");
|
||||
let put_request_payment_err = put_request_payment.err().unwrap();
|
||||
let put_request_payment_code = put_request_payment_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(put_request_payment_code, Some("NoSuchBucket")),
|
||||
"Unexpected PutBucketRequestPayment error code: {:?}, err: {:?}",
|
||||
put_request_payment_code,
|
||||
put_request_payment_err
|
||||
);
|
||||
assert_s3_error(put_request_payment, 404, "NoSuchBucket", "PutBucketRequestPayment for a missing bucket");
|
||||
|
||||
let put_website = client
|
||||
.put_bucket_website()
|
||||
@@ -353,37 +331,13 @@ mod tests {
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
assert!(put_website.is_err(), "PutBucketWebsite should fail for missing bucket");
|
||||
let put_website_err = put_website.err().unwrap();
|
||||
let put_website_code = put_website_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(put_website_code, Some("NoSuchBucket")),
|
||||
"Unexpected PutBucketWebsite error code: {:?}, err: {:?}",
|
||||
put_website_code,
|
||||
put_website_err
|
||||
);
|
||||
assert_s3_error(put_website, 404, "NoSuchBucket", "PutBucketWebsite for a missing bucket");
|
||||
|
||||
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
|
||||
assert!(get_website.is_err(), "GetBucketWebsite should fail for missing bucket");
|
||||
let get_website_err = get_website.err().unwrap();
|
||||
let get_website_code = get_website_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(get_website_code, Some("NoSuchBucket")),
|
||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
||||
get_website_code,
|
||||
get_website_err
|
||||
);
|
||||
assert_s3_error(get_website, 404, "NoSuchBucket", "GetBucketWebsite for a missing bucket");
|
||||
|
||||
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
|
||||
assert!(delete_website.is_err(), "DeleteBucketWebsite should fail for missing bucket");
|
||||
let delete_website_err = delete_website.err().unwrap();
|
||||
let delete_website_code = delete_website_err.as_service_error().and_then(|e| e.code());
|
||||
assert!(
|
||||
matches!(delete_website_code, Some("NoSuchBucket")),
|
||||
"Unexpected DeleteBucketWebsite error code: {:?}, err: {:?}",
|
||||
delete_website_code,
|
||||
delete_website_err
|
||||
);
|
||||
assert_s3_error(delete_website, 404, "NoSuchBucket", "DeleteBucketWebsite for a missing bucket");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use tracing::info;
|
||||
|
||||
@@ -52,10 +53,6 @@ fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
|
||||
#[tokio::test]
|
||||
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !crate::common::awscurl_available() {
|
||||
info!("Skipping test_bucket_policy_authenticated_user because awscurl is not available");
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting test_bucket_policy_authenticated_user...");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -77,10 +74,14 @@ async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::erro
|
||||
let user_client = create_user_client(&env, user_access, user_secret);
|
||||
|
||||
// 4. Verify Access Denied initially (No Policy)
|
||||
let result = user_client.list_objects_v2().bucket(bucket_name).send().await;
|
||||
if result.is_ok() {
|
||||
return Err("Should be Access Denied initially".into());
|
||||
}
|
||||
let denied = user_client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket_name)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a user without a bucket policy must be denied");
|
||||
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
// 5. Apply Bucket Policy Allowed User
|
||||
let policy_json = serde_json::json!({
|
||||
|
||||
@@ -20,10 +20,10 @@ mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use sha2::Sha256;
|
||||
@@ -73,12 +73,12 @@ mod tests {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(body);
|
||||
let digest = hasher.finalize();
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||
}
|
||||
|
||||
fn checksum_sha256_base64(body: &[u8]) -> String {
|
||||
let digest = Sha256::digest(body);
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||
}
|
||||
|
||||
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
|
||||
@@ -186,21 +186,31 @@ mod tests {
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"PutObject with a mismatched SHA256 must be rejected, but it succeeded (issue #4341)"
|
||||
let error = result.expect_err("PutObject with a mismatched SHA256 must be rejected (issue #4341)");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(400),
|
||||
"Mismatched SHA256 must return HTTP 400, got {error:?}"
|
||||
);
|
||||
let err = result.err().unwrap();
|
||||
let msg = format!("{err:?}");
|
||||
info!("PutObject correctly rejected mismatched checksum: {msg}");
|
||||
assert!(
|
||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
||||
"Expected a BadDigest/checksum error, got: {msg}"
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("BadDigest"),
|
||||
"Mismatched SHA256 must return BadDigest, got {error:?}"
|
||||
);
|
||||
|
||||
// And the object must not have been stored.
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await;
|
||||
assert!(head.is_err(), "Object must not exist after a rejected mismatched-checksum PutObject");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Object must not exist after a rejected mismatched-checksum PutObject");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"Rejected mismatched-checksum PutObject absence probe must return HTTP 404, got {error:?}"
|
||||
);
|
||||
info!("PASSED: PutObject rejects mismatched SHA256 and stores nothing");
|
||||
}
|
||||
|
||||
@@ -546,14 +556,29 @@ mod tests {
|
||||
})
|
||||
.send()
|
||||
.await;
|
||||
assert!(put_bad.is_err(), "{header}: a mismatched checksum must be rejected");
|
||||
let msg = format!("{:?}", put_bad.err().unwrap());
|
||||
assert!(
|
||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
||||
"{header}: expected a BadDigest/checksum error, got: {msg}"
|
||||
let error = put_bad.expect_err("a mismatched checksum must be rejected");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(400),
|
||||
"{header}: mismatched checksum must return HTTP 400, got {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("BadDigest"),
|
||||
"{header}: mismatched checksum must return BadDigest, got {error:?}"
|
||||
);
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(&bad_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("nothing must be stored after a rejected PutObject");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"{header}: rejected PutObject absence probe must return HTTP 404, got {error:?}"
|
||||
);
|
||||
let head = client.head_object().bucket(bucket).key(&bad_key).send().await;
|
||||
assert!(head.is_err(), "{header}: nothing must be stored after a rejected PutObject");
|
||||
|
||||
info!("PASSED additional-checksum verify-on-write: {header}");
|
||||
}
|
||||
|
||||
@@ -15,16 +15,27 @@
|
||||
use crate::common::RustFSTestClusterEnvironment;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::types::{CorsConfiguration, CorsRule};
|
||||
use bytes::Bytes;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Barrier;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const BUCKET: &str = "conditional-put-race-bucket";
|
||||
const BUCKET_METADATA_RELOAD_BUCKET: &str = "bucket-metadata-reload-barrier";
|
||||
|
||||
async fn cleanup_object(client: &Client, key: &str) {
|
||||
if let Err(e) = client.delete_object().bucket(BUCKET).key(key).send().await {
|
||||
warn!("Failed to delete object '{}' from bucket '{}' during cleanup: {:?}", key, BUCKET, e);
|
||||
async fn cleanup_object(client: &Client, key: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
client.delete_object().bucket(BUCKET).key(key).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_bucket_cors_missing(client: &Client) {
|
||||
let result = client.get_bucket_cors().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await;
|
||||
match result {
|
||||
Err(SdkError::ServiceError(error)) => {
|
||||
assert_eq!(error.err().meta().code(), Some("NoSuchCORSConfiguration"));
|
||||
}
|
||||
result => panic!("expected the peer to report a missing CORS configuration: {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +82,13 @@ async fn run_race_iteration(
|
||||
test_key: &str,
|
||||
iteration: usize,
|
||||
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
cleanup_object(&clients[0], test_key).await;
|
||||
cleanup_object(&clients[0], test_key).await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let head_result = clients[0].head_object().bucket(BUCKET).key(test_key).send().await;
|
||||
|
||||
if head_result.is_ok() {
|
||||
warn!("Warning: Object still exists after cleanup, skipping iteration {}", iteration);
|
||||
return Ok(0);
|
||||
match clients[0].head_object().bucket(BUCKET).key(test_key).send().await {
|
||||
Ok(_) => return Err(format!("object still exists after cleanup in iteration {iteration}").into()),
|
||||
Err(error) if error.as_service_error().is_some_and(|error| error.is_not_found()) => {}
|
||||
Err(error) => return Err(format!("failed to verify cleanup in iteration {iteration}: {error:?}").into()),
|
||||
}
|
||||
|
||||
info!("\n=== Iteration {} ===", iteration);
|
||||
@@ -120,14 +130,16 @@ async fn run_race_iteration(
|
||||
|
||||
info!("Result: {} out of {} succeeded", success_count, clients.len());
|
||||
|
||||
if had_error {
|
||||
return Err("one or more conditional PUTs failed unexpectedly".into());
|
||||
}
|
||||
|
||||
if success_count > 1 {
|
||||
info!(">>> RACE CONDITION DETECTED!");
|
||||
} else if success_count == 1 {
|
||||
info!(">>> Correct behavior: exactly 1 writer succeeded.");
|
||||
} else if had_error {
|
||||
return Err("all conditional PUTs failed (e.g. cluster/bucket not ready)".into());
|
||||
} else {
|
||||
info!(">>> Unexpected: no writers succeeded.");
|
||||
return Err("no conditional PUT succeeded".into());
|
||||
}
|
||||
|
||||
Ok(success_count)
|
||||
@@ -167,7 +179,7 @@ async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::E
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_object(&clients[0], &test_key).await;
|
||||
cleanup_object(&clients[0], &test_key).await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
@@ -177,7 +189,7 @@ async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::E
|
||||
info!("Total iterations: {}", iterations);
|
||||
info!("Correct (1 winner): {}", correct_count);
|
||||
info!("Race conditions: {}", races_detected);
|
||||
info!("Errors (skipped): {}", error_count);
|
||||
info!("Failed iterations: {}", error_count);
|
||||
|
||||
assert_eq!(races_detected, 0, "Race conditions detected: {}/{}", races_detected, iterations);
|
||||
assert_eq!(
|
||||
@@ -185,6 +197,10 @@ async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::E
|
||||
"{} iteration(s) failed due to errors (e.g. cluster not ready)",
|
||||
error_count
|
||||
);
|
||||
assert_eq!(
|
||||
correct_count, iterations,
|
||||
"only {correct_count}/{iterations} iterations observed exactly one winner"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -201,7 +217,7 @@ async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::
|
||||
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
let test_key = "basic-conditional-put";
|
||||
cleanup_object(&client, test_key).await;
|
||||
cleanup_object(&client, test_key).await?;
|
||||
|
||||
let result = client
|
||||
.put_object()
|
||||
@@ -233,6 +249,51 @@ async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::
|
||||
assert_eq!(code, "PreconditionFailed");
|
||||
}
|
||||
|
||||
cleanup_object(&client, test_key).await;
|
||||
cleanup_object(&client, test_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET_METADATA_RELOAD_BUCKET).await?;
|
||||
|
||||
let writer = cluster.create_s3_client(0)?;
|
||||
let reader = cluster.create_s3_client(1)?;
|
||||
assert_bucket_cors_missing(&reader).await;
|
||||
|
||||
let rule = CorsRule::builder()
|
||||
.allowed_methods("GET")
|
||||
.allowed_origins("https://example.com")
|
||||
.build()?;
|
||||
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
||||
|
||||
writer
|
||||
.put_bucket_cors()
|
||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||
.cors_configuration(configuration)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response = reader.get_bucket_cors().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await?;
|
||||
let rules = response.cors_rules();
|
||||
assert_eq!(
|
||||
rules.len(),
|
||||
1,
|
||||
"peer should observe the committed CORS rule before the write response returns"
|
||||
);
|
||||
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
||||
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
||||
|
||||
writer
|
||||
.delete_bucket_cors()
|
||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
assert_bucket_cors_missing(&reader).await;
|
||||
writer.delete_bucket().bucket(BUCKET_METADATA_RELOAD_BUCKET).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -310,6 +310,17 @@ pub fn rustfs_binary_path() -> PathBuf {
|
||||
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
||||
}
|
||||
|
||||
fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&Path>) -> PathBuf {
|
||||
let mut path = match configured_target_dir {
|
||||
Some(path) if path.is_absolute() => path.to_path_buf(),
|
||||
Some(path) => workspace.join(path),
|
||||
None => workspace.join("target"),
|
||||
};
|
||||
path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
|
||||
path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
path
|
||||
}
|
||||
|
||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
||||
@@ -317,11 +328,9 @@ pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> Pat
|
||||
}
|
||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
let mut binary_path = workspace_root();
|
||||
binary_path.push("target");
|
||||
let profile_dir = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
binary_path.push(profile_dir);
|
||||
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
let workspace = workspace_root();
|
||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
||||
|
||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
||||
@@ -338,7 +347,7 @@ pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> Pat
|
||||
}
|
||||
|
||||
info!("Building RustFS binary to ensure it's up to date...");
|
||||
build_rustfs_binary(requested_features.as_deref());
|
||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
||||
|
||||
info!("Using RustFS binary at {:?}", binary_path);
|
||||
binary_path
|
||||
@@ -440,7 +449,7 @@ fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bo
|
||||
}
|
||||
|
||||
/// Build the RustFS binary using cargo
|
||||
fn build_rustfs_binary(requested_features: Option<&str>) {
|
||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
||||
let workspace = workspace_root();
|
||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
||||
|
||||
@@ -476,11 +485,7 @@ fn build_rustfs_binary(requested_features: Option<&str>) {
|
||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
||||
}
|
||||
|
||||
let mut binary_path = workspace;
|
||||
binary_path.push("target");
|
||||
binary_path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
|
||||
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
||||
}
|
||||
@@ -494,15 +499,20 @@ fn awscurl_binary_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("awscurl"))
|
||||
}
|
||||
|
||||
pub fn awscurl_available() -> bool {
|
||||
let path = awscurl_binary_path();
|
||||
if path.components().count() > 1 || path.is_absolute() {
|
||||
return path.is_file();
|
||||
fn verify_awscurl_path(path: &Path) -> std::io::Result<()> {
|
||||
let output = Command::new(path).arg("--help").output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(&path).is_file()))
|
||||
.unwrap_or(false)
|
||||
Err(std::io::Error::other(format!(
|
||||
"awscurl prerequisite check failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn require_awscurl() -> std::io::Result<()> {
|
||||
verify_awscurl_path(&awscurl_binary_path())
|
||||
}
|
||||
|
||||
// Global initialization
|
||||
@@ -628,6 +638,18 @@ impl RustFSTestEnvironment {
|
||||
extra_args: Vec<&str>,
|
||||
extra_env: &[(&str, &str)],
|
||||
cleanup_existing: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = rustfs_binary_path();
|
||||
self.start_rustfs_server_inner_with_binary(&binary_path, extra_args, extra_env, cleanup_existing)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_rustfs_server_inner_with_binary(
|
||||
&mut self,
|
||||
binary_path: &Path,
|
||||
extra_args: Vec<&str>,
|
||||
extra_env: &[(&str, &str)],
|
||||
cleanup_existing: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if cleanup_existing {
|
||||
self.cleanup_existing_processes().await?;
|
||||
@@ -637,8 +659,7 @@ impl RustFSTestEnvironment {
|
||||
|
||||
info!("Starting RustFS server with args: {:?}", args);
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let mut command = Command::new(&binary_path);
|
||||
let mut command = Command::new(binary_path);
|
||||
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
||||
// The embedded console would bind the fixed default port :9001, which
|
||||
// collides with unrelated local services (e.g. Docker Desktop). Tests
|
||||
@@ -658,6 +679,19 @@ impl RustFSTestEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a specific RustFS binary against this environment's isolated
|
||||
/// data directory. Upgrade tests use this to seed an old on-disk format
|
||||
/// before restarting the same environment with the workspace binary.
|
||||
pub async fn start_rustfs_server_from_binary(
|
||||
&mut self,
|
||||
binary_path: &Path,
|
||||
extra_args: Vec<&str>,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.start_rustfs_server_inner_with_binary(binary_path, extra_args, extra_env, true)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start RustFS server with basic configuration
|
||||
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.start_rustfs_server_inner(extra_args, &[], true).await
|
||||
@@ -1747,6 +1781,22 @@ mod tests {
|
||||
assert_eq!(normalize_rustfs_build_features(" , "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_awscurl_is_a_prerequisite_failure() {
|
||||
let missing = std::env::temp_dir().join(format!("missing-awscurl-{}", Uuid::new_v4()));
|
||||
|
||||
let error = verify_awscurl_path(&missing).expect_err("a missing awscurl binary must fail the test prerequisite");
|
||||
|
||||
assert_eq!(error.kind(), ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_awscurl_client_passes_prerequisite_check() {
|
||||
let executable = std::env::current_exe().expect("the test executable should have a path");
|
||||
|
||||
verify_awscurl_path(&executable).expect("an available client with a working help command should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_log_path_uses_temp_directory_basename() {
|
||||
assert_eq!(
|
||||
@@ -1755,6 +1805,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||
let workspace = Path::new("workspace");
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
let binary = format!("rustfs{}", std::env::consts::EXE_SUFFIX);
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, None),
|
||||
workspace.join("target").join(profile).join(&binary)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, Some(Path::new("custom-target"))),
|
||||
workspace.join("custom-target").join(profile).join(&binary)
|
||||
);
|
||||
|
||||
let absolute = std::env::temp_dir().join("rustfs-e2e-custom-target");
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, Some(&absolute)),
|
||||
absolute.join(profile).join(binary)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_feature_enables_any_required_feature() {
|
||||
assert!(rustfs_build_feature_enabled(Some("full"), "sftp"));
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::info;
|
||||
|
||||
@@ -31,30 +32,58 @@ fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> io::Result<Vec<PathBuf>> {
|
||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||
let mut part_files = Vec::new();
|
||||
|
||||
fn scan_dir(dir: &PathBuf, target: &str, results: &mut Vec<PathBuf>) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, target, results);
|
||||
} else if path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("part."))
|
||||
.unwrap_or(false)
|
||||
&& path.to_string_lossy().contains(target)
|
||||
{
|
||||
results.push(path);
|
||||
fn scan_dir(dir: &Path, target: &str, results: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to read {}: {error}", dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to read entry in {}: {error}", dir.display())))?;
|
||||
let path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to inspect {}: {error}", path.display())))?;
|
||||
if file_type.is_dir() {
|
||||
scan_dir(&path, target, results)?;
|
||||
} else if path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("part."))
|
||||
.unwrap_or(false)
|
||||
&& path.to_string_lossy().contains(target)
|
||||
{
|
||||
if !file_type.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("expected regular part file at {}", path.display()),
|
||||
));
|
||||
}
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
scan_dir(&bucket_path, object_key, &mut part_files);
|
||||
part_files
|
||||
scan_dir(&bucket_path, object_key, &mut part_files)?;
|
||||
Ok(part_files)
|
||||
}
|
||||
|
||||
fn part_files_total_size(part_files: &[PathBuf]) -> io::Result<u64> {
|
||||
part_files.iter().try_fold(0_u64, |total, path| {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to stat {}: {error}", path.display())))?;
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("expected regular part file at {}", path.display()),
|
||||
));
|
||||
}
|
||||
total
|
||||
.checked_add(metadata.len())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "on-disk part size overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -123,8 +152,9 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
|
||||
let content_length = head_response.content_length().unwrap_or(0);
|
||||
assert_eq!(content_length as usize, original_size, "Content-Length should be original size");
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, COMPRESSION_TEST_BUCKET, object_key);
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let part_files = find_part_files(&env.temp_dir, COMPRESSION_TEST_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the compressed object");
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
|
||||
assert!(
|
||||
total_physical_size < original_size as u64,
|
||||
@@ -246,9 +276,9 @@ async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
@@ -366,9 +396,9 @@ async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(),
|
||||
|
||||
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
|
||||
// margin still proves the parts were stored compressed rather than raw or double-encoded.
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size as u64) / 2,
|
||||
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
|
||||
@@ -522,9 +552,9 @@ async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), B
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
|
||||
@@ -585,9 +615,9 @@ async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(),
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
@@ -622,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
|
||||
async fn start_rustfs_with_compression_and_sse(
|
||||
env: &mut RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use base64::Engine;
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
// Server output goes to a file inside the per-test temp dir so a failing
|
||||
// run can be diagnosed from the child's logs.
|
||||
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
||||
@@ -734,9 +763,9 @@ async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std
|
||||
"HEAD must report SSE-S3"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
|
||||
|
||||
@@ -27,8 +27,7 @@ mod tests {
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64_simd::STANDARD as BASE64;
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
@@ -465,7 +464,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
client
|
||||
.put_object()
|
||||
@@ -534,7 +533,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"another deterministic payload whose source checksum must survive the copy";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
||||
let put_src = client
|
||||
@@ -614,7 +613,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
// Source is stored WITH a SHA-256 checksum.
|
||||
client
|
||||
|
||||
@@ -59,7 +59,6 @@ where
|
||||
/// Regression test for data usage accuracy (issue #1012).
|
||||
/// Launches rustfs, writes 1000 objects, then asserts admin data usage reports the full count.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
|
||||
async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -86,28 +85,20 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
usage
|
||||
.buckets_usage
|
||||
.get(TEST_BUCKET)
|
||||
.map(|bucket_usage| usage.objects_total_count >= 1000 && bucket_usage.objects_count >= 1000)
|
||||
.map(|bucket_usage| usage.objects_total_count == 1000 && bucket_usage.objects_count == 1000)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Assert total object count and per-bucket count are not truncated
|
||||
// Assert total object count and per-bucket count are exact.
|
||||
let bucket_usage = usage
|
||||
.buckets_usage
|
||||
.get(TEST_BUCKET)
|
||||
.cloned()
|
||||
.expect("bucket usage should exist");
|
||||
|
||||
assert!(
|
||||
usage.objects_total_count >= 1000,
|
||||
"total object count should be at least 1000, got {}",
|
||||
usage.objects_total_count
|
||||
);
|
||||
assert!(
|
||||
bucket_usage.objects_count >= 1000,
|
||||
"bucket object count should be at least 1000, got {}",
|
||||
bucket_usage.objects_count
|
||||
);
|
||||
assert_eq!(usage.objects_total_count, 1000, "total object count should be exact");
|
||||
assert_eq!(bucket_usage.objects_count, 1000, "bucket object count should be exact");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
@@ -116,7 +107,6 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
/// Regression test for issue #3898.
|
||||
/// Versioned buckets should expose versions and delete markers through admin data usage.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
|
||||
async fn data_usage_reports_versioned_objects_and_delete_markers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
//! It was fixed in three layers on `main`, each with its own *unit* regression:
|
||||
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
|
||||
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
|
||||
//! (`rustfs/src/app/object_usecase.rs`,
|
||||
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
|
||||
//! (`rustfs/src/app/object/get.rs`,
|
||||
//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`).
|
||||
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
|
||||
//! the legacy per-part decode in place, and surfaces reconstruction errors
|
||||
//! instead of silently truncating
|
||||
|
||||
@@ -155,8 +155,13 @@ mod tests {
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await;
|
||||
assert!(get_deleted_version.is_err(), "explicitly deleted version should no longer be readable");
|
||||
.await
|
||||
.expect_err("explicitly deleted version should no longer be readable");
|
||||
assert_eq!(
|
||||
get_deleted_version.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"explicitly deleted version absence probe must return HTTP 404, got {get_deleted_version:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -117,9 +117,18 @@ mod tests {
|
||||
);
|
||||
|
||||
// Verify HEAD returns 404
|
||||
let head = client.head_object().bucket(bucket).key("to-delete.txt").send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05 FAIL: HEAD on deleted object should return error, got success");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("RT-05 FAIL: HEAD on deleted object should return 404, got success");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"RT-05 FAIL: HEAD on deleted object must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
info!("RT-05 PASS: delete correctly removes object from LIST and HEAD");
|
||||
Ok(())
|
||||
@@ -414,9 +423,18 @@ mod tests {
|
||||
|
||||
// All HEAD requests should return 404
|
||||
for key in &keys {
|
||||
let head = client.head_object().bucket(bucket).key(*key).send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05f FAIL: HEAD on deleted key '{key}' should return error");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("RT-05f FAIL: HEAD on deleted key should return 404, got success");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"RT-05f FAIL: HEAD on deleted key '{key}' must return HTTP 404, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// LIST should be empty
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
//! session policy** (`Policy` parameter) via `awscurl --service sts` with explicit
|
||||
//! `Content-Type: application/x-www-form-urlencoded` on `POST /`.
|
||||
|
||||
use crate::common::{
|
||||
RustFSTestEnvironment, awscurl_available, awscurl_delete, awscurl_post_sts_form_urlencoded, awscurl_put, init_logging,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_post_sts_form_urlencoded, awscurl_put, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
@@ -175,11 +174,6 @@ async fn cleanup_bucket_and_object(admin: &Client, bucket: &str, key: &str) {
|
||||
#[tokio::test]
|
||||
async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_iam_policy_existing_object_tag_get_object: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let user = format!("e2eiamtag-{suffix}");
|
||||
let user_secret = "longSecretKeyForTest123!";
|
||||
@@ -215,10 +209,17 @@ async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<
|
||||
let _ = out.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
|
||||
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(
|
||||
denied.is_err(),
|
||||
"GetObject must be denied when ExistingObjectTag no longer matches IAM policy"
|
||||
let denied = uclient
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GetObject must be denied when ExistingObjectTag no longer matches IAM policy");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"IAM ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
@@ -233,11 +234,6 @@ async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<
|
||||
#[tokio::test]
|
||||
async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_bucket_policy_existing_object_tag_get_object: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let user = format!("e2ebptag-{suffix}");
|
||||
let user_secret = "longSecretKeyForTest456!";
|
||||
@@ -257,8 +253,13 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await;
|
||||
assert!(deny_before.is_err(), "without bucket policy, user must be denied");
|
||||
.await
|
||||
.expect_err("without bucket policy, user must be denied");
|
||||
assert_eq!(
|
||||
deny_before.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"missing bucket policy must return AccessDenied: {deny_before:?}"
|
||||
);
|
||||
|
||||
let bp = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
@@ -280,8 +281,18 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
|
||||
let _ = ok.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
|
||||
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(denied.is_err(), "GetObject must fail when tag no longer satisfies bucket policy");
|
||||
let denied = uclient
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GetObject must fail when tag no longer satisfies bucket policy");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"bucket-policy ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
admin_remove_user(&env, &user).await;
|
||||
@@ -294,11 +305,6 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
|
||||
#[tokio::test]
|
||||
async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_sts_assume_role_session_policy_existing_object_tag: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let parent = format!("e2e-sts-par-{suffix}");
|
||||
let parent_secret = "longSecretKeyForParentSts99!";
|
||||
@@ -352,10 +358,17 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
let _ = ok.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&parent_client, &bucket, key, "security", "private").await?;
|
||||
let denied = session_client.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(
|
||||
denied.is_err(),
|
||||
"session policy must deny GetObject when ExistingObjectTag no longer matches"
|
||||
let denied = session_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("session policy must deny GetObject when ExistingObjectTag no longer matches");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"STS ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
@@ -370,11 +383,6 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
#[tokio::test]
|
||||
async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_sts_session_policy_delete_objects_object_prefix_only: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let parent = format!("e2e-sts-del-par-{suffix}");
|
||||
let parent_secret = "longSecretKeyForParentDelete99!";
|
||||
@@ -455,8 +463,18 @@ async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Resu
|
||||
assert_eq!(error.key(), Some(denied_key));
|
||||
assert_eq!(error.code(), Some("AccessDenied"));
|
||||
|
||||
let allowed_head = parent_client.head_object().bucket(&bucket).key(allowed_key).send().await;
|
||||
assert!(allowed_head.is_err(), "allowed-prefix object should have been deleted");
|
||||
let allowed_head = parent_client
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(allowed_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("allowed-prefix object should have been deleted");
|
||||
assert_eq!(
|
||||
allowed_head.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"allowed-prefix object absence probe must return HTTP 404, got {allowed_head:?}"
|
||||
);
|
||||
|
||||
parent_client
|
||||
.head_object()
|
||||
|
||||
@@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
|
||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
||||
@@ -1375,7 +1375,7 @@ impl S3 for FakeBackend {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||
hex::encode(digest)
|
||||
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
};
|
||||
let version = ObjectVersion {
|
||||
@@ -1660,7 +1660,7 @@ impl S3 for FakeBackend {
|
||||
}
|
||||
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||
let e_tag = hex::encode(digest);
|
||||
let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower);
|
||||
let mut state = lock(&self.store);
|
||||
let existing_bytes = state
|
||||
.uploads
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
//! E2E tests for group management (fixes #2028).
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_put, init_logging};
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use tracing::info;
|
||||
@@ -83,7 +83,6 @@ async fn update_group_members_rejects_invalid_new_group_names() -> Result<(), Bo
|
||||
|
||||
/// Test that deleting a group with members fails, and deleting an empty group succeeds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires awscurl and spawns a real RustFS server"]
|
||||
async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -91,29 +90,58 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
// 1. Create a user
|
||||
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey=testuser1", env.url);
|
||||
let user_body = serde_json::json!({
|
||||
"secretKey": "testuser1secret",
|
||||
"status": "enabled"
|
||||
});
|
||||
awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/add-user?accessKey=testuser1",
|
||||
Some(user_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Created testuser1");
|
||||
|
||||
// 2. Create a group with testuser1 as a member
|
||||
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
|
||||
let add_member_body = serde_json::json!({
|
||||
"group": "testgroup",
|
||||
"members": ["testuser1"],
|
||||
"isRemove": false,
|
||||
"groupStatus": "enabled"
|
||||
});
|
||||
awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/update-group-members",
|
||||
Some(add_member_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Added testuser1 to testgroup");
|
||||
|
||||
// 3. Attempt to delete the group while it still has members — should fail
|
||||
let delete_group_url = format!("{}/rustfs/admin/v3/group/testgroup", env.url);
|
||||
let delete_result = awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await;
|
||||
assert!(delete_result.is_err(), "deleting a non-empty group should fail");
|
||||
let (delete_status, delete_body) = admin_request(
|
||||
&env.url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/group/testgroup",
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
delete_status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"deleting a non-empty group must return HTTP 400, body: {delete_body}"
|
||||
);
|
||||
assert!(
|
||||
delete_body.contains("<Code>InvalidRequest</Code>"),
|
||||
"deleting a non-empty group must return InvalidRequest, body: {delete_body}"
|
||||
);
|
||||
assert!(
|
||||
delete_body.contains("<Message>group is not empty</Message>"),
|
||||
"deleting a non-empty group returned an unexpected message: {delete_body}"
|
||||
);
|
||||
info!("Delete of non-empty group correctly rejected");
|
||||
|
||||
// 4. Remove the member from the group
|
||||
@@ -123,17 +151,42 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
|
||||
"isRemove": true,
|
||||
"groupStatus": "enabled"
|
||||
});
|
||||
awscurl_put(&update_members_url, &remove_member_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/update-group-members",
|
||||
Some(remove_member_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Removed testuser1 from testgroup");
|
||||
|
||||
// 5. Delete the now-empty group — should succeed
|
||||
awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(&env, http::Method::DELETE, "/rustfs/admin/v3/group/testgroup", None).await?;
|
||||
info!("Deleted empty testgroup successfully");
|
||||
|
||||
// 6. Verify the group no longer exists
|
||||
let get_group_url = format!("{}/rustfs/admin/v3/group?group=testgroup", env.url);
|
||||
let get_result = awscurl_get(&get_group_url, &env.access_key, &env.secret_key).await;
|
||||
assert!(get_result.is_err(), "group should no longer exist after deletion");
|
||||
let (get_status, get_body) = admin_request(
|
||||
&env.url,
|
||||
http::Method::GET,
|
||||
"/rustfs/admin/v3/group?group=testgroup",
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
get_status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a deleted group must return HTTP 404, body: {get_body}"
|
||||
);
|
||||
assert!(
|
||||
get_body.contains("<Code>NoSuchResource</Code>"),
|
||||
"a deleted group must return NoSuchResource, body: {get_body}"
|
||||
);
|
||||
assert!(
|
||||
get_body.contains("<Message>group 'testgroup' does not exist</Message>"),
|
||||
"a deleted group returned an unexpected message: {get_body}"
|
||||
);
|
||||
info!("Confirmed testgroup no longer exists");
|
||||
|
||||
Ok(())
|
||||
@@ -142,7 +195,6 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
|
||||
/// Test that a user with only group membership (no explicit user policy) gets group policies
|
||||
/// and can perform actions allowed by the group (regression test for #2028.1).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires awscurl and spawns a real RustFS server"]
|
||||
async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -160,39 +212,56 @@ async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn s
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["*"]
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}]
|
||||
});
|
||||
let add_policy_url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
|
||||
awscurl_put(&add_policy_url, &policy_doc.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}"),
|
||||
Some(policy_doc.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Created canned policy {}", policy_name);
|
||||
|
||||
// 2. Create user with no explicit policy
|
||||
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, user_name);
|
||||
let user_body = serde_json::json!({
|
||||
"secretKey": user_secret,
|
||||
"status": "enabled"
|
||||
});
|
||||
awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={user_name}"),
|
||||
Some(user_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Created user {} with no explicit policy", user_name);
|
||||
|
||||
// 3. Add user to group (creates group with this member; user_group_memberships must be updated)
|
||||
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
|
||||
let add_member_body = serde_json::json!({
|
||||
"group": group_name,
|
||||
"members": [user_name],
|
||||
"isRemove": false,
|
||||
"groupStatus": "enabled"
|
||||
});
|
||||
awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/update-group-members",
|
||||
Some(add_member_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Added {} to group {}", user_name, group_name);
|
||||
|
||||
// 4. Attach policy to group
|
||||
let set_policy_url = format!(
|
||||
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=true",
|
||||
env.url, policy_name, group_name
|
||||
);
|
||||
awscurl_put(&set_policy_url, "", &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={group_name}&isGroup=true"),
|
||||
Some(String::new()),
|
||||
)
|
||||
.await?;
|
||||
info!("Attached policy {} to group {}", policy_name, group_name);
|
||||
|
||||
// 5. User with only group (no user policy) should be able to list buckets
|
||||
@@ -209,7 +278,6 @@ async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn s
|
||||
/// Test that after deleting a user who was the only member of a group, the group can be deleted
|
||||
/// (regression test for #2028.2: delete group uses backend membership, not stale cache).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires awscurl and spawns a real RustFS server"]
|
||||
async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -221,33 +289,47 @@ async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::erro
|
||||
let group_name = "soledeletegroup";
|
||||
|
||||
// 1. Create user
|
||||
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, user_name);
|
||||
let user_body = serde_json::json!({
|
||||
"secretKey": user_secret,
|
||||
"status": "enabled"
|
||||
});
|
||||
awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={user_name}"),
|
||||
Some(user_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Created user {}", user_name);
|
||||
|
||||
// 2. Add user to group
|
||||
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
|
||||
let add_member_body = serde_json::json!({
|
||||
"group": group_name,
|
||||
"members": [user_name],
|
||||
"isRemove": false,
|
||||
"groupStatus": "enabled"
|
||||
});
|
||||
awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/update-group-members",
|
||||
Some(add_member_body.to_string()),
|
||||
)
|
||||
.await?;
|
||||
info!("Added {} to group {}", user_name, group_name);
|
||||
|
||||
// 3. Delete the user (backend and cache update so group membership becomes empty)
|
||||
let remove_user_url = format!("{}/rustfs/admin/v3/remove-user?accessKey={}", env.url, user_name);
|
||||
awscurl_delete(&remove_user_url, &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::DELETE,
|
||||
&format!("/rustfs/admin/v3/remove-user?accessKey={user_name}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
info!("Deleted user {}", user_name);
|
||||
|
||||
// 4. Deleting the group should succeed (backend has empty members; no stale cache)
|
||||
let delete_group_url = format!("{}/rustfs/admin/v3/group/{}", env.url, group_name);
|
||||
awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await?;
|
||||
admin_ok(&env, http::Method::DELETE, &format!("/rustfs/admin/v3/group/{group_name}"), None).await?;
|
||||
info!("Deleted group {} after user was removed", group_name);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -380,10 +380,24 @@ mod tests {
|
||||
cluster.start_node(1).await?;
|
||||
|
||||
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
||||
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
assert!(
|
||||
!status_body.contains("MissingContentLength"),
|
||||
"background heal status should not fail without an explicit Content-Length: {status_body}"
|
||||
let mut recovered = serde_json::Value::Null;
|
||||
for _ in 0..60 {
|
||||
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
assert!(
|
||||
!status_body.contains("MissingContentLength"),
|
||||
"background heal status should not fail without an explicit Content-Length: {status_body}"
|
||||
);
|
||||
recovered = serde_json::from_str(&status_body)
|
||||
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
|
||||
if recovered["clusterStatusComplete"] == serde_json::Value::Bool(true) {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
recovered["clusterStatusComplete"],
|
||||
serde_json::Value::Bool(true),
|
||||
"cluster heal status should recover before root heal starts: {recovered}"
|
||||
);
|
||||
|
||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
|
||||
@@ -28,7 +28,6 @@ use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||
LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration,
|
||||
};
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use http::header::{CONTENT_ENCODING, HOST};
|
||||
@@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult {
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
configure_reader_metric_cluster(&mut cluster, &collector);
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
|
||||
cluster.start().await?;
|
||||
|
||||
@@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
||||
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
||||
@@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
|
||||
hot.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
||||
hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1");
|
||||
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||
hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
hot.start().await?;
|
||||
|
||||
@@ -37,7 +37,7 @@ async fn test_bucket_default_sse_s3_put_object() -> Result<(), Box<dyn std::erro
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -159,7 +159,7 @@ async fn test_bucket_default_sse_kms_put_object() -> Result<(), Box<dyn std::err
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -278,7 +278,7 @@ async fn test_bucket_default_sse_kms_multipart_crc32() -> Result<(), Box<dyn std
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -475,7 +475,7 @@ async fn test_explicit_encryption_overrides_bucket_default() -> Result<(), Box<d
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -570,7 +570,7 @@ async fn test_sse_kms_without_key_id_populates_default() -> Result<(), Box<dyn s
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
@@ -22,13 +22,12 @@
|
||||
//! - KMS backend configuration (Local and Vault)
|
||||
//! - SSE encryption testing utilities
|
||||
|
||||
use crate::common::{
|
||||
RustFSTestEnvironment, awscurl_available, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use base64_simd::STANDARD as BASE64;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
@@ -52,6 +51,9 @@ pub const VAULT_TOKEN: &str = "dev-root-token";
|
||||
pub const VAULT_TRANSIT_PATH: &str = "transit";
|
||||
pub const VAULT_KEY_NAME: &str = "rustfs-master-key";
|
||||
pub const ENV_TEST_VAULT_BIN: &str = "RUSTFS_TEST_VAULT_BIN";
|
||||
pub const SSE_C_KEY_MISMATCH_MESSAGE: &str =
|
||||
"The provided encryption parameters did not match the ones used originally to encrypt the object.";
|
||||
pub const SSE_C_MISSING_PARAMETERS_MESSAGE: &str = "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.";
|
||||
|
||||
/// Initialize tracing for KMS tests with KMS-specific log levels
|
||||
pub fn init_logging() {
|
||||
@@ -59,19 +61,28 @@ pub fn init_logging() {
|
||||
// Additional KMS-specific logging configuration can be added here if needed
|
||||
}
|
||||
|
||||
pub fn skip_if_kms_admin_tool_unavailable(test_name: &str) -> bool {
|
||||
if awscurl_available() {
|
||||
return false;
|
||||
}
|
||||
|
||||
info!("Skipping {} because awscurl is not available in PATH", test_name);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(key.as_bytes());
|
||||
BASE64.encode(hasher.finalize())
|
||||
BASE64.encode_to_string(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn assert_s3_error<T, E>(result: Result<T, SdkError<E>>, status: u16, code: &str, message: &str, context: &str)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
{
|
||||
let error = result.expect_err(context);
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(status),
|
||||
"{context}: unexpected HTTP status: {error:?}"
|
||||
);
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.expect("request failure should retain an S3 service error");
|
||||
assert_eq!(service_error.code(), Some(code), "{context}: unexpected error code: {error:?}");
|
||||
assert_eq!(service_error.message(), Some(message), "{context}: unexpected error message: {error:?}");
|
||||
}
|
||||
|
||||
pub async fn kms_admin_request(
|
||||
@@ -189,34 +200,121 @@ pub async fn wait_for_kms_ready(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let total_deadline = Duration::from_secs(5);
|
||||
wait_for_kms_ready_with_timeout(base_url, access_key, secret_key, Duration::from_secs(5)).await
|
||||
}
|
||||
|
||||
async fn wait_for_kms_ready_with_timeout(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
total_deadline: Duration,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + total_deadline;
|
||||
let mut backoff = Duration::from_millis(200);
|
||||
let max_backoff = Duration::from_secs(1);
|
||||
let mut first_attempt = true;
|
||||
|
||||
loop {
|
||||
if !first_attempt {
|
||||
if start.elapsed() >= total_deadline {
|
||||
return Err("KMS failed to become ready within 5 seconds".into());
|
||||
}
|
||||
sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
first_attempt = false;
|
||||
|
||||
match get_kms_status(base_url, access_key, secret_key).await {
|
||||
Ok(status) => {
|
||||
info!("KMS is ready (status: {})", status);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
if start.elapsed() >= total_deadline {
|
||||
return Err(format!("KMS did not become ready within 5 s: last error: {e}").into());
|
||||
match tokio::time::timeout_at(deadline, get_kms_status(base_url, access_key, secret_key)).await {
|
||||
Ok(Ok(status)) => {
|
||||
let backend_status = serde_json::from_str::<serde_json::Value>(&status)
|
||||
.ok()
|
||||
.and_then(|value| value.get("backend_status")?.as_str().map(str::to_owned));
|
||||
if backend_status.as_deref() == Some("healthy") {
|
||||
info!("KMS is ready (status: {})", status);
|
||||
return Ok(());
|
||||
}
|
||||
warn!(error = %e, elapsed_ms = start.elapsed().as_millis() as u64, "KMS not ready yet, retrying…");
|
||||
warn!(
|
||||
backend_status = backend_status.as_deref().unwrap_or("missing"),
|
||||
elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
"KMS not ready yet, retrying…"
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
warn!(error = %e, elapsed_ms, "KMS not ready yet, retrying…");
|
||||
}
|
||||
Err(_) => return Err(format!("KMS failed to become ready within {} ms", total_deadline.as_millis()).into()),
|
||||
}
|
||||
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Err(format!("KMS failed to become ready within {} ms", total_deadline.as_millis()).into());
|
||||
}
|
||||
sleep((now + backoff).min(deadline) - now).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod readiness_tests {
|
||||
use super::{wait_for_kms_ready, wait_for_kms_ready_with_timeout};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn kms_readiness_retries_http_success_until_backend_is_healthy() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind readiness test server");
|
||||
let address = listener.local_addr().expect("read readiness test server address");
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let server_requests = Arc::clone(&requests);
|
||||
let server = tokio::spawn(async move {
|
||||
for backend_status in ["error", "healthy"] {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept readiness request");
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let read = socket.read(&mut chunk).await.expect("read readiness request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
server_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let body = format!(r#"{{"backend_status":"{backend_status}"}}"#);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.expect("write readiness response");
|
||||
}
|
||||
});
|
||||
|
||||
wait_for_kms_ready(&format!("http://{address}"), "access-key", "secret-key")
|
||||
.await
|
||||
.expect("KMS should become ready after the healthy response");
|
||||
|
||||
let observed_requests = requests.load(Ordering::SeqCst);
|
||||
server.abort();
|
||||
assert_eq!(observed_requests, 2, "an HTTP 200 unhealthy status must be retried");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kms_readiness_deadline_covers_a_stalled_status_request() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind readiness test server");
|
||||
let address = listener.local_addr().expect("read readiness test server address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept readiness request");
|
||||
let mut request = [0_u8; 1024];
|
||||
let _ = socket.read(&mut request).await.expect("read readiness request");
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
wait_for_kms_ready_with_timeout(&format!("http://{address}"), "access-key", "secret-key", Duration::from_millis(50)),
|
||||
)
|
||||
.await
|
||||
.expect("readiness helper must enforce its own deadline");
|
||||
|
||||
server.abort();
|
||||
assert!(result.is_err(), "a stalled status request must not outlive the readiness deadline");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
|
||||
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
|
||||
"rotated_at": serde_json::Value::Null,
|
||||
"created_by": "e2e-test",
|
||||
"encrypted_key_material": BASE64.encode(key_data),
|
||||
"encrypted_key_material": BASE64.encode_to_string(key_data),
|
||||
"nonce": Vec::<u8>::new()
|
||||
});
|
||||
|
||||
@@ -285,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
|
||||
info!("Testing SSE-C encryption");
|
||||
|
||||
let test_key = "01234567890123456789012345678901"; // 32-byte key
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let test_data = b"Hello, KMS SSE-C World!";
|
||||
let object_key = "test-sse-c-object";
|
||||
@@ -403,10 +501,6 @@ pub async fn test_kms_key_management(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if skip_if_kms_admin_tool_unavailable("test_kms_key_management") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Testing KMS key management APIs");
|
||||
|
||||
// Test CreateKey
|
||||
@@ -457,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
||||
// Test SSE-C with wrong key for download
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let wrong_key = "98765432109876543210987654321098";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||
let test_data = b"Test data for error scenarios";
|
||||
@@ -487,7 +581,13 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(wrong_key_result.is_err(), "Download with wrong SSE-C key should fail");
|
||||
assert_s3_error(
|
||||
wrong_key_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||
"download with a wrong SSE-C key must be rejected",
|
||||
);
|
||||
info!("✅ Correctly rejected download with wrong SSE-C key");
|
||||
|
||||
info!("Error scenario tests completed successfully");
|
||||
@@ -707,7 +807,7 @@ pub async fn test_multipart_upload_with_config(
|
||||
// Prepare encryption parameters
|
||||
let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type {
|
||||
EncryptionType::SSEC { key, key_md5 } => {
|
||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||
(Some(key_b64), Some(key_md5.clone()))
|
||||
}
|
||||
_ => (None, None),
|
||||
|
||||
@@ -432,7 +432,6 @@ async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a Vault binary"]
|
||||
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = VaultTestEnvironment::new().await?;
|
||||
env.start_vault().await?;
|
||||
|
||||
@@ -61,7 +61,7 @@ async fn test_metadata_replace_self_copy_of_sse_object_stays_decryptable() {
|
||||
)
|
||||
.await
|
||||
.expect("failed to start RustFS with local KMS");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
let client = kms_env.base_env.create_s3_client();
|
||||
// Deliberately an UNVERSIONED bucket: that is the branch where the store layer can service
|
||||
@@ -160,7 +160,7 @@ async fn test_metadata_replace_self_copy_dropping_sse_rewrites_plaintext() {
|
||||
)
|
||||
.await
|
||||
.expect("failed to start RustFS with local KMS");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
let client = kms_env.base_env.create_s3_client();
|
||||
// Unversioned, and deliberately WITHOUT a bucket default-encryption rule, so the copy below
|
||||
@@ -256,7 +256,7 @@ async fn test_metadata_replace_self_copy_under_bucket_default_sse_stays_decrypta
|
||||
)
|
||||
.await
|
||||
.expect("failed to start RustFS with local KMS");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
let client = kms_env.base_env.create_s3_client();
|
||||
let bucket = "copy-object-self-copy-bucket-default-sse-test";
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn test_self_copy_of_historical_sse_s3_version_is_readable() {
|
||||
)
|
||||
.await
|
||||
.expect("failed to start RustFS with local KMS");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
let client = kms_env.base_env.create_s3_client();
|
||||
let bucket = "copy-object-version-restore-sse-test";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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.
|
||||
|
||||
//! Ranged GETs over encrypted single-part objects.
|
||||
//!
|
||||
//! Byte-exactness must hold on every frame layout the server can write:
|
||||
//! legacy v1 (variable frames, conservative full read) and, when
|
||||
//! `RUSTFS_ENCRYPTION_FRAME_V2=true` reaches the server under test, the
|
||||
//! fixed-frame v2 layout whose marker enables the closed-form frame seek.
|
||||
//! The matrix crosses frame boundaries, starts mid-frame, and ends inside
|
||||
//! the final short frame, so a mispositioned seek cannot pass.
|
||||
|
||||
use super::common::LocalKMSTestEnvironment;
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use tracing::info;
|
||||
|
||||
const FRAME_PLAINTEXT: usize = 8 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_s3_single_part_ranged_gets_are_byte_exact() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Testing ranged GETs over an SSE-S3 single-part object");
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
let test_key = "encrypted-range-get";
|
||||
let body: Vec<u8> = (0..3 * FRAME_PLAINTEXT + 500).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
let put = s3_client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
put.server_side_encryption(),
|
||||
Some(&ServerSideEncryption::Aes256),
|
||||
"the object under test must actually be encrypted"
|
||||
);
|
||||
|
||||
let cases: &[(usize, usize)] = &[
|
||||
// Head range inside frame 0.
|
||||
(0, 99),
|
||||
// Crossing the first frame boundary.
|
||||
(FRAME_PLAINTEXT - 1, FRAME_PLAINTEXT),
|
||||
// Starting exactly on a frame boundary.
|
||||
(FRAME_PLAINTEXT, FRAME_PLAINTEXT + 9),
|
||||
// Mid-object, mid-frame on both ends.
|
||||
(2 * FRAME_PLAINTEXT + 5, 3 * FRAME_PLAINTEXT + 100),
|
||||
// Tail range ending inside the final short frame.
|
||||
(3 * FRAME_PLAINTEXT + 100, 3 * FRAME_PLAINTEXT + 499),
|
||||
];
|
||||
|
||||
for &(start, end) in cases {
|
||||
let response = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.range(format!("bytes={start}-{end}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response.content_length(),
|
||||
Some((end - start + 1) as i64),
|
||||
"range {start}-{end} content length"
|
||||
);
|
||||
let data = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(data.as_ref(), &body[start..=end], "range {start}-{end} must be byte-exact");
|
||||
}
|
||||
|
||||
// A suffix range exercises the offset resolution path as well.
|
||||
let response = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.range("bytes=-123")
|
||||
.send()
|
||||
.await?;
|
||||
let data = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(data.as_ref(), &body[body.len() - 123..], "suffix range must be byte-exact");
|
||||
|
||||
// The unranged body still round-trips.
|
||||
let response = s3_client.get_object().bucket(TEST_BUCKET).key(test_key).send().await?;
|
||||
let data = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(data.as_ref(), body.as_slice(), "full body must round-trip");
|
||||
|
||||
// A block-aligned object ends in an empty authenticated final frame under
|
||||
// the v2 layout; tail ranges touching the last plaintext byte must not be
|
||||
// misread as truncation.
|
||||
let aligned_key = "encrypted-range-get-aligned";
|
||||
let aligned_body: Vec<u8> = (0..3 * FRAME_PLAINTEXT).map(|i| ((i + 3) % 251) as u8).collect();
|
||||
s3_client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(aligned_key)
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.body(ByteStream::from(aligned_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
for (start, end) in [
|
||||
(2 * FRAME_PLAINTEXT + 10, 3 * FRAME_PLAINTEXT - 1),
|
||||
(3 * FRAME_PLAINTEXT - 1, 3 * FRAME_PLAINTEXT - 1),
|
||||
] {
|
||||
let response = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(aligned_key)
|
||||
.range(format!("bytes={start}-{end}"))
|
||||
.send()
|
||||
.await?;
|
||||
let data = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(
|
||||
data.as_ref(),
|
||||
&aligned_body[start..=end],
|
||||
"aligned range {start}-{end} must be byte-exact"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn s
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -147,7 +147,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -250,7 +250,7 @@ async fn test_multipart_upload_writes_encrypted_data() -> Result<(), Box<dyn std
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
@@ -19,12 +19,11 @@
|
||||
//! complex workflows.
|
||||
|
||||
use super::common::{
|
||||
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, sse_customer_key_md5_base64,
|
||||
test_all_multipart_encryption_types, test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption,
|
||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
||||
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error,
|
||||
create_sse_c_config, sse_customer_key_md5_base64, test_all_multipart_encryption_types, test_kms_key_management,
|
||||
test_multipart_upload_with_config, test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||
};
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::info;
|
||||
|
||||
/// Comprehensive test: Full KMS workflow with all encryption types
|
||||
@@ -35,7 +34,7 @@ async fn test_comprehensive_kms_full_workflow() -> Result<(), Box<dyn std::error
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -103,7 +102,7 @@ async fn test_comprehensive_stress_test() -> Result<(), Box<dyn std::error::Erro
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -137,7 +136,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -178,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
||||
// Verify that files cannot be read with wrong keys
|
||||
info!("🔒 Verify key isolation");
|
||||
let wrong_key = "11111111111111111111111111111111";
|
||||
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
|
||||
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||
|
||||
// Try to read file encrypted with key1 using wrong key
|
||||
@@ -192,7 +191,13 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(wrong_read_result.is_err(), "The encrypted file should not be readable with the wrong key");
|
||||
assert_s3_error(
|
||||
wrong_read_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||
"multipart SSE-C object GET with a wrong key must be rejected",
|
||||
);
|
||||
info!("✅ Confirm that key isolation is working correctly");
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -208,7 +213,7 @@ async fn test_comprehensive_concurrent_operations() -> Result<(), Box<dyn std::e
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -253,7 +258,7 @@ async fn test_comprehensive_performance_benchmark() -> Result<(), Box<dyn std::e
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
@@ -21,21 +21,13 @@
|
||||
//! - Concurrent encryption operations
|
||||
//! - Security validation tests
|
||||
|
||||
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
|
||||
use super::common::{LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, sse_customer_key_md5_base64};
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::Engine;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Test encryption of zero-byte files (empty files)
|
||||
#[tokio::test]
|
||||
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -44,7 +36,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -75,7 +67,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
|
||||
// Test SSE-C with zero-byte file
|
||||
info!("📤 Testing SSE-C with zero-byte file");
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let object_key_c = "zero-byte-sse-c";
|
||||
|
||||
@@ -117,7 +109,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -168,7 +160,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
|
||||
// Test SSE-C with single byte
|
||||
info!("📤 Testing SSE-C with single-byte file");
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let object_key_c = "single-byte-sse-c";
|
||||
|
||||
@@ -209,7 +201,7 @@ async fn test_kms_multipart_boundary_conditions() -> Result<(), Box<dyn std::err
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -284,7 +276,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -294,8 +286,8 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
// Test 1: Invalid key length for SSE-C
|
||||
info!("🔍 Testing invalid SSE-C key length");
|
||||
let invalid_short_key = "short"; // Too short
|
||||
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
|
||||
let invalid_key_md5 = md5_hex(invalid_short_key);
|
||||
let invalid_key_b64 = base64_simd::STANDARD.encode_to_string(invalid_short_key);
|
||||
let invalid_key_md5 = sse_customer_key_md5_base64(invalid_short_key);
|
||||
|
||||
let invalid_key_result = s3_client
|
||||
.put_object()
|
||||
@@ -308,14 +300,32 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(invalid_key_result.is_err(), "Should reject invalid key length");
|
||||
assert_s3_error(
|
||||
invalid_key_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
"SSE-C key must be 32 bytes (256 bits), got 5 bytes.",
|
||||
"invalid SSE-C key length must be rejected",
|
||||
);
|
||||
assert_s3_error(
|
||||
s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key("test-invalid-key-length")
|
||||
.send()
|
||||
.await,
|
||||
404,
|
||||
"NoSuchKey",
|
||||
"The specified key does not exist.",
|
||||
"rejected invalid-key PUT must not create an object",
|
||||
);
|
||||
info!("✅ Correctly rejected invalid key length");
|
||||
|
||||
// Test 2: Mismatched MD5 for SSE-C
|
||||
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
||||
let valid_key = "01234567890123456789012345678901";
|
||||
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
|
||||
let wrong_md5 = "wrongmd5hash12345678901234567890"; // Wrong MD5
|
||||
let valid_key_b64 = base64_simd::STANDARD.encode_to_string(valid_key);
|
||||
let wrong_md5 = sse_customer_key_md5_base64("98765432109876543210987654321098");
|
||||
|
||||
let wrong_md5_result = s3_client
|
||||
.put_object()
|
||||
@@ -324,11 +334,24 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(test_data.to_vec()))
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&valid_key_b64)
|
||||
.sse_customer_key_md5(wrong_md5)
|
||||
.sse_customer_key_md5(&wrong_md5)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(wrong_md5_result.is_err(), "Should reject mismatched MD5");
|
||||
assert_s3_error(
|
||||
wrong_md5_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
"The calculated MD5 hash of the key did not match the hash that was provided.",
|
||||
"mismatched SSE-C key MD5 must be rejected",
|
||||
);
|
||||
assert_s3_error(
|
||||
s3_client.get_object().bucket(TEST_BUCKET).key("test-wrong-md5").send().await,
|
||||
404,
|
||||
"NoSuchKey",
|
||||
"The specified key does not exist.",
|
||||
"rejected mismatched-MD5 PUT must not create an object",
|
||||
);
|
||||
info!("✅ Correctly rejected mismatched MD5");
|
||||
|
||||
// Test 3: Try to access SSE-C object without providing key
|
||||
@@ -355,7 +378,28 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(no_key_result.is_err(), "Should require SSE-C key for access");
|
||||
assert_s3_error(
|
||||
no_key_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
"The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.",
|
||||
"SSE-C object GET without a customer key must be rejected",
|
||||
);
|
||||
|
||||
let recovered = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key("test-sse-c-no-key-access")
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&valid_key_b64)
|
||||
.sse_customer_key_md5(&valid_key_md5)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(recovered.as_ref(), test_data, "failed GET must not corrupt the SSE-C object");
|
||||
info!("✅ Correctly required SSE-C key for access");
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -371,7 +415,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = Arc::new(kms_env.base_env.create_s3_client());
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -420,7 +464,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
|
||||
2 => {
|
||||
// SSE-C
|
||||
let key = format!("testkey{i:026}"); // 32-byte key
|
||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(&key);
|
||||
let key_md5 = sse_customer_key_md5_base64(&key);
|
||||
|
||||
client
|
||||
@@ -478,7 +522,7 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -490,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
||||
let key1 = "key1key1key1key1key1key1key1key1"; // 32 bytes
|
||||
let key2 = "key2key2key2key2key2key2key2key2"; // 32 bytes
|
||||
|
||||
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
|
||||
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
@@ -563,7 +607,13 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt with wrong key");
|
||||
assert_s3_error(
|
||||
wrong_key_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||
"SSE-C object GET with the wrong customer key must be rejected",
|
||||
);
|
||||
info!("✅ Key isolation verified - wrong key cannot decrypt data");
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
use super::common::LocalKMSTestEnvironment;
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
@@ -37,7 +38,7 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -77,12 +78,25 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// This should fail, but the server should still be responsive
|
||||
if put_result2.is_err() {
|
||||
info!("✅ Upload correctly failed when key directory unavailable");
|
||||
} else {
|
||||
warn!("⚠️ Upload succeeded despite unavailable key directory (may be using cached keys)");
|
||||
}
|
||||
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||
assert_eq!(
|
||||
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InternalError")
|
||||
);
|
||||
let unavailable_absence = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(object_key2)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a write rejected by unavailable KMS must not publish an object");
|
||||
assert_eq!(unavailable_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(
|
||||
unavailable_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchKey")
|
||||
);
|
||||
info!("✅ Upload correctly failed when key directory unavailable");
|
||||
|
||||
// Restore the key directory
|
||||
info!("🔧 Restoring key directory");
|
||||
@@ -107,6 +121,11 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
||||
|
||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
|
||||
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||
|
||||
// Verify we can still access the original file
|
||||
info!("📥 Verifying access to original encrypted file");
|
||||
let get_response = s3_client.get_object().bucket(TEST_BUCKET).key(object_key).send().await?;
|
||||
@@ -127,7 +146,7 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -174,12 +193,22 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// This might succeed if KMS uses cached keys, but should eventually fail
|
||||
if put_result2.is_err() {
|
||||
info!("✅ Upload correctly failed with corrupted key");
|
||||
} else {
|
||||
warn!("⚠️ Upload succeeded despite corrupted key (likely using cached key)");
|
||||
}
|
||||
let corrupt_error = put_result2.expect_err("corrupt Local KMS key material must reject encrypted writes");
|
||||
assert_eq!(corrupt_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||
assert_eq!(
|
||||
corrupt_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InternalError")
|
||||
);
|
||||
let corrupt_absence = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(object_key2)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a write rejected by corrupt KMS material must not publish an object");
|
||||
assert_eq!(corrupt_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(corrupt_absence.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
info!("✅ Upload correctly failed with corrupted key");
|
||||
|
||||
// Restore the original key file
|
||||
info!("🔧 Restoring original key file");
|
||||
@@ -205,6 +234,11 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
||||
|
||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
|
||||
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
info!("✅ Corrupted key files test completed successfully");
|
||||
Ok(())
|
||||
@@ -218,7 +252,7 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -280,18 +314,14 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
||||
info!("🔧 Simulating upload interruption");
|
||||
|
||||
// Abort the multipart upload
|
||||
let abort_result = s3_client
|
||||
s3_client
|
||||
.abort_multipart_upload()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match abort_result {
|
||||
Ok(_) => info!("✅ Multipart upload aborted successfully"),
|
||||
Err(e) => warn!("⚠️ Failed to abort multipart upload: {}", e),
|
||||
}
|
||||
.await?;
|
||||
info!("✅ Multipart upload aborted successfully");
|
||||
|
||||
// Try to complete the aborted upload - this should fail
|
||||
info!("🔍 Attempting to complete aborted upload");
|
||||
@@ -310,18 +340,38 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
||||
.set_parts(Some(completed_parts))
|
||||
.build();
|
||||
|
||||
let complete_result = s3_client
|
||||
let complete_error = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(complete_result.is_err(), "Should not be able to complete aborted upload");
|
||||
.await
|
||||
.expect_err("an aborted multipart upload must not be completable");
|
||||
assert_eq!(complete_error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(
|
||||
complete_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchUpload")
|
||||
);
|
||||
assert_eq!(
|
||||
complete_error.as_service_error().and_then(ProvideErrorMetadata::message),
|
||||
Some(
|
||||
"The specified multipart upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
|
||||
)
|
||||
);
|
||||
info!("✅ Correctly failed to complete aborted upload");
|
||||
|
||||
let missing_object = s3_client
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborting a multipart upload must not publish an object");
|
||||
assert_eq!(missing_object.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(missing_object.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
|
||||
// Start a new multipart upload and complete it successfully
|
||||
info!("📤 Starting new multipart upload");
|
||||
let create_multipart_output2 = s3_client
|
||||
@@ -393,15 +443,14 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test KMS resilience to temporary resource constraints
|
||||
/// Test concurrent KMS encryption requests
|
||||
#[tokio::test]
|
||||
async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn test_kms_concurrent_encryption_requests() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("🧪 Testing KMS behavior under resource constraints");
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -431,29 +480,27 @@ async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error
|
||||
}
|
||||
|
||||
// Wait for all uploads to complete
|
||||
let mut successful_uploads = 0;
|
||||
let mut failed_uploads = 0;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for task in upload_tasks {
|
||||
let (object_key, result) = task.await.unwrap();
|
||||
let (object_key, result) = task.await?;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
successful_uploads += 1;
|
||||
info!("✅ Rapid upload {} succeeded", object_key);
|
||||
}
|
||||
Err(e) => {
|
||||
failed_uploads += 1;
|
||||
warn!("❌ Rapid upload {} failed: {}", object_key, e);
|
||||
failures.push(format!("{object_key}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("📊 Rapid upload results: {} succeeded, {} failed", successful_uploads, failed_uploads);
|
||||
|
||||
// We expect most uploads to succeed even under load
|
||||
assert!(successful_uploads >= 7, "Expected at least 7/10 rapid uploads to succeed");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"all 10 concurrent KMS uploads must succeed; failures: {}",
|
||||
failures.join("; ")
|
||||
);
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
info!("✅ Resource constraints test completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! - Complete encryption/decryption lifecycle
|
||||
|
||||
use super::common::{
|
||||
LocalKMSTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
|
||||
LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, get_kms_status, sse_customer_key_md5_base64,
|
||||
test_kms_key_management, test_sse_c_encryption,
|
||||
};
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
@@ -29,9 +29,6 @@ use tracing::{error, info};
|
||||
#[tokio::test]
|
||||
async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_local_kms_end_to_end") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Local KMS End-to-End Test");
|
||||
|
||||
// Create LocalKMS test environment
|
||||
@@ -46,7 +43,7 @@ async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
|
||||
.expect("Failed to start RustFS with Local KMS");
|
||||
|
||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||
|
||||
@@ -127,7 +124,7 @@ async fn test_local_kms_key_isolation() {
|
||||
.expect("Failed to start RustFS with Local KMS");
|
||||
|
||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||
|
||||
@@ -141,8 +138,8 @@ async fn test_local_kms_key_isolation() {
|
||||
// Test that different SSE-C keys create isolated encrypted objects
|
||||
let key1 = "01234567890123456789012345678901";
|
||||
let key2 = "98765432109876543210987654321098";
|
||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
@@ -200,7 +197,13 @@ async fn test_local_kms_key_isolation() {
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt object1 with key2");
|
||||
assert_s3_error(
|
||||
wrong_key_result,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||
"local SSE-C object GET with a wrong key must be rejected",
|
||||
);
|
||||
|
||||
kms_env
|
||||
.base_env
|
||||
@@ -227,7 +230,7 @@ async fn test_local_kms_large_file() {
|
||||
.expect("Failed to start RustFS with Local KMS");
|
||||
|
||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||
|
||||
@@ -309,7 +312,7 @@ async fn test_local_kms_multipart_upload() {
|
||||
.expect("Failed to start RustFS with Local KMS");
|
||||
|
||||
// Wait for KMS initialization
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
||||
|
||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||
|
||||
@@ -562,7 +565,7 @@ async fn test_multipart_upload_with_sse_c(
|
||||
|
||||
// SSE-C encryption key
|
||||
let encryption_key = "01234567890123456789012345678901";
|
||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(encryption_key);
|
||||
let key_md5 = sse_customer_key_md5_base64(encryption_key);
|
||||
|
||||
// Generate test data
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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.
|
||||
|
||||
//! Bulk DEK rekey sweep over stored objects.
|
||||
//!
|
||||
//! The full loop — rotate the master key, sweep, prove convergence — runs
|
||||
//! against Vault Transit, whose context-bound envelopes exercise the
|
||||
//! decrypt + re-encrypt rewrap route end to end. The capability refusal runs
|
||||
//! against the Local backend, which supports no rewrap at all.
|
||||
|
||||
use super::common::{
|
||||
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, kms_admin_request, start_kms, wait_for_kms_ready,
|
||||
};
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
async fn rekey_status(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let body = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::GET,
|
||||
"/rustfs/admin/v3/kms/keys/rekey/status",
|
||||
None,
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Start a sweep and poll it to a terminal state.
|
||||
async fn run_rekey_to_completion(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
request_body: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys/rekey",
|
||||
Some(request_body),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for _ in 0..120 {
|
||||
let status = rekey_status(base_url, access_key, secret_key).await?;
|
||||
if status["state"] != "running" {
|
||||
return Ok(status);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err("rekey sweep did not reach a terminal state in time".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kms_rekey_sweep_rewraps_rotated_envelopes_and_converges() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Testing the bulk rekey sweep against Vault Transit");
|
||||
|
||||
let mut env = VaultTestEnvironment::new().await?;
|
||||
env.start_vault().await?;
|
||||
env.setup_vault_transit().await?;
|
||||
env.start_rustfs_for_vault().await?;
|
||||
env.configure_vault_transit_kms().await?;
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
wait_for_kms_ready(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
|
||||
let base_url = env.base_env.url.clone();
|
||||
let access_key = env.base_env.access_key.clone();
|
||||
let secret_key = env.base_env.secret_key.clone();
|
||||
|
||||
let s3_client = env.base_env.create_s3_client();
|
||||
env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
|
||||
// Three encrypted objects the sweep must rewrap, one plaintext object it
|
||||
// must leave alone.
|
||||
let encrypted_keys = ["rekey/alpha", "rekey/beta", "rekey/gamma"];
|
||||
let mut bodies = Vec::new();
|
||||
for (index, key) in encrypted_keys.iter().enumerate() {
|
||||
let body: Vec<u8> = (0..2048).map(|i| ((i + index * 7) % 251) as u8).collect();
|
||||
s3_client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(*key)
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id(VAULT_KEY_NAME)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
bodies.push(body);
|
||||
}
|
||||
s3_client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key("rekey/plaintext")
|
||||
.body(ByteStream::from(b"unencrypted".to_vec()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Rotate the master key so the stored envelopes fall behind Vault's
|
||||
// latest version.
|
||||
kms_admin_request(
|
||||
&base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys/rotate",
|
||||
Some(&format!(r#"{{"key_id":"{VAULT_KEY_NAME}"}}"#)),
|
||||
&access_key,
|
||||
&secret_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status =
|
||||
run_rekey_to_completion(&base_url, &access_key, &secret_key, &format!(r#"{{"buckets":["{TEST_BUCKET}"]}}"#)).await?;
|
||||
assert_eq!(status["state"], "completed", "first sweep must complete: {status}");
|
||||
assert_eq!(status["failed"], 0, "no object may fail: {status}");
|
||||
assert_eq!(
|
||||
status["rewrapped"],
|
||||
encrypted_keys.len(),
|
||||
"every rotated envelope must be rewrapped: {status}"
|
||||
);
|
||||
assert!(
|
||||
status["not_applicable"].as_u64().unwrap_or(0) >= 1,
|
||||
"the plaintext object must be reported not applicable: {status}"
|
||||
);
|
||||
|
||||
// The rewrapped objects still serve their exact bytes.
|
||||
for (key, expected) in encrypted_keys.iter().zip(&bodies) {
|
||||
let response = s3_client.get_object().bucket(TEST_BUCKET).key(*key).send().await?;
|
||||
let data = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(data.as_ref(), expected.as_slice(), "object {key} must be byte-exact after the rewrap");
|
||||
}
|
||||
|
||||
// Convergence: a second sweep finds everything current and writes nothing.
|
||||
let status =
|
||||
run_rekey_to_completion(&base_url, &access_key, &secret_key, &format!(r#"{{"buckets":["{TEST_BUCKET}"]}}"#)).await?;
|
||||
assert_eq!(status["state"], "completed", "second sweep must complete: {status}");
|
||||
assert_eq!(status["rewrapped"], 0, "a converged sweep must write nothing: {status}");
|
||||
assert_eq!(status["failed"], 0, "{status}");
|
||||
assert_eq!(
|
||||
status["already_current"],
|
||||
encrypted_keys.len(),
|
||||
"every envelope must now be current: {status}"
|
||||
);
|
||||
|
||||
env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kms_rekey_refuses_a_backend_without_rewrap_support() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Testing that the rekey sweep refuses the Local backend up front");
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let error = kms_admin_request(
|
||||
&kms_env.base_env.url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys/rekey",
|
||||
Some("{}"),
|
||||
&kms_env.base_env.access_key,
|
||||
&kms_env.base_env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect_err("a backend without rewrap support must be refused up front");
|
||||
assert!(error.to_string().contains("501"), "the refusal must be 501 Not Implemented, got: {error}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,13 +19,12 @@
|
||||
//! multipart upload behaviour.
|
||||
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::common::{
|
||||
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
|
||||
start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management, test_sse_c_encryption,
|
||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
||||
SSE_C_KEY_MISMATCH_MESSAGE, VAULT_KEY_NAME, VaultTestEnvironment, assert_s3_error, get_kms_status,
|
||||
sse_customer_key_md5_base64, start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management,
|
||||
test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||
};
|
||||
|
||||
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
|
||||
@@ -45,8 +44,8 @@ impl VaultKmsTestContext {
|
||||
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
|
||||
// Allow Vault to finish initialising token auth and transit engine.
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
// Wait for KMS to finish initialising.
|
||||
super::common::wait_for_kms_ready(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
|
||||
Ok(Self { env })
|
||||
}
|
||||
@@ -63,9 +62,6 @@ impl VaultKmsTestContext {
|
||||
#[tokio::test]
|
||||
async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_vault_kms_end_to_end") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Vault KMS End-to-End Test with default key {}", VAULT_KEY_NAME);
|
||||
|
||||
let context = VaultKmsTestContext::new().await?;
|
||||
@@ -118,9 +114,6 @@ async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
|
||||
#[tokio::test]
|
||||
async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_isolation") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Vault KMS SSE-C key isolation test");
|
||||
|
||||
let context = VaultKmsTestContext::new().await?;
|
||||
@@ -134,8 +127,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
||||
|
||||
let key1 = "01234567890123456789012345678901";
|
||||
let key2 = "98765432109876543210987654321098";
|
||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
@@ -189,7 +182,13 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
||||
.sse_customer_key_md5(&key2_md5)
|
||||
.send()
|
||||
.await;
|
||||
assert!(wrong_key.is_err(), "Object1 should not decrypt with key2");
|
||||
assert_s3_error(
|
||||
wrong_key,
|
||||
400,
|
||||
"InvalidRequest",
|
||||
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||
"Vault-backed SSE-C object GET with a wrong key must be rejected",
|
||||
);
|
||||
|
||||
context
|
||||
.base_env()
|
||||
@@ -204,9 +203,6 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
||||
#[tokio::test]
|
||||
async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_vault_kms_large_file") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Vault KMS large file SSE-S3 test");
|
||||
|
||||
let context = VaultKmsTestContext::new().await?;
|
||||
@@ -268,9 +264,6 @@ async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + S
|
||||
#[tokio::test]
|
||||
async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_vault_kms_multipart_upload") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Vault KMS multipart upload encryption suite");
|
||||
|
||||
let context = VaultKmsTestContext::new().await?;
|
||||
@@ -298,9 +291,6 @@ async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Err
|
||||
#[tokio::test]
|
||||
async fn test_vault_kms_key_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_operations") {
|
||||
return Ok(());
|
||||
}
|
||||
info!("Starting Vault KMS key operations test (CRUD)");
|
||||
|
||||
let context = VaultKmsTestContext::new().await?;
|
||||
|
||||
@@ -39,9 +39,6 @@ mod kms_edge_cases_test;
|
||||
#[cfg(test)]
|
||||
mod kms_fault_recovery_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_runner;
|
||||
|
||||
#[cfg(test)]
|
||||
mod bucket_default_encryption_test;
|
||||
|
||||
@@ -51,6 +48,9 @@ mod encryption_metadata_test;
|
||||
#[cfg(test)]
|
||||
mod copy_object_self_copy_sse_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod encrypted_range_get_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_version_restore_sse_test;
|
||||
|
||||
@@ -62,3 +62,6 @@ mod kms_authorization_negative_matrix_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_ilm_sse_kms_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_rekey_sweep_test;
|
||||
|
||||
@@ -33,7 +33,7 @@ async fn test_step1_basic_single_file_encryption() -> Result<(), Box<dyn std::er
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -89,7 +89,7 @@ async fn test_step2_basic_multipart_upload_without_encryption() -> Result<(), Bo
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -187,7 +187,7 @@ async fn test_step3_multipart_upload_with_sse_s3() -> Result<(), Box<dyn std::er
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -310,7 +310,7 @@ async fn test_step4_large_multipart_upload_with_encryption() -> Result<(), Box<d
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -435,7 +435,7 @@ async fn test_step5_all_encryption_types_multipart() -> Result<(), Box<dyn std::
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
|
||||
let s3_client = kms_env.base_env.create_s3_client();
|
||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||
@@ -497,7 +497,7 @@ async fn test_multipart_encryption_type(
|
||||
// Prepare SSE-C keys when required
|
||||
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
|
||||
let key = "01234567890123456789012345678901";
|
||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||
let key_md5 = sse_customer_key_md5_base64(key);
|
||||
(Some(key_b64), Some(key_md5))
|
||||
} else {
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
#![allow(dead_code)]
|
||||
// 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.
|
||||
|
||||
//! Unified KMS test suite runner
|
||||
//!
|
||||
//! This module provides a unified interface for running KMS tests with categorization,
|
||||
//! filtering, and comprehensive reporting capabilities.
|
||||
|
||||
use crate::common::init_logging;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Test category for organization and filtering
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum TestCategory {
|
||||
CoreFunctionality,
|
||||
MultipartEncryption,
|
||||
EdgeCases,
|
||||
FaultRecovery,
|
||||
Comprehensive,
|
||||
Performance,
|
||||
}
|
||||
|
||||
impl TestCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TestCategory::CoreFunctionality => "core-functionality",
|
||||
TestCategory::MultipartEncryption => "multipart-encryption",
|
||||
TestCategory::EdgeCases => "edge-cases",
|
||||
TestCategory::FaultRecovery => "fault-recovery",
|
||||
TestCategory::Comprehensive => "comprehensive",
|
||||
TestCategory::Performance => "performance",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test definition with metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: TestCategory,
|
||||
pub estimated_duration: Duration,
|
||||
pub is_critical: bool,
|
||||
}
|
||||
|
||||
impl TestDefinition {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
category: TestCategory,
|
||||
estimated_duration: Duration,
|
||||
is_critical: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
category,
|
||||
estimated_duration,
|
||||
is_critical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test execution result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestResult {
|
||||
pub test_name: String,
|
||||
pub category: TestCategory,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl TestResult {
|
||||
pub fn success(test_name: String, category: TestCategory, duration: Duration) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
category,
|
||||
success: true,
|
||||
duration,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure(test_name: String, category: TestCategory, duration: Duration, error: String) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
category,
|
||||
success: false,
|
||||
duration,
|
||||
error_message: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive test suite configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestSuiteConfig {
|
||||
pub categories: Vec<TestCategory>,
|
||||
pub include_critical_only: bool,
|
||||
pub max_duration: Option<Duration>,
|
||||
pub parallel_execution: bool,
|
||||
}
|
||||
|
||||
impl Default for TestSuiteConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
categories: vec![
|
||||
TestCategory::CoreFunctionality,
|
||||
TestCategory::MultipartEncryption,
|
||||
TestCategory::EdgeCases,
|
||||
TestCategory::FaultRecovery,
|
||||
TestCategory::Comprehensive,
|
||||
],
|
||||
include_critical_only: false,
|
||||
max_duration: None,
|
||||
parallel_execution: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified KMS test suite runner
|
||||
pub struct KMSTestSuite {
|
||||
tests: Vec<TestDefinition>,
|
||||
config: TestSuiteConfig,
|
||||
}
|
||||
|
||||
impl KMSTestSuite {
|
||||
/// Create a new test suite with default configuration
|
||||
pub fn new() -> Self {
|
||||
let tests = vec![
|
||||
// Core Functionality Tests
|
||||
TestDefinition::new(
|
||||
"test_local_kms_end_to_end",
|
||||
"End-to-end KMS test with all encryption types",
|
||||
TestCategory::CoreFunctionality,
|
||||
Duration::from_secs(60),
|
||||
true,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_local_kms_key_isolation",
|
||||
"Test KMS key isolation and security",
|
||||
TestCategory::CoreFunctionality,
|
||||
Duration::from_secs(45),
|
||||
true,
|
||||
),
|
||||
// Multipart Encryption Tests
|
||||
TestDefinition::new(
|
||||
"test_local_kms_multipart_upload",
|
||||
"Test large file multipart upload with encryption",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(120),
|
||||
true,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_step1_basic_single_file_encryption",
|
||||
"Basic single file encryption test",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(30),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_step2_basic_multipart_upload_without_encryption",
|
||||
"Basic multipart upload without encryption",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(45),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_step3_multipart_upload_with_sse_s3",
|
||||
"Multipart upload with SSE-S3 encryption",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(60),
|
||||
true,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_step4_large_multipart_upload_with_encryption",
|
||||
"Large file multipart upload with encryption",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(90),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_step5_all_encryption_types_multipart",
|
||||
"All encryption types multipart test",
|
||||
TestCategory::MultipartEncryption,
|
||||
Duration::from_secs(120),
|
||||
true,
|
||||
),
|
||||
// Edge Cases Tests
|
||||
TestDefinition::new(
|
||||
"test_kms_zero_byte_file_encryption",
|
||||
"Test encryption of zero-byte files",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(20),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_single_byte_file_encryption",
|
||||
"Test encryption of single-byte files",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(20),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_multipart_boundary_conditions",
|
||||
"Test multipart upload boundary conditions",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(45),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_invalid_key_scenarios",
|
||||
"Test invalid key scenarios",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(30),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_concurrent_encryption",
|
||||
"Test concurrent encryption operations",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(60),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_key_validation_security",
|
||||
"Test key validation security",
|
||||
TestCategory::EdgeCases,
|
||||
Duration::from_secs(30),
|
||||
false,
|
||||
),
|
||||
// Fault Recovery Tests
|
||||
TestDefinition::new(
|
||||
"test_kms_key_directory_unavailable",
|
||||
"Test KMS when key directory is unavailable",
|
||||
TestCategory::FaultRecovery,
|
||||
Duration::from_secs(45),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_corrupted_key_files",
|
||||
"Test KMS with corrupted key files",
|
||||
TestCategory::FaultRecovery,
|
||||
Duration::from_secs(30),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_multipart_upload_interruption",
|
||||
"Test multipart upload interruption recovery",
|
||||
TestCategory::FaultRecovery,
|
||||
Duration::from_secs(60),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_kms_resource_constraints",
|
||||
"Test KMS under resource constraints",
|
||||
TestCategory::FaultRecovery,
|
||||
Duration::from_secs(90),
|
||||
false,
|
||||
),
|
||||
// Comprehensive Tests
|
||||
TestDefinition::new(
|
||||
"test_comprehensive_kms_full_workflow",
|
||||
"Full KMS workflow comprehensive test",
|
||||
TestCategory::Comprehensive,
|
||||
Duration::from_secs(300),
|
||||
true,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_comprehensive_stress_test",
|
||||
"KMS stress test with large datasets",
|
||||
TestCategory::Comprehensive,
|
||||
Duration::from_secs(400),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_comprehensive_key_isolation",
|
||||
"Comprehensive key isolation test",
|
||||
TestCategory::Comprehensive,
|
||||
Duration::from_secs(180),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_comprehensive_concurrent_operations",
|
||||
"Comprehensive concurrent operations test",
|
||||
TestCategory::Comprehensive,
|
||||
Duration::from_secs(240),
|
||||
false,
|
||||
),
|
||||
TestDefinition::new(
|
||||
"test_comprehensive_performance_benchmark",
|
||||
"KMS performance benchmark test",
|
||||
TestCategory::Comprehensive,
|
||||
Duration::from_secs(360),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
Self {
|
||||
tests,
|
||||
config: TestSuiteConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the test suite
|
||||
pub fn with_config(mut self, config: TestSuiteConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Filter tests based on category
|
||||
pub fn filter_by_category(&self, category: &TestCategory) -> Vec<&TestDefinition> {
|
||||
self.tests.iter().filter(|test| &test.category == category).collect()
|
||||
}
|
||||
|
||||
/// Filter tests based on criticality
|
||||
pub fn filter_critical_tests(&self) -> Vec<&TestDefinition> {
|
||||
self.tests.iter().filter(|test| test.is_critical).collect()
|
||||
}
|
||||
|
||||
/// Get test summary by category
|
||||
pub fn get_category_summary(&self) -> std::collections::HashMap<TestCategory, Vec<&TestDefinition>> {
|
||||
let mut summary = std::collections::HashMap::new();
|
||||
for test in &self.tests {
|
||||
summary.entry(test.category.clone()).or_insert_with(Vec::new).push(test);
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Run the complete test suite
|
||||
pub async fn run_test_suite(&self) -> Vec<TestResult> {
|
||||
init_logging();
|
||||
info!("🚀 Starting unified KMS test suite");
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Filter tests based on configuration
|
||||
let tests_to_run: Vec<&TestDefinition> = self
|
||||
.tests
|
||||
.iter()
|
||||
.filter(|test| self.config.categories.contains(&test.category))
|
||||
.filter(|test| !self.config.include_critical_only || test.is_critical)
|
||||
.collect();
|
||||
|
||||
info!("📊 Test plan: {} test(s) scheduled", tests_to_run.len());
|
||||
for (i, test) in tests_to_run.iter().enumerate() {
|
||||
info!(" {}. {} ({})", i + 1, test.name, test.category.as_str());
|
||||
}
|
||||
|
||||
// Execute tests
|
||||
for (i, test_def) in tests_to_run.iter().enumerate() {
|
||||
info!("🧪 Running test {}/{}: {}", i + 1, tests_to_run.len(), test_def.name);
|
||||
info!(" 📝 Description: {}", test_def.description);
|
||||
info!(" 🏷️ Category: {}", test_def.category.as_str());
|
||||
info!(" ⏱️ Estimated duration: {:?}", test_def.estimated_duration);
|
||||
|
||||
let test_start = Instant::now();
|
||||
let result = self.run_single_test(test_def).await;
|
||||
let test_duration = test_start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!("✅ Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64());
|
||||
results.push(TestResult::success(test_def.name.clone(), test_def.category.clone(), test_duration));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e);
|
||||
results.push(TestResult::failure(
|
||||
test_def.name.clone(),
|
||||
test_def.category.clone(),
|
||||
test_duration,
|
||||
e.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between tests to avoid resource conflicts
|
||||
if i < tests_to_run.len() - 1 {
|
||||
debug!("⏸️ Waiting two seconds before the next test...");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let total_duration = start_time.elapsed();
|
||||
self.print_test_summary(&results, total_duration);
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Run a single test by dispatching to the appropriate test function
|
||||
async fn run_single_test(&self, test_def: &TestDefinition) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// This is a placeholder for test dispatch logic
|
||||
// In a real implementation, this would dispatch to actual test functions
|
||||
warn!("⚠️ Test '{}' is not implemented in the unified runner; skipping", test_def.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print comprehensive test summary
|
||||
fn print_test_summary(&self, results: &[TestResult], total_duration: Duration) {
|
||||
info!("📊 KMS test suite summary");
|
||||
info!("⏱️ Total duration: {:.2} seconds", total_duration.as_secs_f64());
|
||||
info!("📈 Total tests: {}", results.len());
|
||||
|
||||
let passed = results.iter().filter(|r| r.success).count();
|
||||
let failed = results.iter().filter(|r| !r.success).count();
|
||||
|
||||
info!("✅ Passed: {}", passed);
|
||||
info!("❌ Failed: {}", failed);
|
||||
info!("📊 Success rate: {:.1}%", (passed as f64 / results.len() as f64) * 100.0);
|
||||
|
||||
// Summary by category
|
||||
let mut category_summary: std::collections::HashMap<TestCategory, (usize, usize)> = std::collections::HashMap::new();
|
||||
for result in results {
|
||||
let (total, passed_count) = category_summary.entry(result.category.clone()).or_insert((0, 0));
|
||||
*total += 1;
|
||||
if result.success {
|
||||
*passed_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
info!("📊 Category summary:");
|
||||
for (category, (total, passed_count)) in category_summary {
|
||||
info!(
|
||||
" 🏷️ {}: {}/{} ({:.1}%)",
|
||||
category.as_str(),
|
||||
passed_count,
|
||||
total,
|
||||
(passed_count as f64 / total as f64) * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
// List failed tests
|
||||
if failed > 0 {
|
||||
warn!("❌ Failing tests:");
|
||||
for result in results.iter().filter(|r| !r.success) {
|
||||
warn!(" - {}: {}", result.test_name, result.error_message.as_deref().unwrap_or("Unknown error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick test suite for critical tests only
|
||||
#[tokio::test]
|
||||
async fn test_kms_critical_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = TestSuiteConfig {
|
||||
categories: vec![TestCategory::CoreFunctionality, TestCategory::MultipartEncryption],
|
||||
include_critical_only: true,
|
||||
max_duration: Some(Duration::from_secs(600)), // 10 minutes max
|
||||
parallel_execution: false,
|
||||
};
|
||||
|
||||
let suite = KMSTestSuite::new().with_config(config);
|
||||
let results = suite.run_test_suite().await;
|
||||
|
||||
let failed_count = results.iter().filter(|r| !r.success).count();
|
||||
if failed_count > 0 {
|
||||
return Err(format!("Critical test suite failed: {failed_count} tests failed").into());
|
||||
}
|
||||
|
||||
info!("✅ All critical tests passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full comprehensive test suite
|
||||
#[tokio::test]
|
||||
async fn test_kms_full_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let suite = KMSTestSuite::new();
|
||||
let results = suite.run_test_suite().await;
|
||||
|
||||
let total_tests = results.len();
|
||||
let failed_count = results.iter().filter(|r| !r.success).count();
|
||||
let success_rate = ((total_tests - failed_count) as f64 / total_tests as f64) * 100.0;
|
||||
|
||||
info!("📊 Full suite success rate: {:.1}%", success_rate);
|
||||
|
||||
// Allow up to 10% failure rate for non-critical tests
|
||||
if success_rate < 90.0 {
|
||||
return Err(format!("Test suite success rate too low: {success_rate:.1}%").into());
|
||||
}
|
||||
|
||||
info!("✅ Full test suite succeeded");
|
||||
Ok(())
|
||||
}
|
||||
@@ -131,8 +131,18 @@ mod tests {
|
||||
|
||||
// DELETE through the raw key removes the normalized object.
|
||||
client.delete_object().bucket(bucket).key("//keyname").send().await?;
|
||||
let result = client.get_object().bucket(bucket).key("keyname").send().await;
|
||||
assert!(result.is_err(), "object must be gone after DELETE with raw key");
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("keyname")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("object must be gone after DELETE with raw key");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"GET after DELETE with raw key must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
info!("Test completed successfully");
|
||||
|
||||
@@ -61,6 +61,15 @@ mod get_codec_streaming_compat_test;
|
||||
#[cfg(test)]
|
||||
mod version_id_regression_test;
|
||||
|
||||
// Pinned previous-release -> current-build on-disk compatibility.
|
||||
#[cfg(test)]
|
||||
mod upgrade_compatibility_test;
|
||||
|
||||
// Receiver-side replication LWW (rustfs/backlog#1953): stale inbound
|
||||
// replication metadata must not overwrite a newer local category state.
|
||||
#[cfg(test)]
|
||||
mod replication_lww_receiver_test;
|
||||
|
||||
// Data usage regression tests
|
||||
#[cfg(test)]
|
||||
mod data_usage_test;
|
||||
@@ -275,6 +284,7 @@ mod console_smoke_test;
|
||||
// plus non-admin 403 probes per endpoint (sec-4 pattern).
|
||||
#[cfg(test)]
|
||||
mod admin_iam_crud_test;
|
||||
mod admin_mfa_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod admin_pools_test;
|
||||
|
||||
@@ -41,13 +41,6 @@ async fn create_issue_3107_fixture(root: &Path) -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mc_available() -> bool {
|
||||
Command::new("mc")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn run_mc(args: &[&str]) -> TestResult {
|
||||
let output = Command::new("mc").args(args).output()?;
|
||||
if !output.status.success() {
|
||||
@@ -75,10 +68,7 @@ fn count_files(root: &Path) -> usize {
|
||||
async fn test_mc_mirror_small_bucket_completes_without_list_timeout() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
info!("Starting issue #3107 mc mirror regression test");
|
||||
if !mc_available() {
|
||||
info!("Skipping issue #3107 mc mirror regression test because mc is not installed");
|
||||
return Ok(());
|
||||
}
|
||||
run_mc(&["--version"])?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
@@ -22,7 +22,6 @@ use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||
};
|
||||
use base64::Engine;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use http::HeaderValue;
|
||||
@@ -47,19 +46,19 @@ fn encode_post_policy(conditions: Vec<serde_json::Value>) -> String {
|
||||
"conditions": conditions,
|
||||
});
|
||||
|
||||
base64::engine::general_purpose::STANDARD.encode(policy.to_string())
|
||||
base64_simd::STANDARD.encode_to_string(policy.to_string())
|
||||
}
|
||||
|
||||
fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(key.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
||||
base64_simd::STANDARD.encode_to_string(hasher.finalize())
|
||||
}
|
||||
|
||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
async fn create_restricted_user(
|
||||
@@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke
|
||||
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
|
||||
fn local_sse_master_key_value() -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
|
||||
base64_simd::STANDARD.encode_to_string([0x42u8; 32])
|
||||
}
|
||||
|
||||
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
|
||||
@@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
||||
let object_key = "sse-c-object.txt";
|
||||
let expected_body = b"anonymous-post-sse-c".to_vec();
|
||||
let customer_key = "01234567890123456789012345678901";
|
||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
||||
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
@@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key))
|
||||
.sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key))
|
||||
.sse_customer_key_md5(customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res
|
||||
let object_key = "sse-c-mismatch-object.txt";
|
||||
let policy_key = "01234567890123456789012345678901";
|
||||
let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF";
|
||||
let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key);
|
||||
let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key);
|
||||
let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key);
|
||||
let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key);
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
@@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
@@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
@@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn
|
||||
let extracted_key = "nested/file.txt";
|
||||
let expected_body = b"extract-sse-c-body".to_vec();
|
||||
let customer_key = "01234567890123456789012345678901";
|
||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
||||
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||
|
||||
let client = env.create_s3_client();
|
||||
@@ -4278,10 +4277,6 @@ async fn test_signed_put_object_extract_preserves_pax_metadata_and_version_id()
|
||||
async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retention_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !crate::common::awscurl_available() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
//! rejected header-SigV4 requests.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
||||
@@ -280,7 +281,8 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
let path = format!("/{BUCKET}/tampered-payload.txt");
|
||||
let key = "tampered-payload.txt";
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let claimed_body = b"the-body-i-claim-to-send";
|
||||
let actual_body = b"the-body-i-really-send!!";
|
||||
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||
@@ -295,17 +297,82 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
assert_ne!(status.as_u16(), 200, "payload mismatch must not succeed, body:\n{body}");
|
||||
assert!(
|
||||
status.is_client_error() || status.is_server_error(),
|
||||
"payload mismatch must be an error status, got {status}, body:\n{body}"
|
||||
status.is_client_error(),
|
||||
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
|
||||
);
|
||||
info!(%status, "tampered payload rejected with error status");
|
||||
}
|
||||
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
||||
// also a valid rejection (definitely not a 200 success).
|
||||
Err(err) => info!(%err, "tampered payload rejected via transport error"),
|
||||
Err(err) => {
|
||||
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
|
||||
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
|
||||
info!(%err, "tampered payload rejected via mid-stream transport error");
|
||||
}
|
||||
}
|
||||
|
||||
let absent = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a tampered payload must not publish an object");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A signed UploadPart body must pass the same payload-hash gate as PutObject.
|
||||
/// Rejection must happen before the part is published into the multipart upload.
|
||||
#[tokio::test]
|
||||
async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
let key = "tampered-upload-part.bin";
|
||||
let client = env.create_s3_client();
|
||||
let upload = client.create_multipart_upload().bucket(BUCKET).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("create multipart upload omitted upload_id")?;
|
||||
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let canonical_query = format!("partNumber=1&uploadId={}", urlencoding::encode(upload_id));
|
||||
let request_target = format!("{path}?{canonical_query}");
|
||||
let claimed_body = b"the-part-i-claim-to-send";
|
||||
let actual_body = b"the-part-i-really-send!!";
|
||||
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||
|
||||
let signer = SigV4::new(&env);
|
||||
let headers = signer.sign("PUT", &path, &canonical_query, &sha256_hex(claimed_body));
|
||||
let resp = send_signed(&env, reqwest::Method::PUT, &request_target, &headers, Some(actual_body.to_vec())).await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"multipart payload mismatch must be rejected as BadDigest, body:\n{body}"
|
||||
);
|
||||
assert_error_code(&body, "BadDigest");
|
||||
|
||||
let parts = client
|
||||
.list_parts()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(parts.parts().is_empty(), "a tampered UploadPart must not publish a part");
|
||||
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_lo
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_config::{ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, ENV_NOTIFY_ENABLE};
|
||||
use rustfs_signer::pre_sign_v4;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use s3s::Body;
|
||||
@@ -976,7 +977,8 @@ async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
||||
.await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-disabled-target";
|
||||
let key = "input.txt";
|
||||
@@ -992,17 +994,24 @@ async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
configure_webhook_target_with_key_values(
|
||||
&env,
|
||||
"transformer",
|
||||
vec![
|
||||
("endpoint", "http://127.0.0.1:9/transform".to_string()),
|
||||
("auth_token", "secret-token".to_string()),
|
||||
("enable", "off".to_string()),
|
||||
],
|
||||
let queue_dir = format!("{}/disabled-target-queue", env.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
let config_url = format!("{}/rustfs/admin/v3/set-config-kv", env.url);
|
||||
let directive = format!(
|
||||
"notify_webhook:transformer enable=off endpoint=\"http://127.0.0.1:9/transform\" auth_token=\"secret-token\" queue_dir=\"{queue_dir}\""
|
||||
);
|
||||
let disable_response = signed_request(
|
||||
http::Method::PUT,
|
||||
&config_url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
Some(directive.into_bytes()),
|
||||
Some("text/plain"),
|
||||
)
|
||||
.await?;
|
||||
wait_for_target_visibility(&env, "transformer").await?;
|
||||
let disable_status = disable_response.status();
|
||||
let disable_body = disable_response.text().await?;
|
||||
assert_eq!(disable_status, StatusCode::OK, "failed to disable target: {disable_body}");
|
||||
|
||||
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
|
||||
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
|
||||
@@ -1021,7 +1030,8 @@ async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Resul
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
||||
.await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-invalid-endpoint";
|
||||
|
||||
@@ -1064,7 +1074,8 @@ async fn test_configure_object_lambda_notify_webhook_rejects_response_header_tim
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
||||
.await?;
|
||||
|
||||
let response = send_configure_webhook_target_request(
|
||||
&env,
|
||||
@@ -1173,6 +1184,8 @@ async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.set_env(ENV_NOTIFY_ENABLE, "true");
|
||||
cluster.set_env(ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, "1");
|
||||
cluster.start().await?;
|
||||
|
||||
let bucket = "listen-notification-cluster";
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
//! - Bypass governance retention header handling
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||
use aws_sdk_s3::operation::put_object_retention::PutObjectRetentionError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||
@@ -180,11 +183,8 @@ pub async fn put_object_retention(
|
||||
mode: ObjectLockRetentionMode,
|
||||
retain_until: DateTime<Utc>,
|
||||
bypass_governance: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
|
||||
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let retain_until_datetime =
|
||||
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
||||
) -> Result<(), Box<SdkError<PutObjectRetentionError>>> {
|
||||
let retain_until_datetime = aws_sdk_s3::primitives::DateTime::from_secs(retain_until.timestamp());
|
||||
|
||||
let retention = ObjectLockRetention::builder()
|
||||
.mode(mode.clone())
|
||||
@@ -202,7 +202,7 @@ pub async fn put_object_retention(
|
||||
request = request.version_id(vid);
|
||||
}
|
||||
|
||||
request.send().await?;
|
||||
request.send().await.map_err(Box::new)?;
|
||||
info!("Put object retention on {} with mode {:?}", key, mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -236,7 +236,7 @@ pub async fn delete_object_with_bypass(
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
bypass_governance: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
) -> Result<(), Box<SdkError<DeleteObjectError>>> {
|
||||
let mut request = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
@@ -247,7 +247,7 @@ pub async fn delete_object_with_bypass(
|
||||
request = request.version_id(vid);
|
||||
}
|
||||
|
||||
request.send().await?;
|
||||
request.send().await.map_err(Box::new)?;
|
||||
info!("Deleted object {} (bypass: {})", key, bypass_governance);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
//! - PutObjectRetention modification restrictions
|
||||
//! - Default bucket retention is applied to new objects
|
||||
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use super::common::*;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
||||
use aws_sdk_s3::types::{
|
||||
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
||||
@@ -70,25 +72,49 @@ fn retention_timestamp(days: i64) -> aws_sdk_s3::primitives::DateTime {
|
||||
.expect("retention timestamp should parse")
|
||||
}
|
||||
|
||||
fn assert_access_denied<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) {
|
||||
let err = match result {
|
||||
Ok(_) => panic!("{context}"),
|
||||
Err(err) => format!("{err:?}"),
|
||||
};
|
||||
assert!(
|
||||
err.contains("AccessDenied") || err.to_lowercase().contains("access denied"),
|
||||
"{context}: expected AccessDenied, got: {err}"
|
||||
fn assert_access_denied<T, E, R>(result: Result<T, R>, context: &str)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||
{
|
||||
let error = result.expect_err(context);
|
||||
let sdk_error = error.borrow();
|
||||
assert_eq!(
|
||||
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(403),
|
||||
"{context}: expected HTTP 403, got: {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
sdk_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"{context}: expected AccessDenied, got: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_invalid_object_lock_retention_pair<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) {
|
||||
let err = match result {
|
||||
Ok(_) => panic!("{context}"),
|
||||
Err(err) => format!("{err:?}"),
|
||||
};
|
||||
assert!(
|
||||
err.contains("InvalidRequest") || err.contains("must both be supplied"),
|
||||
"{context}: expected invalid paired retention headers, got: {err}"
|
||||
fn assert_invalid_object_lock_retention_pair<T, E, R>(result: Result<T, R>, context: &str)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||
{
|
||||
let error = result.expect_err(context);
|
||||
let sdk_error = error.borrow();
|
||||
assert_eq!(
|
||||
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(400),
|
||||
"{context}: expected HTTP 400, got: {error:?}"
|
||||
);
|
||||
let service_error = sdk_error.as_service_error().expect("expected an S3 service error");
|
||||
assert_eq!(
|
||||
service_error.code(),
|
||||
Some("InvalidRequest"),
|
||||
"{context}: expected InvalidRequest, got: {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
service_error.message(),
|
||||
Some("x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"),
|
||||
"{context}: unexpected error message: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,14 +155,15 @@ async fn test_delete_object_blocked_by_compliance_retention() {
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete - should fail
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||
"Delete should fail for COMPLIANCE locked object",
|
||||
);
|
||||
|
||||
// Even with bypass header, COMPLIANCE should not allow deletion
|
||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
||||
assert!(
|
||||
delete_with_bypass_result.is_err(),
|
||||
"Delete with bypass should still fail for COMPLIANCE mode"
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await,
|
||||
"Delete with bypass should still fail for COMPLIANCE mode",
|
||||
);
|
||||
|
||||
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
|
||||
@@ -165,8 +192,10 @@ async fn test_delete_object_blocked_by_governance_without_bypass() {
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete without bypass - should fail
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||
"Delete without bypass should fail for GOVERNANCE locked object",
|
||||
);
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
|
||||
}
|
||||
@@ -198,14 +227,19 @@ async fn test_delete_object_allowed_by_governance_with_bypass() {
|
||||
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
|
||||
|
||||
// Verify object is deleted
|
||||
let head_result = client
|
||||
let head_error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await;
|
||||
assert!(head_result.is_err(), "Object should be deleted");
|
||||
.await
|
||||
.expect_err("Object should be deleted");
|
||||
assert_eq!(
|
||||
head_error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"deleted version should return HTTP 404: {head_error:?}"
|
||||
);
|
||||
|
||||
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
|
||||
}
|
||||
@@ -240,17 +274,18 @@ async fn test_delete_object_creates_delete_marker_for_retained_current_version()
|
||||
.expect("delete marker should have a version id")
|
||||
.to_string();
|
||||
|
||||
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
||||
assert!(protected_delete.is_err(), "Retained version should still reject direct deletion");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||
"Retained version should still reject direct deletion",
|
||||
);
|
||||
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let still_protected = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
||||
assert!(
|
||||
still_protected.is_err(),
|
||||
"Retained version should remain protected after delete marker removal"
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||
"Retained version should remain protected after delete marker removal",
|
||||
);
|
||||
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
|
||||
@@ -282,12 +317,16 @@ async fn test_delete_object_blocked_by_legal_hold() {
|
||||
.unwrap();
|
||||
|
||||
// Attempt to delete - should fail (legal hold cannot be bypassed)
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for legal hold object");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||
"Delete should fail for legal hold object",
|
||||
);
|
||||
|
||||
// Even with bypass header, legal hold should block deletion
|
||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
||||
assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await,
|
||||
"Delete with bypass should still fail for legal hold",
|
||||
);
|
||||
|
||||
info!("✅ Test passed: Legal Hold blocks deletion");
|
||||
}
|
||||
@@ -315,14 +354,19 @@ async fn test_delete_object_allowed_with_legal_hold_off() {
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||
assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF");
|
||||
|
||||
let head_result = client
|
||||
let head_error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await;
|
||||
assert!(head_result.is_err(), "Object should be deleted when legal hold is OFF");
|
||||
.await
|
||||
.expect_err("Object should be deleted when legal hold is OFF");
|
||||
assert_eq!(
|
||||
head_error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"deleted version should return HTTP 404: {head_error:?}"
|
||||
);
|
||||
|
||||
info!("✅ Test passed: Legal Hold OFF allows deletion");
|
||||
}
|
||||
@@ -545,8 +589,10 @@ async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
"held version must keep its legal hold after the overwrite"
|
||||
);
|
||||
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
|
||||
assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
|
||||
"held version must stay delete-protected after the overwrite",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -768,8 +814,10 @@ async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
"held destination version must keep its legal hold after the copy"
|
||||
);
|
||||
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await;
|
||||
assert!(delete_result.is_err(), "held destination version must stay delete-protected");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await,
|
||||
"held destination version must stay delete-protected",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -909,10 +957,9 @@ async fn test_create_multipart_upload_creates_new_version_under_compliance_reten
|
||||
|
||||
// COMPLIANCE retention on the previous version survives the overwrite and
|
||||
// cannot be bypassed.
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
|
||||
assert!(
|
||||
delete_result.is_err(),
|
||||
"retained version must stay delete-protected even with governance bypass"
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
|
||||
"retained version must stay delete-protected even with governance bypass",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -971,8 +1018,10 @@ async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
|
||||
.unwrap();
|
||||
|
||||
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by legal hold");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||
"Delete should fail for multipart object protected by legal hold",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1032,8 +1081,10 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() {
|
||||
.unwrap();
|
||||
|
||||
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by retention");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||
"Delete should fail for multipart object protected by retention",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1111,8 +1162,10 @@ async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
|
||||
"held version must keep its legal hold after multipart completion"
|
||||
);
|
||||
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
|
||||
assert!(delete_result.is_err(), "held version must stay delete-protected");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
|
||||
"held version must stay delete-protected",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1181,10 +1234,9 @@ async fn test_complete_multipart_upload_creates_new_version_under_compliance_ret
|
||||
|
||||
// COMPLIANCE retention on the previous version survives the overwrite and
|
||||
// cannot be bypassed.
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
|
||||
assert!(
|
||||
delete_result.is_err(),
|
||||
"retained version must stay delete-protected even with governance bypass"
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
|
||||
"retained version must stay delete-protected even with governance bypass",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1438,7 +1490,7 @@ async fn test_put_retention_compliance_cannot_shorten() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
|
||||
assert_access_denied(shorten_result, "Shortening COMPLIANCE retention should fail");
|
||||
|
||||
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
||||
}
|
||||
@@ -1561,10 +1613,7 @@ async fn test_put_retention_governance_shorten_requires_bypass() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
shorten_without_bypass.is_err(),
|
||||
"Shortening GOVERNANCE retention without bypass should fail"
|
||||
);
|
||||
assert_access_denied(shorten_without_bypass, "Shortening GOVERNANCE retention without bypass should fail");
|
||||
|
||||
// Shorten with bypass - should succeed
|
||||
let shorten_with_bypass = put_object_retention(
|
||||
@@ -1621,8 +1670,10 @@ async fn test_default_retention_applied_to_new_objects() {
|
||||
let version_id = response.version_id().unwrap();
|
||||
|
||||
// Try to delete without bypass - should fail due to default retention
|
||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
||||
assert!(delete_result.is_err(), "Delete should fail for object with default retention applied");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||
"Delete should fail for object with default retention applied",
|
||||
);
|
||||
|
||||
let retention = client
|
||||
.get_object_retention()
|
||||
@@ -1710,8 +1761,10 @@ async fn test_delete_object_creates_delete_marker_for_default_retained_current_v
|
||||
.expect("delete marker should have a version id")
|
||||
.to_string();
|
||||
|
||||
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
||||
assert!(protected_delete.is_err(), "Default-retained version should still reject direct deletion");
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||
"Default-retained version should still reject direct deletion",
|
||||
);
|
||||
|
||||
let retention_after_delete_marker = client
|
||||
.get_object_retention()
|
||||
@@ -2011,11 +2064,9 @@ async fn test_copy_object_retention_uses_destination_policy() {
|
||||
|
||||
// COMPLIANCE retention on the previous destination version survives the
|
||||
// overwrite and cannot be bypassed.
|
||||
let delete_result =
|
||||
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await;
|
||||
assert!(
|
||||
delete_result.is_err(),
|
||||
"retained destination version must stay delete-protected even with governance bypass"
|
||||
assert_access_denied(
|
||||
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await,
|
||||
"retained destination version must stay delete-protected even with governance bypass",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2268,9 +2319,9 @@ async fn test_versioning_auto_enabled_with_object_lock() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
async fn test_legal_hold_and_retention_delete_errors_are_exact_and_non_mutating() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
|
||||
info!("🧪 Test: Legal Hold and Retention reject deletes without mutating objects");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||
env.start_rustfs().await.unwrap();
|
||||
@@ -2295,7 +2346,6 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete legal hold object - check error
|
||||
let lh_delete_result = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
@@ -2303,18 +2353,8 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
.version_id(&lh_version)
|
||||
.send()
|
||||
.await;
|
||||
assert_access_denied(lh_delete_result, "Legal Hold must reject deleting the protected version");
|
||||
|
||||
if let Err(e) = lh_delete_result {
|
||||
let error_str = format!("{:?}", e);
|
||||
info!("Legal hold delete error: {}", error_str);
|
||||
// Error should mention legal hold
|
||||
assert!(
|
||||
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
|
||||
"Error should mention legal hold"
|
||||
);
|
||||
}
|
||||
|
||||
// Delete retention object - check error
|
||||
let ret_delete_result = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
@@ -2322,16 +2362,24 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
.version_id(&ret_version)
|
||||
.send()
|
||||
.await;
|
||||
assert_access_denied(ret_delete_result, "COMPLIANCE retention must reject deleting the protected version");
|
||||
|
||||
if let Err(e) = ret_delete_result {
|
||||
let error_str = format!("{:?}", e);
|
||||
info!("Retention delete error: {}", error_str);
|
||||
// Error should mention retention
|
||||
assert!(
|
||||
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
|
||||
"Error should mention retention"
|
||||
);
|
||||
for (key, version_id) in [(legal_hold_key, &lh_version), (retention_key, &ret_version)] {
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("rejected delete must leave the protected version readable")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("protected version body should remain readable")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), b"data", "rejected delete mutated protected object {key}");
|
||||
}
|
||||
|
||||
info!("✅ Test passed: Error messages distinguish lock types");
|
||||
info!("✅ Test passed: protected deletes are exact and non-mutating");
|
||||
}
|
||||
|
||||
@@ -11,29 +11,20 @@ The tests cover the following AWS policy variable scenarios:
|
||||
3. **Variable concatenation** - Combining variables with static text like `prefix-${aws:username}-suffix`
|
||||
4. **Nested variables** - Complex nested variable patterns like `${${aws:username}-test}`
|
||||
5. **Deny scenarios** - Testing deny policies with variables
|
||||
6. **STS credentials** - Variable resolution inherited by temporary credentials
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- RustFS server binary
|
||||
- `awscurl` utility for admin API calls
|
||||
- AWS SDK for Rust (included in the project)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Policy Tests Using Unified Test Runner
|
||||
|
||||
```bash
|
||||
# Run all policy tests with comprehensive reporting
|
||||
# Note: Requires a RustFS server running on localhost:9000
|
||||
cargo test -p e2e_test policy::test_runner::test_policy_full_suite -- --nocapture --ignored --test-threads=1
|
||||
|
||||
# Run only critical policy tests
|
||||
cargo test -p e2e_test policy::test_runner::test_policy_critical_suite -- --nocapture --ignored --test-threads=1
|
||||
```
|
||||
|
||||
### Run All Policy Tests
|
||||
|
||||
```bash
|
||||
# From the project root directory
|
||||
cargo test -p e2e_test policy:: -- --nocapture --ignored --test-threads=1
|
||||
```
|
||||
cargo test -p e2e_test policy:: -- --nocapture
|
||||
```
|
||||
|
||||
Each test starts an isolated RustFS server on a dynamically allocated local port and cleans it up afterward.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user