mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-14 17:13:13 +00:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 975003d60a | |||
| b563230782 | |||
| 9b4a73f315 | |||
| 371a3529e5 | |||
| a8574d0104 | |||
| 2fb88d2c60 | |||
| 380ec74ece | |||
| 035ce5d784 | |||
| 988cd8adbb | |||
| 9dd0461f3e | |||
| fbb6cebeb4 | |||
| 2ce670837c | |||
| 8a65017f36 | |||
| 3a5b6eb11d | |||
| 0800f74874 | |||
| a918f1a48a | |||
| ec67884f8d | |||
| e5cfa8e375 | |||
| 1fdcbd9225 | |||
| 114b2420a2 | |||
| 52c70738eb | |||
| 60ee86c835 | |||
| 5206c82423 | |||
| 00324e6936 | |||
| 6028dad2f4 | |||
| 54d8c02a2f | |||
| 3f716746cf | |||
| 779b5a49ea | |||
| 2cc7443067 | |||
| 378c9ba67f | |||
| 4473c548be | |||
| ac63808d3c | |||
| 2cdba03dee | |||
| 885096d1de | |||
| 921ddef2c7 | |||
| f1a4588326 | |||
| 2698a03582 | |||
| b1ddda3bb2 | |||
| da531c8a97 | |||
| 40cd10c1d0 | |||
| f440a14e53 | |||
| c104ba23d4 | |||
| 7463655ae1 | |||
| 93b38b3fbd | |||
| f00f5fe776 | |||
| 6eddc04b0a | |||
| 3d59c90371 | |||
| 2bf2ce1ff2 | |||
| bd834297da | |||
| c147afd19c | |||
| 9644064e57 | |||
| c1955a8498 | |||
| f34aba1be7 | |||
| 34f1d2c0cd | |||
| 8bb147cb70 | |||
| 8e3e552576 | |||
| 557a616ae6 | |||
| a29ae4e5cd | |||
| da6fc5314d | |||
| a12043f49f | |||
| a6599dbc32 | |||
| 7528a0b916 | |||
| 105af08a10 | |||
| f1a85c6a93 | |||
| 3cfe867dff | |||
| fc3896f479 | |||
| 6d8c19e71c | |||
| fbec33bd29 | |||
| 4c83bff81c | |||
| 51f9d1b74f | |||
| ed02899d31 | |||
| 81fc61db41 | |||
| a08fb56607 | |||
| c8016cbcdb | |||
| 56a7e3b707 | |||
| f2d09d1426 | |||
| 02aa383598 | |||
| 860ad68afd | |||
| 95e96e1bd5 | |||
| 816849a8ee | |||
| 5ef5eb8ea9 | |||
| ad721bff42 | |||
| 29dedcc7fd | |||
| 749cb2c1a1 | |||
| eb4f2d61ad | |||
| 3c00ad6048 | |||
| 85bc0d3ce2 | |||
| b8d2f1c84c | |||
| 59f69fbfb5 | |||
| be7ba1bba9 | |||
| 6363263f09 | |||
| 533896d045 | |||
| 0bf077f918 | |||
| 284faec03f | |||
| bfec547d36 | |||
| 0bd53becb5 | |||
| da389c0e21 | |||
| b7805caa58 | |||
| b0bb0bbd3a | |||
| bc41e567a5 | |||
| d5c6ba99d5 | |||
| 62d44d10b8 | |||
| 8218248000 | |||
| fd36bdfb1a | |||
| 322ce21b9a | |||
| 35e4415ed9 | |||
| 4e34f97dd7 | |||
| 782c78e0ef | |||
| 8387528c9b | |||
| 4ce0e280f2 | |||
| 793c193a6b | |||
| 5a6e850c67 | |||
| 2d8ad5caee | |||
| 3d4f4bb86d | |||
| 2b31bda6d1 | |||
| 68e344bf03 | |||
| 48c2fcb62b | |||
| 428fde069d | |||
| 364168c0ba | |||
| 3f4f31129e | |||
| 6c99d4fe22 | |||
| f5348d5cc4 | |||
| e2b2bdcc34 | |||
| b965bd6eef | |||
| 1524ed891f | |||
| 7354a5663d | |||
| 790bdc0e63 | |||
| 707d062174 | |||
| 4b6b6f14bd | |||
| 9080ea8ea0 | |||
| 739efaaea1 | |||
| b6d4689c75 | |||
| 8763cd0c67 | |||
| fdac60b0e2 | |||
| 98b20b4231 | |||
| ffc9de72cb | |||
| c09d11ff3b | |||
| 792f2ef204 | |||
| 74019845c4 | |||
| 04c5921850 | |||
| db8039dece | |||
| 40eee6177a |
@@ -60,6 +60,11 @@ body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fai
|
|||||||
@echo "🧱 Checking body-cache whitelist guard..."
|
@echo "🧱 Checking body-cache whitelist guard..."
|
||||||
./scripts/check_body_cache_whitelist.sh
|
./scripts/check_body_cache_whitelist.sh
|
||||||
|
|
||||||
|
.PHONY: fips-wording-check
|
||||||
|
fips-wording-check: ## Check outward docs do not make unsupported FIPS claims
|
||||||
|
@echo "📣 Checking FIPS wording guard..."
|
||||||
|
./scripts/check_fips_wording.sh
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
@echo "🩺 Checking log-analyzer rule anchors..."
|
@echo "🩺 Checking log-analyzer rule anchors..."
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
|||||||
./scripts/check_no_planning_docs.sh
|
./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
.PHONY: pre-commit
|
.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 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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||||
@echo "✅ All pre-commit checks passed!"
|
@echo "✅ All pre-commit checks passed!"
|
||||||
|
|
||||||
.PHONY: pre-pr
|
.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 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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||||
@echo "✅ All pre-PR checks passed!"
|
@echo "✅ All pre-PR checks passed!"
|
||||||
|
|
||||||
.PHONY: dev-check
|
.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 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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||||
@echo "✅ Fast development checks passed!"
|
@echo "✅ Fast development checks passed!"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_entrypoint_credentials.sh
|
./scripts/test_entrypoint_credentials.sh
|
||||||
./scripts/test_internode_grpc_ab_bench.sh
|
./scripts/test_internode_grpc_ab_bench.sh
|
||||||
./scripts/test_object_batch_bench_enhanced.sh
|
./scripts/test_object_batch_bench_enhanced.sh
|
||||||
|
./scripts/test_hotpath_warp_ab_gate.sh
|
||||||
|
./scripts/test_hotpath_warp_abba.sh
|
||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
|
|||||||
+16
-7
@@ -9,6 +9,8 @@
|
|||||||
# * bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
|
# * bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
|
||||||
# uses the shared multipart fixture and a deterministic uploadId-lock
|
# uses the shared multipart fixture and a deterministic uploadId-lock
|
||||||
# handoff, so it must not overlap another process mutating that fixture.
|
# handoff, so it must not overlap another process mutating that fixture.
|
||||||
|
# * bucket::metadata_sys::tests::concurrent_config_writes_from_separate_nodes_do_not_lose_writes
|
||||||
|
# uses the shared transaction lock and must not overlap other ecstore tests.
|
||||||
#
|
#
|
||||||
# serial_test's #[serial] attribute does NOT serialize these across runs:
|
# serial_test's #[serial] attribute does NOT serialize these across runs:
|
||||||
# nextest executes each test in its own process, where the in-process
|
# nextest executes each test in its own process, where the in-process
|
||||||
@@ -40,7 +42,7 @@ e2e-inline-boundaries = { max-threads = 1 }
|
|||||||
|
|
||||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
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'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
|
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
|
||||||
@@ -104,9 +106,9 @@ filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(
|
|||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
retries = 2
|
retries = 2
|
||||||
|
|
||||||
# Keep the deterministic multipart handoff isolated across nextest processes.
|
# Keep deterministic ECStore write handoffs isolated across nextest processes.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & test(concurrent_resend_same_part_commits_one_generation)'
|
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
|
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
|
||||||
@@ -212,6 +214,17 @@ default-filter = """
|
|||||||
"""
|
"""
|
||||||
fail-fast = false
|
fail-fast = false
|
||||||
|
|
||||||
|
[profile.e2e-smoke.junit]
|
||||||
|
path = "junit.xml"
|
||||||
|
|
||||||
|
# The pagination boundary cases can stall when a server/listing regression
|
||||||
|
# prevents the continuation request from completing. Keep the timeout scoped
|
||||||
|
# to those known failure modes so legitimate lifecycle/tiering waits retain
|
||||||
|
# their test-level timing budget.
|
||||||
|
[[profile.e2e-smoke.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^list_objects_v2_pagination_test::tests::(test_list_objects_v2_delimiter_small_page_traverses_all|test_list_objects_v2_max_keys_above_limit_returns_token|test_list_objects_v2_maxkeys_above_limit_with_delimiter)$/)'
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-repl-nightly profile — scheduled full replication e2e lane (repl-1)
|
# e2e-repl-nightly profile — scheduled full replication e2e lane (repl-1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -300,9 +313,6 @@ path = "junit.xml"
|
|||||||
# negative-path siblings of each family stay in as regression guards.
|
# negative-path siblings of each family stay in as regression guards.
|
||||||
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
||||||
# archive even under ignore-errors semantics.
|
# archive even under ignore-errors semantics.
|
||||||
# * rustfs#4846 — distributed-lock quorum tests misclassify as timeout
|
|
||||||
# under parallel load (multi-node in-process clusters; natural home is
|
|
||||||
# ci-7's nightly cluster lane).
|
|
||||||
[profile.e2e-full]
|
[profile.e2e-full]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test)
|
package(e2e_test)
|
||||||
@@ -311,7 +321,6 @@ default-filter = """
|
|||||||
& !test(/^replication_extension_test::/)
|
& !test(/^replication_extension_test::/)
|
||||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
||||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
||||||
& !test(/^reliant::lock::test_distributed_lock_(2_nodes_grpc_read_survives_failed_node|4_nodes_grpc_read_write_quorum_split_with_two_failed_nodes)$/)
|
|
||||||
"""
|
"""
|
||||||
fail-fast = false
|
fail-fast = false
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-con
|
|||||||
| `CodecStreamingFallbackSpike` | Warning | Codec streaming fallback > 10x baseline for 10m |
|
| `CodecStreamingFallbackSpike` | Warning | Codec streaming fallback > 10x baseline for 10m |
|
||||||
| `IoQueueSaturation` | Warning | IO queue utilization > 90% for 5m |
|
| `IoQueueSaturation` | Warning | IO queue utilization > 90% for 5m |
|
||||||
|
|
||||||
|
The file `prometheus-rules/rustfs-kms-alerts.yml` contains alerting rules for the KMS backend operation metrics. Thresholds are conservative defaults pending staging baseline calibration; response procedures live in `docs/operations/kms-observability-runbook.md`, and the matching dashboard is `deploy/observability/grafana/rustfs-kms-observability.json`.
|
||||||
|
|
||||||
|
| Alert | Severity | Condition |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| `KmsBackendFatalErrors` | Critical | Fatal (non-retryable) attempt failures > 0 for 5m |
|
||||||
|
| `KmsBackendHighErrorRate` | Critical | Non-success operation ratio > 5% for 10m (with traffic guard) |
|
||||||
|
| `KmsBackendP99LatencyHigh` | Warning | Operation p99 duration (incl. retries) > 2s for 10m |
|
||||||
|
| `KmsBackendAttemptFailureSpike` | Warning | Attempt failure rate > 0.5/s for 10m |
|
||||||
|
| `KmsBackendRetryBudgetExhausted` | Warning | budget_exhausted / deadline_exceeded outcomes > 0.05/s for 10m |
|
||||||
|
|
||||||
### Enabling Alert Rules
|
### Enabling Alert Rules
|
||||||
|
|
||||||
Add the alert rules file to your Prometheus configuration:
|
Add the alert rules file to your Prometheus configuration:
|
||||||
|
|||||||
@@ -60,6 +60,16 @@
|
|||||||
| `CodecStreamingFallbackSpike` | 警告 | Codec streaming 回退 > 10x 基线,持续 10 分钟 |
|
| `CodecStreamingFallbackSpike` | 警告 | Codec streaming 回退 > 10x 基线,持续 10 分钟 |
|
||||||
| `IoQueueSaturation` | 警告 | IO 队列利用率 > 90%,持续 5 分钟 |
|
| `IoQueueSaturation` | 警告 | IO 队列利用率 > 90%,持续 5 分钟 |
|
||||||
|
|
||||||
|
文件 `prometheus-rules/rustfs-kms-alerts.yml` 包含 KMS 后端操作指标的告警规则。阈值为保守默认值,待 staging 基线校准;响应流程见 `docs/operations/kms-observability-runbook.md`,配套仪表盘为 `deploy/observability/grafana/rustfs-kms-observability.json`。
|
||||||
|
|
||||||
|
| 告警 | 级别 | 条件 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KmsBackendFatalErrors` | 严重 | fatal(不可重试)尝试失败 > 0,持续 5 分钟 |
|
||||||
|
| `KmsBackendHighErrorRate` | 严重 | 非 success 操作占比 > 5%,持续 10 分钟(含流量下限保护) |
|
||||||
|
| `KmsBackendP99LatencyHigh` | 警告 | 操作 p99 耗时(含重试)> 2s,持续 10 分钟 |
|
||||||
|
| `KmsBackendAttemptFailureSpike` | 警告 | 尝试失败率 > 0.5/s,持续 10 分钟 |
|
||||||
|
| `KmsBackendRetryBudgetExhausted` | 警告 | budget_exhausted / deadline_exceeded 结果 > 0.05/s,持续 10 分钟 |
|
||||||
|
|
||||||
### 启用告警规则
|
### 启用告警规则
|
||||||
|
|
||||||
在 Prometheus 配置中添加告警规则文件:
|
在 Prometheus 配置中添加告警规则文件:
|
||||||
|
|||||||
@@ -11500,6 +11500,831 @@
|
|||||||
],
|
],
|
||||||
"title": "Compression Operations Rate",
|
"title": "Compression Operations Rate",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 332
|
||||||
|
},
|
||||||
|
"id": 531,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Metrics Dimensions Drilldown",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 333
|
||||||
|
},
|
||||||
|
"id": 532,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (server, name, type) (rate(rustfs_api_requests_requests_total_by_server{job=~\"$job\",server=~\"$server\",name=~\"$api\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "{{server}} | {{name}} | {{type}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "API Requests by Server and API",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "A"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 333
|
||||||
|
},
|
||||||
|
"id": 533,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (server, drive, pool_index, set_index, drive_index, state) (rustfs_system_drive_runtime_state{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
||||||
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "max by (server, drive, pool_index, set_index, drive_index) (rustfs_system_drive_offline_duration_seconds{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
||||||
|
"legendFormat": "{{server}} | {{drive}} | offline seconds"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Drive Runtime State and Offline Duration",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 341
|
||||||
|
},
|
||||||
|
"id": 534,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (server, drive, pool_index, set_index, drive_index, api) (rate(rustfs_system_drive_api_calls_total{job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Drive API Calls by Operation",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "B"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "short"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.axisPlacement",
|
||||||
|
"value": "right"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 341
|
||||||
|
},
|
||||||
|
"id": 535,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (server, source, state) (rate(rustfs_scanner_source_work_total{job=~\"$job\",server=~\"$server\",source=~\"$scanner_source\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "{{server}} | {{source}} | {{state}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "sum by (server, cycle_scope, source, state) (rustfs_scanner_cycle_source_work{job=~\"$job\",server=~\"$server\",source=~\"$scanner_source\"})",
|
||||||
|
"legendFormat": "{{server}} | {{cycle_scope}} | {{source}} | {{state}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scanner Source Work by Server",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "B"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "short"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.axisPlacement",
|
||||||
|
"value": "right"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 349
|
||||||
|
},
|
||||||
|
"id": 536,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (server, bucket, drive, result) (rate(rustfs_scanner_bucket_drive_result_total{job=~\"$job\",server=~\"$server\",bucket=~\"$bucket\",drive=~\"$drive\",result=~\"$scanner_result\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "{{server}} | {{bucket}} | {{drive}} | {{result}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "sum by (server, cycle_scope, bucket, drive, result) (rustfs_scanner_cycle_bucket_drive_result{job=~\"$job\",server=~\"$server\",bucket=~\"$bucket\",drive=~\"$drive\",result=~\"$scanner_result\"})",
|
||||||
|
"legendFormat": "{{server}} | {{cycle_scope}} | {{bucket}} | {{drive}} | {{result}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scanner Bucket Drive Results",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "B"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "Bps"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 349
|
||||||
|
},
|
||||||
|
"id": 537,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (bucket, target_arn) (rate(rustfs_bucket_replication_target_sent_count{job=~\"$job\",bucket=~\"$bucket\",target_arn=~\"$target_arn\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "sent objects | {{bucket}} | {{target_arn}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "sum by (bucket, target_arn) (rate(rustfs_bucket_replication_target_sent_bytes{job=~\"$job\",bucket=~\"$bucket\",target_arn=~\"$target_arn\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "sent bytes | {{bucket}} | {{target_arn}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "C",
|
||||||
|
"expr": "sum by (bucket, target_arn) (rate(rustfs_bucket_replication_target_total_failed_count{job=~\"$job\",bucket=~\"$bucket\",target_arn=~\"$target_arn\"}[$__rate_interval]))",
|
||||||
|
"legendFormat": "failed objects | {{bucket}} | {{target_arn}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Bucket Replication Target Flow",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 357
|
||||||
|
},
|
||||||
|
"id": 538,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.3.2",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (server, target_id) (rustfs_audit_target_queue_length_by_server{job=~\"$job\",server=~\"$server\"})",
|
||||||
|
"legendFormat": "audit queue | {{server}} | {{target_id}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "max by (server, action, state) (rustfs_ilm_action_tasks{job=~\"$job\",server=~\"$server\"})",
|
||||||
|
"legendFormat": "ilm | {{server}} | {{action}} | {{state}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Audit and ILM by Server",
|
||||||
|
"type": "timeseries"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"preload": false,
|
"preload": false,
|
||||||
@@ -11551,6 +12376,32 @@
|
|||||||
"sort": 1,
|
"sort": 1,
|
||||||
"type": "query"
|
"type": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs_system_drive_api_calls_total,api)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Drive API",
|
||||||
|
"multi": true,
|
||||||
|
"name": "drive_api",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs_system_drive_api_calls_total,api)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-drive_api"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allValue": ".*",
|
"allValue": ".*",
|
||||||
"current": {
|
"current": {
|
||||||
@@ -11670,6 +12521,136 @@
|
|||||||
"regex": "",
|
"regex": "",
|
||||||
"sort": 1,
|
"sort": 1,
|
||||||
"type": "query"
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs_api_requests_requests_total_by_server,server)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Server",
|
||||||
|
"multi": true,
|
||||||
|
"name": "server",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs_api_requests_requests_total_by_server,server)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-server"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs_api_requests_requests_total_by_server,name)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "API",
|
||||||
|
"multi": true,
|
||||||
|
"name": "api",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs_api_requests_requests_total_by_server,name)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-api"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values({__name__=\"rustfs_bucket_replication_target_sent_count\",bucket=~\"$bucket\"},target_arn)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Target ARN",
|
||||||
|
"multi": true,
|
||||||
|
"name": "target_arn",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values({__name__=\"rustfs_bucket_replication_target_sent_count\",bucket=~\"$bucket\"},target_arn)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-target_arn"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs_scanner_source_work_total,source)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Scanner Source",
|
||||||
|
"multi": true,
|
||||||
|
"name": "scanner_source",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs_scanner_source_work_total,source)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-scanner_source"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"text": "All",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs_scanner_bucket_drive_result_total,result)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Scanner Result",
|
||||||
|
"multi": true,
|
||||||
|
"name": "scanner_result",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs_scanner_bucket_drive_result_total,result)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-scanner_result"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RustFS KMS backend — Prometheus alerting rules
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Metric source: the KMS operation-policy choke point in
|
||||||
|
# crates/kms/src/policy.rs. All label values are bounded static strings
|
||||||
|
# (operation, op_class, outcome, error_class, backend, scope); key identifiers,
|
||||||
|
# key material, and tokens never appear in labels.
|
||||||
|
#
|
||||||
|
# Response procedures: docs/operations/kms-observability-runbook.md
|
||||||
|
#
|
||||||
|
# IMPORTANT — threshold status: every numeric threshold below is a
|
||||||
|
# conservative default chosen without a production baseline. Calibrate against
|
||||||
|
# a staging baseline before relying on these alerts for paging, and prefer
|
||||||
|
# loosening over tightening until the baseline exists. Formal SLO targets are
|
||||||
|
# deliberately not encoded here (see rustfs/backlog#1584).
|
||||||
|
#
|
||||||
|
# NOTE: prometheus.yml loads /etc/prometheus/rules/*.yml — keep the .yml
|
||||||
|
# extension or the file is silently ignored by the docker-compose stack.
|
||||||
|
#
|
||||||
|
# Validate: promtool check rules rustfs-kms-alerts.yml
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
groups:
|
||||||
|
# ==========================================================================
|
||||||
|
# Critical alerts — immediate action required
|
||||||
|
# ==========================================================================
|
||||||
|
- name: rustfs-kms-critical
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. KmsBackendFatalErrors
|
||||||
|
# Any attempt failure classified as fatal (non-retryable): auth
|
||||||
|
# or permission errors, malformed requests, missing keys. The
|
||||||
|
# policy never retries these, so even a low rate means real
|
||||||
|
# operations are failing right now.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendFatalErrors
|
||||||
|
expr: |
|
||||||
|
sum by (operation) (rate(rustfs_kms_backend_attempt_failures_total{error_class="fatal"}[5m])) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend fatal errors on operation {{ $labels.operation }}"
|
||||||
|
description: >-
|
||||||
|
Attempt failures classified as fatal are occurring at
|
||||||
|
{{ $value | printf "%.3f" }}/s on operation
|
||||||
|
{{ $labels.operation }}. Fatal failures are not retried:
|
||||||
|
each one is a KMS backend call that failed permanently
|
||||||
|
(authentication, permissions, malformed request, or a
|
||||||
|
missing key/version).
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendfatalerrors"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. KmsBackendHighErrorRate
|
||||||
|
# Sustained share of operations terminating without success
|
||||||
|
# (fatal, budget/deadline exhaustion, admission backpressure,
|
||||||
|
# or an open circuit). The cancelled outcome is excluded because
|
||||||
|
# shutdowns legitimately produce it.
|
||||||
|
# The traffic guard keeps a single failure on a near-idle
|
||||||
|
# cluster from firing the alert.
|
||||||
|
# Threshold: 5% for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendHighErrorRate
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(rate(rustfs_kms_backend_operations_total{outcome!~"success|cancelled"}[5m]))
|
||||||
|
/
|
||||||
|
clamp_min(sum(rate(rustfs_kms_backend_operations_total[5m])), 1e-9)
|
||||||
|
) > 0.05
|
||||||
|
and
|
||||||
|
sum(rate(rustfs_kms_backend_operations_total[5m])) > 0.02
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend non-success ratio above 5% for 10m"
|
||||||
|
description: >-
|
||||||
|
{{ $value | humanizePercentage }} of KMS backend operations
|
||||||
|
are terminating in fatal, budget_exhausted,
|
||||||
|
deadline_exceeded, backpressure_timeout,
|
||||||
|
backpressure_rejected, or circuit_open. Object encryption
|
||||||
|
and decryption paths depending on the KMS are degraded or
|
||||||
|
failing.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendhigherrorrate"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Warning alerts — investigation needed
|
||||||
|
# ==========================================================================
|
||||||
|
- name: rustfs-kms-warning
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. KmsBackendP99LatencyHigh
|
||||||
|
# p99 wall-clock duration of whole operations (attempts plus
|
||||||
|
# backoff) is sustained above 2 seconds. Because the histogram
|
||||||
|
# includes retries, a high p99 usually means the retry policy
|
||||||
|
# is absorbing backend failures, not that every call is slow.
|
||||||
|
# Threshold: 2s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendP99LatencyHigh
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.99,
|
||||||
|
sum by (le) (rate(rustfs_kms_backend_operation_duration_seconds_bucket[5m]))
|
||||||
|
) > 2
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend operation p99 latency above 2s for 10m"
|
||||||
|
description: >-
|
||||||
|
The 99th-percentile KMS backend operation duration is
|
||||||
|
{{ $value | humanizeDuration }}, including retries and
|
||||||
|
backoff. Encryption and decryption latency is leaking into
|
||||||
|
S3 request latency.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendp99latencyhigh"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. KmsBackendAttemptFailureSpike
|
||||||
|
# Aggregate attempt-failure rate (all error classes) sustained
|
||||||
|
# above an absolute floor. An absolute threshold is used instead
|
||||||
|
# of an offset-1d baseline ratio because fresh deployments have
|
||||||
|
# no baseline and an empty offset vector would keep a ratio
|
||||||
|
# alert from ever firing; switch to a baseline-relative form
|
||||||
|
# (see rustfs-get-optimization-alerts.yaml for the pattern)
|
||||||
|
# once a stable staging baseline exists.
|
||||||
|
# Threshold: 0.5/s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendAttemptFailureSpike
|
||||||
|
expr: |
|
||||||
|
sum(rate(rustfs_kms_backend_attempt_failures_total[5m])) > 0.5
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend attempt failures above 0.5/s for 10m"
|
||||||
|
description: >-
|
||||||
|
KMS backend attempts are failing at
|
||||||
|
{{ $value | printf "%.2f" }}/s across all error classes.
|
||||||
|
The retry policy may still be masking these from callers —
|
||||||
|
check the error-class breakdown before it stops absorbing
|
||||||
|
them.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendattemptfailurespike"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. KmsBackendRetryBudgetExhausted
|
||||||
|
# Operations are running out of retry budget (budget_exhausted)
|
||||||
|
# or operation deadline (deadline_exceeded). These surface to
|
||||||
|
# callers as failed KMS operations even though every individual
|
||||||
|
# failure was retryable — the backend is unhealthy for longer
|
||||||
|
# than the policy can bridge.
|
||||||
|
# Threshold: 0.05/s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendRetryBudgetExhausted
|
||||||
|
expr: |
|
||||||
|
sum by (outcome) (rate(rustfs_kms_backend_operations_total{outcome=~"budget_exhausted|deadline_exceeded"}[5m])) > 0.05
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend operations exhausting retry budget ({{ $labels.outcome }})"
|
||||||
|
description: >-
|
||||||
|
KMS backend operations are terminating as
|
||||||
|
{{ $labels.outcome }} at {{ $value | printf "%.3f" }}/s.
|
||||||
|
Retryable failures are outlasting the retry budget, so
|
||||||
|
callers are seeing hard failures.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendretrybudgetexhausted"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. KmsBackendCircuitOpen
|
||||||
|
# Direct circuit-state signal, independent of operation traffic.
|
||||||
|
# A transient open can recover on its first half-open probe; alert
|
||||||
|
# only when the circuit remains open or half-open for one minute.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendCircuitOpen
|
||||||
|
expr: |
|
||||||
|
rustfs_kms_backend_circuit_open > 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend circuit open ({{ $labels.backend }}/{{ $labels.scope }})"
|
||||||
|
description: >-
|
||||||
|
The KMS backend circuit for {{ $labels.backend }} scope
|
||||||
|
{{ $labels.scope }} has remained open or half-open for one
|
||||||
|
minute. Operations in this scope can terminate as
|
||||||
|
circuit_open until the half-open probe succeeds or returns
|
||||||
|
a non-retryable failure.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendcircuitopen"
|
||||||
@@ -25,9 +25,13 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: "rustfs-deps"
|
default: "rustfs-deps"
|
||||||
cache-save-if:
|
cache-save-if:
|
||||||
description: "Condition for saving cache"
|
description: >-
|
||||||
|
Whether to save the cache. The fail-safe default is 'false': a caller that
|
||||||
|
wants to populate a cache must opt in explicitly, so a forgotten input
|
||||||
|
costs a cold cache (minutes) rather than silently consuming the
|
||||||
|
repository-wide 10GB Actions cache quota and evicting other lanes.
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: "false"
|
||||||
install-cross-tools:
|
install-cross-tools:
|
||||||
description: "Install cross-compilation tools"
|
description: "Install cross-compilation tools"
|
||||||
required: false
|
required: false
|
||||||
@@ -36,28 +40,43 @@ inputs:
|
|||||||
description: "Target architecture to add"
|
description: "Target architecture to add"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
github-token:
|
install-build-packaging-tools:
|
||||||
description: "GitHub token for API access"
|
description: >-
|
||||||
|
Install musl-tools/zip/unzip, needed for musl linking and release
|
||||||
|
packaging. Off for CI test lanes, which use none of them.
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: "true"
|
||||||
|
install-test-tools:
|
||||||
|
description: >-
|
||||||
|
Install cargo-nextest and the rustfmt/clippy components. Off for release
|
||||||
|
and audit lanes, which run no tests and no lints.
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
|
# protobuf-compiler is deliberately absent: the setup-protoc step below
|
||||||
|
# installs 34.1 into the tool cache and prepends it to PATH, so the apt
|
||||||
|
# build (older, and never version-matched) was shadowed on every run and
|
||||||
|
# simply never used.
|
||||||
- name: Install system dependencies (Ubuntu)
|
- name: Install system dependencies (Ubuntu)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
musl-tools \
|
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
ripgrep \
|
ripgrep
|
||||||
unzip \
|
|
||||||
zip \
|
# musl-gcc is needed by the native musl release leg, and zip/unzip by the
|
||||||
protobuf-compiler
|
# release packaging steps. No CI test lane touches any of them.
|
||||||
|
- name: Install packaging and cross-linking dependencies (Ubuntu)
|
||||||
|
if: runner.os == 'Linux' && inputs.install-build-packaging-tools == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: sudo apt-get install -y musl-tools zip unzip
|
||||||
|
|
||||||
- name: Install protoc
|
- name: Install protoc
|
||||||
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
|
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
|
||||||
@@ -75,7 +94,7 @@ runs:
|
|||||||
with:
|
with:
|
||||||
toolchain: ${{ inputs.rust-version }}
|
toolchain: ${{ inputs.rust-version }}
|
||||||
targets: ${{ inputs.target }}
|
targets: ${{ inputs.target }}
|
||||||
components: rustfmt, clippy
|
components: ${{ inputs.install-test-tools == 'true' && 'rustfmt, clippy' || '' }}
|
||||||
|
|
||||||
- name: Install Zig
|
- name: Install Zig
|
||||||
if: inputs.install-cross-tools == 'true'
|
if: inputs.install-cross-tools == 'true'
|
||||||
@@ -86,12 +105,24 @@ runs:
|
|||||||
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
||||||
|
|
||||||
- name: Install cargo-nextest
|
- name: Install cargo-nextest
|
||||||
|
if: inputs.install-test-tools == 'true'
|
||||||
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
|
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
|
||||||
|
|
||||||
- name: Setup Rust cache
|
- name: Setup Rust cache
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
cache-all-crates: true
|
# false is rust-cache's own default. With true, cleanup.ts returns
|
||||||
|
# *before* pruning ~/.cargo/registry/src, and config.ts archives the
|
||||||
|
# whole registry — so every cache carried the unpacked source tree of
|
||||||
|
# every dependency, not just "a few extra crates".
|
||||||
|
#
|
||||||
|
# No coverage is lost: getPackages runs `cargo metadata --all-features`,
|
||||||
|
# a strict superset of any single lane's feature closure, and -sys crates
|
||||||
|
# are explicitly exempted from pruning (their src timestamps would
|
||||||
|
# otherwise trigger rebuilds). Anything pruned is re-unpacked from the
|
||||||
|
# .crate files still in registry/cache, whose mtimes crates.io
|
||||||
|
# normalises, so cargo fingerprints stay valid.
|
||||||
|
cache-all-crates: false
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
shared-key: ${{ inputs.cache-shared-key }}
|
shared-key: ${{ inputs.cache-shared-key }}
|
||||||
save-if: ${{ inputs.cache-save-if }}
|
save-if: ${{ inputs.cache-save-if }}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -45,8 +46,11 @@ jobs:
|
|||||||
name: Architecture Migration Rules
|
name: Architecture Migration Rules
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ on:
|
|||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
|
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
||||||
|
# published against an unchanged dependency tree; at weekly cadence a new
|
||||||
|
# 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)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -59,6 +64,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -74,11 +80,32 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
with:
|
||||||
cache-shared-key: rustfs-cargo-deny
|
persist-credentials: false
|
||||||
|
|
||||||
|
# cargo-deny compiles nothing, so the full setup composite (apt packages,
|
||||||
|
# protoc, flatc, nextest, rustfmt/clippy) was pure overhead here. It does
|
||||||
|
# still need a real cargo: `cargo deny check` runs `cargo metadata`, and
|
||||||
|
# Cargo.toml pins datafusion and s3s as git dependencies, which must be
|
||||||
|
# materialised into ~/.cargo/git — a cold clone is hundreds of MB, so the
|
||||||
|
# cache stays.
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
|
|
||||||
|
# Was relying on the composite's default, which used to be "true": every
|
||||||
|
# PR touching Cargo.toml/Cargo.lock saved a second, PR-scoped copy of this
|
||||||
|
# cache and pushed the main-scoped lanes out of the 10GB quota. The
|
||||||
|
# default is now "false", but state it explicitly — see
|
||||||
|
# scripts/security/check_cache_save_if.sh.
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
with:
|
||||||
|
# Same reasoning as the setup composite: true archives every
|
||||||
|
# dependency's unpacked source tree.
|
||||||
|
cache-all-crates: false
|
||||||
|
cache-on-failure: true
|
||||||
|
shared-key: rustfs-cargo-deny
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Install cargo-deny
|
- name: Install cargo-deny
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||||
@@ -96,16 +123,28 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Report unpinned GitHub Actions
|
- name: Report unpinned GitHub Actions
|
||||||
run: ./scripts/security/check_workflow_pins.sh --enforce
|
run: ./scripts/security/check_workflow_pins.sh --enforce
|
||||||
|
|
||||||
|
- name: Check setup cache-save-if is explicit
|
||||||
|
run: ./scripts/security/check_cache_save_if.sh
|
||||||
|
|
||||||
|
- name: Check every job declares a timeout
|
||||||
|
run: ./scripts/security/check_job_timeouts.sh
|
||||||
|
|
||||||
|
- name: Check checkouts clear their credentials
|
||||||
|
run: ./scripts/security/check_persist_credentials.sh
|
||||||
|
|
||||||
- name: Check preview release workflow policy
|
- name: Check preview release workflow policy
|
||||||
run: ./scripts/security/check_preview_release_workflow.sh
|
run: ./scripts/security/check_preview_release_workflow.sh
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
if: github.event_name == 'pull_request' && github.event.action != 'closed'
|
if: github.event_name == 'pull_request' && github.event.action != 'closed'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -113,6 +152,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
|
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
|
||||||
@@ -125,3 +166,28 @@ jobs:
|
|||||||
# conscious re-review of the license/provenance claim (backlog#1181).
|
# conscious re-review of the license/provenance claim (backlog#1181).
|
||||||
allow-dependencies-licenses: pkg:cargo/rustfs-uring@0.1.0
|
allow-dependencies-licenses: pkg:cargo/rustfs-uring@0.1.0
|
||||||
comment-summary-in-pr: always
|
comment-summary-in-pr: always
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
# dependency-review is deliberately excluded: it only runs on pull_request,
|
||||||
|
# so it can never contribute a failure to a scheduled run.
|
||||||
|
needs: [cargo-deny, workflow-pin-report]
|
||||||
|
# A scheduled cargo-deny failure usually means the dependency tree just
|
||||||
|
# matched a newly published advisory — the single most important signal this
|
||||||
|
# workflow produces, and until now it was only visible to whoever happened to
|
||||||
|
# open the Actions tab. Same ci-8 mechanism coverage.yml and
|
||||||
|
# e2e-replication-nightly.yml already use.
|
||||||
|
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 }}
|
||||||
|
|||||||
@@ -50,12 +50,18 @@ on:
|
|||||||
- "**/*.svg"
|
- "**/*.svg"
|
||||||
- ".gitignore"
|
- ".gitignore"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
|
- "flake.lock"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_docker:
|
build_docker:
|
||||||
description: "Build and push Docker images after binary build"
|
# Advisory only. docker.yml triggers on workflow_run and its job-level
|
||||||
|
# condition requires the triggering event to be a tag push, so a manual
|
||||||
|
# dispatch of this workflow never produces images regardless of this
|
||||||
|
# value. Kept because the summary step reports it; wiring it up would
|
||||||
|
# mean teaching docker.yml's version parser a second event shape.
|
||||||
|
description: "Build and push Docker images after binary build (ignored: dispatch runs never reach docker.yml)"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -83,6 +89,7 @@ jobs:
|
|||||||
build-check:
|
build-check:
|
||||||
name: Build Strategy Check
|
name: Build Strategy Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
should_build: ${{ steps.check.outputs.should_build }}
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
build_type: ${{ steps.check.outputs.build_type }}
|
build_type: ${{ steps.check.outputs.build_type }}
|
||||||
@@ -92,6 +99,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Determine build strategy
|
- name: Determine build strategy
|
||||||
id: check
|
id: check
|
||||||
@@ -164,6 +173,7 @@ jobs:
|
|||||||
name: Prepare Platform Matrix
|
name: Prepare Platform Matrix
|
||||||
needs: build-check
|
needs: build-check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
matrix: ${{ steps.select.outputs.matrix }}
|
matrix: ${{ steps.select.outputs.matrix }}
|
||||||
selected: ${{ steps.select.outputs.selected }}
|
selected: ${{ steps.select.outputs.selected }}
|
||||||
@@ -171,10 +181,14 @@ jobs:
|
|||||||
- name: Select target platforms
|
- name: Select target platforms
|
||||||
id: select
|
id: select
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
# via env, not interpolation: a dispatch input is free-form text and
|
||||||
|
# would otherwise be pasted into the script for bash to evaluate.
|
||||||
|
RAW_PLATFORMS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platforms || 'all' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
selected="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platforms || 'all' }}"
|
selected="$RAW_PLATFORMS"
|
||||||
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
||||||
if [[ -z "${selected}" ]]; then
|
if [[ -z "${selected}" ]]; then
|
||||||
selected="all"
|
selected="all"
|
||||||
@@ -245,6 +259,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -253,9 +268,17 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
cache-shared-key: build-${{ matrix.target }}
|
cache-shared-key: build-${{ matrix.target }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
|
# other tag, no main run and no PR can restore it, so every release
|
||||||
|
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||||
|
# six legs each) that nobody could read, evicting the hot lanes from
|
||||||
|
# the repo-wide 10GB quota. Tag builds still restore the main-scoped
|
||||||
|
# cache, since default-branch caches are readable from every ref.
|
||||||
|
# The one real cost: re-running a failed leg of the same tag no longer
|
||||||
|
# finds that tag's own warm cache and falls back to main's.
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
install-cross-tools: ${{ matrix.cross }}
|
install-cross-tools: ${{ matrix.cross }}
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
- name: Download static console assets
|
- name: Download static console assets
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -702,9 +725,14 @@ jobs:
|
|||||||
needs: [ build-check, build-rustfs ]
|
needs: [ build-check, build-rustfs ]
|
||||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Build completion summary
|
- name: Build completion summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
# dispatch input via env: free-form text must not be pasted into the
|
||||||
|
# script for bash to evaluate.
|
||||||
|
INPUT_BUILD_DOCKER: ${{ github.event.inputs.build_docker }}
|
||||||
run: |
|
run: |
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
VERSION="${{ needs.build-check.outputs.version }}"
|
VERSION="${{ needs.build-check.outputs.version }}"
|
||||||
@@ -746,7 +774,7 @@ jobs:
|
|||||||
echo "🐳 Docker Images:"
|
echo "🐳 Docker Images:"
|
||||||
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
||||||
echo "⏭️ Preview tags do not publish Docker images"
|
echo "⏭️ Preview tags do not publish Docker images"
|
||||||
elif [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then
|
elif [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then
|
||||||
echo "⏭️ Docker image build was skipped (binary only build)"
|
echo "⏭️ Docker image build was skipped (binary only build)"
|
||||||
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
||||||
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
||||||
@@ -760,6 +788,7 @@ jobs:
|
|||||||
needs: [ build-check, build-rustfs ]
|
needs: [ build-check, build-rustfs ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
@@ -769,6 +798,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
@@ -819,12 +849,15 @@ jobs:
|
|||||||
needs: [ build-check, build-rustfs, create-release ]
|
needs: [ build-check, build-rustfs, create-release ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read
|
actions: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download all build artifacts
|
- name: Download all build artifacts
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -910,6 +943,7 @@ jobs:
|
|||||||
needs: [ build-check, publish-release ]
|
needs: [ build-check, publish-release ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Update latest.json
|
- name: Update latest.json
|
||||||
env:
|
env:
|
||||||
@@ -969,6 +1003,7 @@ jobs:
|
|||||||
needs: [ build-check, create-release, upload-release-assets ]
|
needs: [ build-check, create-release, upload-release-assets ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Copyright 2026 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.
|
||||||
|
|
||||||
|
# Sole writer of the Rust dependency caches that ci.yml restores.
|
||||||
|
#
|
||||||
|
# Why this is a separate workflow rather than steps inside ci.yml: ci.yml's
|
||||||
|
# concurrency group cancels in-progress runs on main pushes, and merges land far
|
||||||
|
# faster than its 70-minute pipeline. Measured over 15 consecutive main pushes:
|
||||||
|
# 12 cancelled, 2 failed, 0 succeeded. A cancelled run never reaches
|
||||||
|
# Swatinem/rust-cache's post step (cache-on-failure does not cover cancellation),
|
||||||
|
# so the writer lanes were saving nothing and every PR paid a cold restore —
|
||||||
|
# 11.8-20.9 minutes of "Setup Rust environment" against 0.7-3.4 warm.
|
||||||
|
#
|
||||||
|
# Splitting cache writing out of the test pipeline lets ci.yml keep cancelling
|
||||||
|
# superseded runs (which is correct — nobody needs test results for a commit
|
||||||
|
# that is already three merges behind) while the caches still get written.
|
||||||
|
#
|
||||||
|
# The group below deliberately does NOT cancel in progress; see the comment on
|
||||||
|
# it for how that bounds concurrency and why it is scoped by event.
|
||||||
|
#
|
||||||
|
# Each job below owns exactly one shared-key and is the only place that sets
|
||||||
|
# cache-save-if to anything but 'false' for it; every lane in ci.yml reads.
|
||||||
|
# scripts/security/check_cache_save_if.sh keeps the declarations explicit.
|
||||||
|
#
|
||||||
|
# The builds are supersets of what the reading lanes compile, because a reader
|
||||||
|
# restores only what the writer saved. Feature resolution matters here: a lane
|
||||||
|
# built with e2e-test-hooks resolves dependency features differently, which
|
||||||
|
# changes -Cmetadata, so the plain build does not cover it. See
|
||||||
|
# rustfs/backlog#1600.
|
||||||
|
|
||||||
|
name: Cache Warm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
|
||||||
|
# compiles, it cannot change what ci.yml needs restored either.
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "docs/**"
|
||||||
|
- "deploy/**"
|
||||||
|
- "scripts/dev_*.sh"
|
||||||
|
- "scripts/probe.sh"
|
||||||
|
- "LICENSE*"
|
||||||
|
- ".gitignore"
|
||||||
|
- ".dockerignore"
|
||||||
|
- "README*"
|
||||||
|
- "**/*.png"
|
||||||
|
- "**/*.jpg"
|
||||||
|
- "**/*.svg"
|
||||||
|
- ".github/workflows/build.yml"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
|
- ".github/workflows/audit.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
emit_timings:
|
||||||
|
description: >-
|
||||||
|
Also emit cargo --timings for the ci-dev build and upload it. Used to
|
||||||
|
decide whether sccache is worth adopting (rustfs/backlog#1601 gate).
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
|
||||||
|
# one running plus one pending per group, so with a single shared group a
|
||||||
|
# manually dispatched run was displaced as pending by the next merge and
|
||||||
|
# cancelled — observed three times in a row, which made the --timings gate in
|
||||||
|
# rustfs/backlog#1601 effectively impossible to trigger while main was busy.
|
||||||
|
#
|
||||||
|
# Still no cancel-in-progress: a burst of merges collapses into "current run
|
||||||
|
# finishes, newest queued run follows" rather than a pile-up, which is what
|
||||||
|
# bounds this workflow to one self-hosted runner per event type.
|
||||||
|
#
|
||||||
|
# The two paths can now overlap and race to save the same key. That is benign:
|
||||||
|
# the loser finds the key already present and skips, and both builds produce the
|
||||||
|
# same artifacts from the same commit.
|
||||||
|
concurrency:
|
||||||
|
group: cache-warm-${{ github.event_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||||
|
# e2e-tests, e2e-full.
|
||||||
|
warm-ci-dev:
|
||||||
|
name: Warm ci-dev
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
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: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
# rustfs/backlog#1601 gate. sccache can only cache compilation units whose
|
||||||
|
# --emit includes link, so it covers workspace rlibs and nothing else:
|
||||||
|
# clippy is metadata-only, and the ~100 test binaries, the rustfs bin and
|
||||||
|
# every build script invoke the system linker. Before spending a bucket,
|
||||||
|
# credentials and a supply-chain boundary on it, measure how much of the
|
||||||
|
# build is actually rlib codegen.
|
||||||
|
#
|
||||||
|
# Read from the report: workspace lib codegen as a share of the build, and
|
||||||
|
# s3select-query's own rlib as a share. The plan adopts sccache only above
|
||||||
|
# 50% and 25% respectively; if linking dominates instead, the answer is
|
||||||
|
# mold/lld plus split-debuginfo, which is exactly the part sccache cannot
|
||||||
|
# touch. Off by default — this doubles the ci-dev build.
|
||||||
|
- name: Build ci-dev superset (with --timings)
|
||||||
|
if: inputs.emit_timings
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: cargo build --workspace --all-targets --timings
|
||||||
|
|
||||||
|
- name: Upload cargo timings report
|
||||||
|
if: inputs.emit_timings
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: cargo-timings-ci-dev
|
||||||
|
path: target/cargo-timings/
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# --all-targets covers the test binaries nextest builds, including
|
||||||
|
# e2e_test, which test-and-lint's own run excludes. The second build adds
|
||||||
|
# the e2e-test-hooks feature resolution that build-rustfs-debug-binary uses
|
||||||
|
# and that no lint lane enables.
|
||||||
|
- name: Build ci-dev superset
|
||||||
|
env:
|
||||||
|
# Same limit ci.yml puts on its nextest step: this builds the same
|
||||||
|
# ~100 workspace test binaries, and three concurrent links saturate the
|
||||||
|
# self-hosted runner's overlay I/O and can wedge Cargo (#5394).
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
cargo build --workspace --all-targets
|
||||||
|
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||||
|
|
||||||
|
# Runs before rust-cache's post step, so these are the sizes it is about
|
||||||
|
# to archive. Reported so the cache-all-crates decision stays evidence-led:
|
||||||
|
# registry/src is what that flag prunes, registry/cache is what the pruned
|
||||||
|
# sources are re-unpacked from. See rustfs/backlog#1600.
|
||||||
|
- name: Report cache input sizes
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# tee, not a plain redirect: sent only to $GITHUB_STEP_SUMMARY these
|
||||||
|
# numbers are readable in the UI but absent from the job log, and the
|
||||||
|
# REST API exposes the log, not the summary — which made the figures
|
||||||
|
# unreachable for exactly the scripted comparison they exist for.
|
||||||
|
sizes="$(du -sh ~/.cargo/registry/src ~/.cargo/registry/cache \
|
||||||
|
~/.cargo/registry/index ~/.cargo/git target 2>/dev/null || true)"
|
||||||
|
echo "cache-input-sizes-begin"
|
||||||
|
printf '%s\n' "$sizes"
|
||||||
|
echo "cache-input-sizes-end"
|
||||||
|
{
|
||||||
|
echo "### Cache input sizes (ci-dev)"
|
||||||
|
echo '```'
|
||||||
|
printf '%s\n' "$sizes"
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
|
||||||
|
warm-ci-feat-rio:
|
||||||
|
name: Warm ci-feat-rio
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
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-feat-rio
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Build ci-feat-rio superset
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs -p rustfs-ecstore --all-targets --features rio-v2
|
||||||
|
cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
|
# Readers: the swift and sftp legs of test-and-lint-protocols. Built in
|
||||||
|
# sequence rather than as `--features swift,sftp`, which is a combination no
|
||||||
|
# lane actually compiles; running both leaves the union in target/.
|
||||||
|
warm-ci-feat-proto:
|
||||||
|
name: Warm ci-feat-proto
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
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-feat-proto
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Build ci-feat-proto superset
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features swift
|
||||||
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features sftp
|
||||||
|
|
||||||
|
# Reader: uring-integration. Runs on ubuntu-latest to match it: rust-cache's
|
||||||
|
# key covers runner.os and arch but not the runner label or image, so a cache
|
||||||
|
# written on sm-standard-4 would be restored by the hosted runner as if it
|
||||||
|
# belonged to it.
|
||||||
|
warm-ci-uring:
|
||||||
|
name: Warm ci-uring
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
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-uring
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||||
|
|
||||||
|
- name: Build ci-uring superset
|
||||||
|
run: cargo build -p rustfs-ecstore --all-targets
|
||||||
@@ -12,18 +12,24 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
# Companion to ci.yml for the required "Test and Lint" status check.
|
# Companion to ci.yml for required status checks.
|
||||||
#
|
#
|
||||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch
|
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
||||||
# ruleset requires a check named "Test and Lint" — without this workflow a
|
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
||||||
# docs-only PR would wait on that check forever. This workflow triggers on
|
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
||||||
# exactly the paths ci.yml ignores and reports an instant success under the
|
# ignores and reports success under the same job name. Mixed PRs trigger both
|
||||||
# same job name. Mixed PRs trigger both workflows and the real check still
|
# workflows and the real check still gates: a required check with any failing
|
||||||
# gates: a required check with any failing run blocks the merge.
|
# run blocks the merge.
|
||||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
||||||
#
|
#
|
||||||
|
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
|
||||||
|
# required too (rustfs/backlog#1599). Until that change lands this job is
|
||||||
|
# inert; mirroring it first is what lets the ruleset change happen without
|
||||||
|
# stranding docs-only PRs on a check nobody reports.
|
||||||
|
#
|
||||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
# Keep the paths list below in sync with the pull_request paths-ignore list
|
||||||
# in ci.yml.
|
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
||||||
|
# quick-checks job in ci.yml.
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
name: Continuous Integration (docs only)
|
||||||
|
|
||||||
@@ -47,17 +53,82 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-and-lint:
|
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
||||||
name: Test and Lint
|
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
||||||
|
# two check runs with this name: the real one (45-51s) and this companion.
|
||||||
|
# GitHub has no written contract for how it picks between same-named
|
||||||
|
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
||||||
|
# relying on ordering we make both runs execute the same commands against
|
||||||
|
# the same merge ref — their conclusions are then necessarily identical and
|
||||||
|
# the choice does not matter. Keep these steps byte-identical to the
|
||||||
|
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
||||||
|
# sync below, is tracked in rustfs/backlog#1603).
|
||||||
|
#
|
||||||
|
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
||||||
|
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
||||||
|
quick-checks:
|
||||||
|
name: Quick Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install ripgrep
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
|
- name: Check unsafe code allowances
|
||||||
|
run: ./scripts/check_unsafe_code_allowances.sh
|
||||||
|
|
||||||
|
- name: Check layered dependencies
|
||||||
|
run: ./scripts/check_layer_dependencies.sh
|
||||||
|
|
||||||
|
- name: Check architecture migration rules
|
||||||
|
run: ./scripts/check_architecture_migration_rules.sh
|
||||||
|
|
||||||
|
- name: Check tokio io-uring feature guard
|
||||||
|
run: ./scripts/check_no_tokio_io_uring.sh
|
||||||
|
|
||||||
|
- name: Check extension schema boundaries
|
||||||
|
run: ./scripts/check_extension_schema_boundaries.sh
|
||||||
|
|
||||||
|
- name: Check body-cache whitelist guard
|
||||||
|
run: ./scripts/check_body_cache_whitelist.sh
|
||||||
|
|
||||||
|
- name: Check no planning docs committed
|
||||||
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
|
- name: Check CI paths stay in sync
|
||||||
|
run: ./scripts/check_ci_paths_sync.sh
|
||||||
|
|
||||||
|
- name: Check io_uring lane --lib precondition
|
||||||
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|
||||||
|
test-and-lint:
|
||||||
|
name: Test and Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
# Docs-only PRs skip the full code CI, but they are exactly where a
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
# planning-type document could be slipped in (git add -f bypasses
|
||||||
|
|||||||
+251
-73
@@ -33,6 +33,7 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
@@ -54,6 +55,7 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -81,6 +83,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -89,13 +92,20 @@ jobs:
|
|||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
# Fast, compile-free checks that fail early so contributors get feedback in
|
||||||
# ~1 minute instead of waiting for the full test job.
|
# ~1 minute instead of waiting for the full test job.
|
||||||
|
#
|
||||||
|
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
|
||||||
|
# PR, which reports two check runs named "Quick Checks", cannot get one red
|
||||||
|
# and one green. Edit both jobs together.
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -104,6 +114,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
@@ -137,24 +149,48 @@ jobs:
|
|||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
|
- name: Check CI paths stay in sync
|
||||||
|
run: ./scripts/check_ci_paths_sync.sh
|
||||||
|
|
||||||
|
- name: Check io_uring lane --lib precondition
|
||||||
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
|
# Both lines are required. Job-level `permissions` replaces the workflow
|
||||||
|
# block rather than merging with it, so declaring only `actions: write`
|
||||||
|
# would drop `contents: read` and break this job's checkout and the
|
||||||
|
# repo-token the setup action hands to setup-protoc.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
# 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.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test
|
# Every lane in this workflow reads its cache and none writes it.
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
# cache-warm.yml is the sole writer for all four keys: this workflow
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
# cancels superseded runs on main, and a cancelled run never reaches
|
||||||
|
# rust-cache's post step, so writing from here saved nothing (12 of 15
|
||||||
|
# consecutive main-push runs were cancelled). See rustfs/backlog#1600.
|
||||||
|
cache-shared-key: ci-dev
|
||||||
|
cache-save-if: 'false'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Prepare test evidence
|
- name: Prepare test evidence
|
||||||
run: |
|
run: |
|
||||||
@@ -169,43 +205,54 @@ jobs:
|
|||||||
# Clippy runs before the test pass: lint failures are the most common
|
# Clippy runs before the test pass: lint failures are the most common
|
||||||
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
||||||
# of tests.
|
# of tests.
|
||||||
|
# Sampled too: clippy is the natural control arm for any CARGO_BUILD_JOBS
|
||||||
|
# experiment, since --all-targets is check-only for workspace members and
|
||||||
|
# never links the ~100 test binaries the limit exists to throttle.
|
||||||
- name: Run clippy lints
|
- name: Run clippy lints
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
run: |
|
||||||
|
./scripts/ci/resource_sampler.sh start clippy
|
||||||
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Run nextest tests
|
- name: Run nextest tests
|
||||||
env:
|
env:
|
||||||
# Three concurrent workspace test links saturate the self-hosted
|
# #5394 mitigation, now under a measured experiment (backlog#1601).
|
||||||
# runner's overlay I/O and can wedge Cargo until the 75m timeout.
|
#
|
||||||
CARGO_BUILD_JOBS: "2"
|
# 2 was chosen when three concurrent workspace test links were believed
|
||||||
|
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
|
||||||
|
# timeout. cgroup v2 readings from the sampler show the pod actually
|
||||||
|
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
|
||||||
|
# a seventh of what is available and memory was never the constraint —
|
||||||
|
# the label name "sm-standard-4" had led everyone, including the
|
||||||
|
# original mitigation, to assume 4 cores.
|
||||||
|
#
|
||||||
|
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
|
||||||
|
# merge path is untouched while the experiment runs.
|
||||||
|
#
|
||||||
|
# Dispatch is included because push alone cannot supply the samples:
|
||||||
|
# this workflow cancels superseded runs on main, and only 4 of the last
|
||||||
|
# 20 push-triggered Test and Lint jobs reached a terminal state — at
|
||||||
|
# that rate ten samples would take roughly fifty merges. The
|
||||||
|
# concurrency group is scoped by event_name, so a dispatched run has
|
||||||
|
# its own group and is not cancelled by merge traffic, which makes the
|
||||||
|
# sample collectable on demand rather than by waiting.
|
||||||
|
#
|
||||||
|
# Baseline over 17 samples at 2:
|
||||||
|
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
|
||||||
|
# criterion is that ratio dropping at least 10% (below ~1.76) with no
|
||||||
|
# 75m timeout and no run showing three consecutive samples of
|
||||||
|
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
|
||||||
|
# "this limit is not the bottleneck" — fix it back at 2 and record the
|
||||||
|
# experiment, which is a result, not a failure.
|
||||||
|
#
|
||||||
|
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
|
||||||
|
# prefixed variables from process.env into the cache key, so promoting
|
||||||
|
# this to job level would rotate every key on this lane.
|
||||||
|
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/test-and-lint
|
mkdir -p artifacts/test-and-lint
|
||||||
# Evidence sampler for issue #5394: the post-mortem pgrep below runs
|
./scripts/ci/resource_sampler.sh start nextest
|
||||||
# only after `timeout` has already TERM'd the whole cargo process
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
# group, so it cannot name a wedged process. Sample system and
|
|
||||||
# process state every 60s instead; the last samples before the
|
|
||||||
# timeout show what was stuck (rustc, linker, build script, memory
|
|
||||||
# pressure, ...). The log rides along in the existing artifact.
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
{
|
|
||||||
echo "=== $(date --utc --iso-8601=seconds)"
|
|
||||||
echo "--- load"; cat /proc/loadavg
|
|
||||||
echo "--- psi"; grep -H . /proc/pressure/* 2>/dev/null || true
|
|
||||||
echo "--- mem"; free -m
|
|
||||||
echo "--- disk"; df -h / /home/runner 2>/dev/null || df -h /
|
|
||||||
echo "--- top-rss"
|
|
||||||
ps -eo pid,ppid,stat,etime,rss,pcpu,args --sort=-rss | head -15
|
|
||||||
echo "--- build/test processes"
|
|
||||||
ps -eo pid,ppid,stat,etime,rss,pcpu,args | grep -E '[c]argo|[r]ustc|[n]extest|[c]ollect2|rust-ll[d]|[b]uild-script|deps[/]' || true
|
|
||||||
echo "--- d-state (uninterruptible IO)"
|
|
||||||
ps -eo pid,stat,etime,args | awk 'NR > 1 && $2 ~ /D/' || true
|
|
||||||
echo
|
|
||||||
} >> artifacts/test-and-lint/sampler.log 2>&1 || true
|
|
||||||
sleep 60
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
sampler_pid=$!
|
|
||||||
trap 'kill "${sampler_pid}" 2>/dev/null || true' EXIT
|
|
||||||
set +e
|
set +e
|
||||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 75m \
|
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 75m \
|
||||||
cargo nextest run --profile ci --all --exclude e2e_test \
|
cargo nextest run --profile ci --all --exclude e2e_test \
|
||||||
@@ -277,6 +324,50 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
|
# Early stop. Once this job has failed the PR cannot merge, so the sibling
|
||||||
|
# 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.
|
||||||
|
- 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
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-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
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -290,6 +381,7 @@ jobs:
|
|||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
@@ -297,14 +389,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-ilm-serial
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||||
@@ -327,6 +421,7 @@ jobs:
|
|||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -334,14 +429,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test-rio-v2
|
cache-shared-key: ci-feat-rio
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Run rio-v2 clippy lints
|
- name: Run rio-v2 clippy lints
|
||||||
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
||||||
@@ -354,10 +451,17 @@ jobs:
|
|||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
# 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.
|
||||||
|
# Everywhere else (main pushes, the merge queue, the weekly schedule) keep
|
||||||
|
# the full signal: there we want to know whether swift AND sftp are broken,
|
||||||
|
# not just whichever failed first. This is the only part of the early-stop
|
||||||
|
# work that also covers fork PRs, since it needs no token.
|
||||||
|
fail-fast: ${{ github.event_name == 'pull_request' }}
|
||||||
matrix:
|
matrix:
|
||||||
features:
|
features:
|
||||||
- name: swift
|
- name: swift
|
||||||
@@ -369,14 +473,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test-${{ matrix.features.name }}
|
cache-shared-key: ci-feat-proto
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Run clippy with ${{ matrix.features.name }}
|
- name: Run clippy with ${{ matrix.features.name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -389,6 +495,7 @@ jobs:
|
|||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -396,14 +503,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-rustfs-debug-binary
|
cache-shared-key: ci-dev
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: 'false'
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary
|
- name: Build debug binary
|
||||||
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
||||||
@@ -419,6 +528,7 @@ jobs:
|
|||||||
build-rustfs-debug-binary-rio-v2:
|
build-rustfs-debug-binary-rio-v2:
|
||||||
name: Build RustFS Debug Binary (rio-v2)
|
name: Build RustFS Debug Binary (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -426,14 +536,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-rustfs-debug-binary-rio-v2
|
cache-shared-key: ci-feat-rio
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: 'false'
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary with rio-v2
|
- name: Build debug binary with rio-v2
|
||||||
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
@@ -448,6 +560,14 @@ jobs:
|
|||||||
|
|
||||||
uring-integration:
|
uring-integration:
|
||||||
name: io_uring Integration (real)
|
name: io_uring Integration (real)
|
||||||
|
# The pull_request trigger includes `closed` purely so the concurrency
|
||||||
|
# group cancels in-flight runs of a closed PR; every other job opts out of
|
||||||
|
# that run with this guard (or is skipped through its `needs` chain). This
|
||||||
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -458,17 +578,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
|
# Keeps its own key rather than joining ci-dev. rust-cache's key is
|
||||||
|
# built from runner.os/arch plus rustc and lockfile fingerprints — it
|
||||||
|
# does NOT include the runner label or image. ubuntu-latest and
|
||||||
|
# sm-standard-4 are therefore indistinguishable to it, so sharing a key
|
||||||
|
# would let two different system images overwrite each other's
|
||||||
|
# artifacts, and would make a 2-core hosted runner unpack ci-dev's ~3GB
|
||||||
|
# instead of this lane's ~1.3GB. cache-warm.yml warms this key on
|
||||||
|
# ubuntu-latest for the same reason.
|
||||||
cache-shared-key: ci-uring
|
cache-shared-key: ci-uring
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
|
||||||
|
|
||||||
# ext4 supports O_DIRECT; the runner's default TMPDIR may sit on tmpfs or
|
# ext4 supports O_DIRECT; the runner's default TMPDIR may sit on tmpfs or
|
||||||
# overlayfs, where open(O_DIRECT) returns EINVAL/EOPNOTSUPP and the native
|
# overlayfs, where open(O_DIRECT) returns EINVAL/EOPNOTSUPP and the native
|
||||||
@@ -495,7 +622,17 @@ jobs:
|
|||||||
RUSTFS_IO_URING_READ_ENABLE: "true"
|
RUSTFS_IO_URING_READ_ENABLE: "true"
|
||||||
RUSTFS_URING_TESTS_MUST_RUN: "1"
|
RUSTFS_URING_TESTS_MUST_RUN: "1"
|
||||||
TMPDIR: /mnt/rustfs-odirect
|
TMPDIR: /mnt/rustfs-odirect
|
||||||
run: cargo test -p rustfs-ecstore uring_ -- --test-threads=1 --nocapture
|
# --lib narrows what gets compiled, not what gets run: every selected
|
||||||
|
# test lives in the lib target. The 7 integration binaries under
|
||||||
|
# crates/ecstore/tests/ each reported "running 0 tests" here, so they
|
||||||
|
# were compiled and linked for nothing.
|
||||||
|
#
|
||||||
|
# The `uring_` filter must stay exactly as it is. libtest matches on
|
||||||
|
# substring, so it also selects names containing `during_` — 6 of the 18
|
||||||
|
# selected tests are such incidental matches. Narrowing the filter to
|
||||||
|
# `io_uring` would silently drop them, which is a coverage change.
|
||||||
|
# scripts/check_uring_lane_lib_only.sh guards the --lib precondition.
|
||||||
|
run: cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture
|
||||||
|
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: End-to-End Tests
|
name: End-to-End Tests
|
||||||
@@ -505,6 +642,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Full setup with dependency caching: the smoke-suite step below
|
# Full setup with dependency caching: the smoke-suite step below
|
||||||
# compiles the e2e_test crate, which pulls in most of the workspace.
|
# compiles the e2e_test crate, which pulls in most of the workspace.
|
||||||
@@ -513,9 +652,9 @@ jobs:
|
|||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# Download after the cache restore so the freshly built binary from the
|
# Download after the cache restore so the freshly built binary from the
|
||||||
# build job always wins over anything restored into target/debug.
|
# build job always wins over anything restored into target/debug.
|
||||||
@@ -528,15 +667,17 @@ jobs:
|
|||||||
- name: Make binary executable
|
- name: Make binary executable
|
||||||
run: chmod +x ./target/debug/rustfs
|
run: chmod +x ./target/debug/rustfs
|
||||||
|
|
||||||
# Guard the security negative-auth smoke subset (backlog#1151 sec-5)
|
# Build the e2e test graph once. The archive is reused by the security
|
||||||
# against a rename or deletion silently dropping it out of the e2e-smoke
|
# count-floor check and the smoke run below, avoiding a second compile of
|
||||||
# filter. The script lists what the profile selects and fails if the count
|
# the same e2e_test target on cold runners (backlog#1645).
|
||||||
# of security auth-rejection tests falls below the committed floor in
|
- name: Archive e2e smoke test binaries
|
||||||
# .config/security-smoke-floor.txt (infra-12 count-floor mechanism). Run
|
env:
|
||||||
# before the smoke suite so a thinned gate fails fast; the `nextest list`
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
# here compiles the e2e_test binaries the run below reuses.
|
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||||
- name: Check security smoke subset count floor
|
run: |
|
||||||
run: ./scripts/check_security_smoke_count.sh check
|
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
|
||||||
|
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
|
||||||
|
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
|
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
|
||||||
# profile.e2e-smoke default-filter in .config/nextest.toml is the single
|
# profile.e2e-smoke default-filter in .config/nextest.toml is the single
|
||||||
@@ -544,7 +685,30 @@ jobs:
|
|||||||
# adding new e2e jobs here. Each test spawns its own rustfs server on a
|
# adding new e2e jobs here. Each test spawns its own rustfs server on a
|
||||||
# random port and reuses the downloaded debug binary above.
|
# random port and reuses the downloaded debug binary above.
|
||||||
- name: Run e2e smoke suite
|
- name: Run e2e smoke suite
|
||||||
run: cargo nextest run --profile e2e-smoke -p e2e_test
|
env:
|
||||||
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||||
|
run: |
|
||||||
|
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||||
|
--status-level all --final-status-level all --failure-output final
|
||||||
|
|
||||||
|
- name: Upload e2e smoke diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-smoke-diagnostics-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/rustfs-e2e-smoke-logs/
|
||||||
|
${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Upload e2e smoke JUnit report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-smoke-junit-${{ github.run_number }}
|
||||||
|
path: target/nextest/e2e-smoke/junit.xml
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Install s3s-e2e test tool
|
- name: Install s3s-e2e test tool
|
||||||
uses: taiki-e/cache-cargo-install-action@7447f04c51f2ba27ca35e7f1e28fab848c5b3ba7 # v2
|
uses: taiki-e/cache-cargo-install-action@7447f04c51f2ba27ca35e7f1e28fab848c5b3ba7 # v2
|
||||||
@@ -589,14 +753,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# Download after the cache restore so the freshly built binary from the
|
# Download after the cache restore so the freshly built binary from the
|
||||||
# build job always wins over anything restored into target/debug.
|
# build job always wins over anything restored into target/debug.
|
||||||
@@ -632,6 +798,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Clean up previous test run
|
- name: Clean up previous test run
|
||||||
run: |
|
run: |
|
||||||
@@ -686,6 +854,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download debug binary
|
- name: Download debug binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -746,12 +916,20 @@ jobs:
|
|||||||
# evaluates ILM within ~2s of the due time, well inside the poll window.
|
# evaluates ILM within ~2s of the due time, well inside the poll window.
|
||||||
s3-lifecycle-behavior-tests:
|
s3-lifecycle-behavior-tests:
|
||||||
name: S3 Lifecycle Behavior Tests
|
name: S3 Lifecycle Behavior Tests
|
||||||
needs: [ build-rustfs-debug-binary ]
|
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
|
||||||
|
# suite is already red this lane cannot tell us anything new, and it holds a
|
||||||
|
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
|
||||||
|
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
|
||||||
|
# already waits on e2e-tests — finishes later anyway, so a green PR's total
|
||||||
|
# wall clock is unchanged.
|
||||||
|
needs: [ build-rustfs-debug-binary, e2e-tests ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download debug binary
|
- name: Download debug binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ on:
|
|||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
|
|
||||||
|
# Least privilege at the top, widened per job below. This workflow runs on
|
||||||
|
# pull_request_target and issue_comment, so it holds full secrets on every fork
|
||||||
|
# PR and on any comment anyone writes — the one place in this repository where a
|
||||||
|
# compromised action would be handed a repo-write token. It does not check out
|
||||||
|
# or execute PR code, so there is no pwn-request path today, but the blast
|
||||||
|
# radius should not depend on that staying true.
|
||||||
|
#
|
||||||
|
# contents: write in particular was never used: the signature records are
|
||||||
|
# written to rustfs/cla through the scoped app token created below, and nothing
|
||||||
|
# here writes to this repository's contents.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
checks: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
||||||
@@ -36,14 +43,26 @@ jobs:
|
|||||||
cancel-closed-pr-runs:
|
cancel-closed-pr-runs:
|
||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
|
||||||
|
# Echoes one line; the run exists only so the concurrency group cancels the
|
||||||
|
# in-flight run of a closed PR.
|
||||||
|
permissions: {}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
cla:
|
cla:
|
||||||
if: ${{ (github.event_name != 'issue_comment' || github.event.issue.pull_request) && (github.event_name != 'pull_request_target' || github.event.action != 'closed') }}
|
if: ${{ (github.event_name != 'issue_comment' || github.event.issue.pull_request) && (github.event_name != 'pull_request_target' || github.event.action != 'closed') }}
|
||||||
|
# checks: write reports the merge-queue check run; pull-requests and issues
|
||||||
|
# let cla-bot comment and label. contents stays read — see the note above.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Report CLA result for merge queue
|
- name: Report CLA result for merge queue
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
|
|||||||
@@ -62,14 +62,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-coverage
|
cache-shared-key: ci-coverage
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
- name: Install cargo-llvm-cov
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||||
@@ -116,6 +118,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ jobs:
|
|||||||
github.event.workflow_run.head_branch != 'main' &&
|
github.event.workflow_run.head_branch != 'main' &&
|
||||||
!contains(github.event.workflow_run.head_branch, '-preview'))
|
!contains(github.event.workflow_run.head_branch, '-preview'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
should_build: ${{ steps.check.outputs.should_build }}
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
should_push: ${{ steps.check.outputs.should_push }}
|
should_push: ${{ steps.check.outputs.should_push }}
|
||||||
@@ -97,11 +98,18 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
# For workflow_run events, checkout the specific commit that triggered the workflow
|
# For workflow_run events, checkout the specific commit that triggered the workflow
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check build conditions
|
- name: Check build conditions
|
||||||
id: check
|
id: check
|
||||||
|
env:
|
||||||
|
# dispatch inputs via env, not `${{ }}` interpolation: they are
|
||||||
|
# free-form strings and would otherwise be evaluated by bash.
|
||||||
|
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
INPUT_PUSH_IMAGES: ${{ github.event.inputs.push_images }}
|
||||||
|
INPUT_FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }}
|
||||||
run: |
|
run: |
|
||||||
should_build=false
|
should_build=false
|
||||||
should_push=false
|
should_push=false
|
||||||
@@ -202,9 +210,9 @@ jobs:
|
|||||||
|
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
# Manual trigger
|
# Manual trigger
|
||||||
input_version="${{ github.event.inputs.version }}"
|
input_version="$INPUT_VERSION"
|
||||||
version="${input_version}"
|
version="${input_version}"
|
||||||
should_push="${{ github.event.inputs.push_images }}"
|
should_push="$INPUT_PUSH_IMAGES"
|
||||||
should_build=true
|
should_build=true
|
||||||
|
|
||||||
# Get short SHA
|
# Get short SHA
|
||||||
@@ -212,7 +220,7 @@ jobs:
|
|||||||
|
|
||||||
echo "🎯 Manual Docker build triggered:"
|
echo "🎯 Manual Docker build triggered:"
|
||||||
echo " 📋 Requested version: $input_version"
|
echo " 📋 Requested version: $input_version"
|
||||||
echo " 🔧 Force rebuild: ${{ github.event.inputs.force_rebuild }}"
|
echo " 🔧 Force rebuild: $INPUT_FORCE_REBUILD"
|
||||||
echo " 🚀 Push images: $should_push"
|
echo " 🚀 Push images: $should_push"
|
||||||
|
|
||||||
case "$input_version" in
|
case "$input_version" in
|
||||||
@@ -298,6 +306,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
@@ -333,32 +343,28 @@ jobs:
|
|||||||
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
||||||
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
||||||
|
|
||||||
# Convert version format for Dockerfile compatibility
|
# Convert version format for Dockerfile compatibility. The former
|
||||||
|
# DOCKER_CHANNEL was "release" down every branch and was passed as a
|
||||||
|
# build-arg no Dockerfile declares, so it is gone.
|
||||||
case "$VERSION" in
|
case "$VERSION" in
|
||||||
"latest")
|
"latest")
|
||||||
# For stable latest, use RELEASE=latest + release CHANNEL
|
|
||||||
DOCKER_RELEASE="latest"
|
DOCKER_RELEASE="latest"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
v*)
|
v*)
|
||||||
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
|
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
|
||||||
DOCKER_RELEASE="${VERSION#v}"
|
DOCKER_RELEASE="${VERSION#v}"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
# For other versions, pass as-is
|
# For other versions, pass as-is
|
||||||
DOCKER_RELEASE="${VERSION}"
|
DOCKER_RELEASE="${VERSION}"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
|
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
|
||||||
echo "docker_channel=$DOCKER_CHANNEL" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "🐳 Docker build parameters:"
|
echo "🐳 Docker build parameters:"
|
||||||
echo " - Original version: $VERSION"
|
echo " - Original version: $VERSION"
|
||||||
echo " - Docker RELEASE: $DOCKER_RELEASE"
|
echo " - Docker RELEASE: $DOCKER_RELEASE"
|
||||||
echo " - Docker CHANNEL: $DOCKER_CHANNEL"
|
|
||||||
|
|
||||||
# Generate tags based on build type
|
# Generate tags based on build type
|
||||||
# Only support release and prerelease builds (no development builds)
|
# Only support release and prerelease builds (no development builds)
|
||||||
@@ -412,18 +418,24 @@ jobs:
|
|||||||
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: |
|
# No layer cache. This build compiles nothing — it downloads a
|
||||||
type=gha,scope=docker-${{ matrix.variant }}
|
# release zip and runs apk/apt — so the cache could only save the
|
||||||
cache-to: |
|
# minute or two those take, while creating a correctness problem: with
|
||||||
type=gha,mode=max,scope=docker-${{ matrix.variant }}
|
# RELEASE=latest the binary URL is resolved by curl *inside* a RUN
|
||||||
|
# layer, and the layer key does not include what that resolved to. A
|
||||||
|
# rebuild at the same RELEASE value (dispatch with version=latest, or
|
||||||
|
# a re-run of the same version) would hit the old layer and ship the
|
||||||
|
# previous release's binary. mode=max also consumed the same 10GB
|
||||||
|
# Actions cache quota the Rust lanes are fighting over.
|
||||||
|
#
|
||||||
|
# Only RELEASE is passed: it is the sole build-arg the Dockerfiles
|
||||||
|
# declare besides TARGETARCH. BUILDTIME, VERSION, BUILD_TYPE, REVISION
|
||||||
|
# and CHANNEL were never read by any stage (and BUILDTIME's $(date ...)
|
||||||
|
# was a literal here, not a shell substitution). BUILD_DATE and VCS_REF
|
||||||
|
# are declared by the Dockerfiles but deliberately left unset —
|
||||||
|
# supplying them would change the published image labels.
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILDTIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
VERSION=${{ needs.build-check.outputs.version }}
|
|
||||||
BUILD_TYPE=${{ needs.build-check.outputs.build_type }}
|
|
||||||
REVISION=${{ github.sha }}
|
|
||||||
RELEASE=${{ steps.meta.outputs.docker_release }}
|
RELEASE=${{ steps.meta.outputs.docker_release }}
|
||||||
CHANNEL=${{ steps.meta.outputs.docker_channel }}
|
|
||||||
BUILDKIT_INLINE_CACHE=1
|
|
||||||
provenance: true
|
provenance: true
|
||||||
sbom: true
|
sbom: true
|
||||||
# Add retry mechanism by splitting the build process
|
# Add retry mechanism by splitting the build process
|
||||||
@@ -439,6 +451,7 @@ jobs:
|
|||||||
needs: [ build-check, build-docker ]
|
needs: [ build-check, build-docker ]
|
||||||
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
@@ -493,6 +506,7 @@ jobs:
|
|||||||
needs: [ build-check, build-docker ]
|
needs: [ build-check, build-docker ]
|
||||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Docker build completion summary
|
- name: Docker build completion summary
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -64,14 +64,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e-repl
|
cache-shared-key: ci-e2e-repl
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
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
|
# awscurl lets the STS dual-node test actually exercise its path. Without
|
||||||
# it the test skips gracefully with a visible log line
|
# it the test skips gracefully with a visible log line
|
||||||
@@ -124,6 +126,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
||||||
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
||||||
|
|
||||||
|
# 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: e2e-s3tests
|
name: e2e-s3tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -135,6 +142,8 @@ jobs:
|
|||||||
TEST_MODE: ${{ matrix.test-mode }}
|
TEST_MODE: ${{ matrix.test-mode }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Provision Python explicitly rather than trusting the runner image to
|
# Provision Python explicitly rather than trusting the runner image to
|
||||||
# ship a working pip (ci-1: a bare python3 without pip is what broke the
|
# ship a working pip (ci-1: a bare python3 without pip is what broke the
|
||||||
@@ -354,6 +363,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 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: Fuzz
|
name: Fuzz
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -59,6 +66,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -79,13 +87,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: nightly
|
rust-version: nightly
|
||||||
cache-shared-key: fuzz-${{ hashFiles('fuzz/Cargo.lock') }}
|
cache-shared-key: fuzz-${{ hashFiles('fuzz/Cargo.lock') }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
||||||
|
|
||||||
- name: Install cargo-fuzz
|
- name: Install cargo-fuzz
|
||||||
@@ -145,6 +154,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download prebuilt fuzz binaries
|
- name: Download prebuilt fuzz binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -200,6 +211,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download prebuilt fuzz binaries
|
- name: Download prebuilt fuzz binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -247,6 +260,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build-helm-package:
|
build-helm-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
if: |
|
if: |
|
||||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||||
(
|
(
|
||||||
@@ -49,16 +50,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout helm chart repo
|
- name: Checkout helm chart repo
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Both inputs reach the shell through env rather than `${{ }}`
|
||||||
|
# interpolation. A git ref name may contain `$(...)` — anything without a
|
||||||
|
# space is a legal tag — and interpolation pastes it into the script
|
||||||
|
# verbatim, where bash would run it. Reading "$RAW_INPUT" instead makes it
|
||||||
|
# data.
|
||||||
- name: Normalize release version
|
- name: Normalize release version
|
||||||
id: version
|
id: version
|
||||||
|
env:
|
||||||
|
RAW_INPUT: ${{ github.event.inputs.version }}
|
||||||
|
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
RAW="${{ github.event.inputs.version }}"
|
RAW="$RAW_INPUT"
|
||||||
else
|
else
|
||||||
RAW="${{ github.event.workflow_run.head_branch }}"
|
RAW="$RAW_BRANCH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$RAW" in
|
case "$RAW" in
|
||||||
@@ -73,10 +84,13 @@ jobs:
|
|||||||
./scripts/helm_chart_version.sh "$RAW_TAG"
|
./scripts/helm_chart_version.sh "$RAW_TAG"
|
||||||
|
|
||||||
- name: Replace chart version and app version
|
- name: Replace chart version and app version
|
||||||
|
env:
|
||||||
|
CHART_VERSION: ${{ steps.version.outputs.chart_version }}
|
||||||
|
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
sed -i -E 's/^version:.*/version: "${{ steps.version.outputs.chart_version }}"/' helm/rustfs/Chart.yaml
|
sed -i -E "s/^version:.*/version: \"${CHART_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||||
sed -i -E 's/^appVersion:.*/appVersion: "${{ steps.version.outputs.app_version }}"/' helm/rustfs/Chart.yaml
|
sed -i -E "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||||
@@ -101,6 +115,7 @@ jobs:
|
|||||||
|
|
||||||
publish-helm-package:
|
publish-helm-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
needs: [ build-helm-package ]
|
needs: [ build-helm-package ]
|
||||||
if: needs.build-helm-package.result == 'success'
|
if: needs.build-helm-package.result == 'success'
|
||||||
|
|
||||||
@@ -108,6 +123,8 @@ jobs:
|
|||||||
- name: Checkout helm package repo
|
- name: Checkout helm package repo
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
# persist-credentials-exempt: this checkout's token IS the push credential —
|
||||||
|
# the job git-pushes to rustfs/helm below. Clearing it breaks chart publishing.
|
||||||
repository: rustfs/helm
|
repository: rustfs/helm
|
||||||
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
||||||
|
|
||||||
@@ -123,11 +140,19 @@ jobs:
|
|||||||
- name: Generate index
|
- name: Generate index
|
||||||
run: helm repo index . --url https://charts.rustfs.com
|
run: helm repo index . --url https://charts.rustfs.com
|
||||||
|
|
||||||
|
# app_version is derived from the triggering tag name, and this job holds
|
||||||
|
# the cross-repository push token with rustfs/helm already checked out —
|
||||||
|
# the worst place in the repo to paste an attacker-influenced string into
|
||||||
|
# a shell line. Passed through env so bash treats it as data.
|
||||||
- name: Push helm package and index file
|
- name: Push helm package and index file
|
||||||
|
env:
|
||||||
|
GIT_USERNAME: ${{ secrets.USERNAME }}
|
||||||
|
GIT_EMAIL: ${{ secrets.EMAIL_ADDRESS }}
|
||||||
|
APP_VERSION: ${{ needs.build-helm-package.outputs.app_version }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
git config --global user.name "${{ secrets.USERNAME }}"
|
git config --global user.name "${GIT_USERNAME}"
|
||||||
git config --global user.email "${{ secrets.EMAIL_ADDRESS }}"
|
git config --global user.email "${GIT_EMAIL}"
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Update rustfs helm package with ${{ needs.build-helm-package.outputs.app_version }}." || echo "No changes to commit"
|
git commit -m "Update rustfs helm package with ${APP_VERSION}." || echo "No changes to commit"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 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: "issue-translator"
|
name: "issue-translator"
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -26,6 +33,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
|
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -18,11 +18,25 @@
|
|||||||
# This is NOT a PR gate. The fixtures are real MinIO backend trees generated on
|
# This is NOT a PR gate. The fixtures are real MinIO backend trees generated on
|
||||||
# the fly (they are gitignored, never committed), so the job regenerates them
|
# the fly (they are gitignored, never committed), so the job regenerates them
|
||||||
# each run with Docker and then runs the `#[ignore]` reader tests in
|
# each run with Docker and then runs the `#[ignore]` reader tests in
|
||||||
# crates/ecstore/tests/minio_generated_read_test.rs.
|
# rustfs/src/storage/minio_generated_read_test.rs.
|
||||||
|
#
|
||||||
|
# Scope: end-to-end MinIO-to-RustFS SSE interop is NOT implemented yet. Both
|
||||||
|
# envelope parsers reject MinIO's own wrapped-DEK shape — see
|
||||||
|
# `is_data_key_envelope` in crates/kms/src/encryption/dek.rs and the
|
||||||
|
# `deny_unknown_fields` `LocalSseDekEnvelope` in rustfs/src/storage/sse.rs — and
|
||||||
|
# closing that gap is tracked in rustfs/backlog#1638. Treat this job as the
|
||||||
|
# harness for #1638, not as standing evidence that a MinIO migration reads back.
|
||||||
#
|
#
|
||||||
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
|
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
|
||||||
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability
|
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability
|
||||||
# (see the infra note in e2e-s3tests.yml). Nightly + manual only.
|
# (see the infra note in e2e-s3tests.yml). Nightly + manual only.
|
||||||
|
# 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: minio-interop
|
name: minio-interop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -48,23 +62,62 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
# Fixed 32-byte test KMS key baked into the fixture lab; not a secret.
|
# Fixed 32-byte test KMS key baked into the fixture lab; not a secret.
|
||||||
RUSTFS_MINIO_STATIC_KMS_KEY_B64: IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g=
|
RUSTFS_MINIO_STATIC_KMS_KEY_B64: IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g=
|
||||||
|
# Single definition of "the interop tests", shared by the guard step and
|
||||||
|
# the run step so the two cannot drift apart.
|
||||||
|
#
|
||||||
|
# These used to live in crates/ecstore/tests/minio_generated_read_test.rs
|
||||||
|
# and were selected with `-p rustfs-ecstore -E
|
||||||
|
# 'binary(minio_generated_read_test)'`. #5435 moved them into the `rustfs`
|
||||||
|
# crate as a `#[cfg(test)] mod`, which deleted that test binary; the
|
||||||
|
# selector was never updated and has selected zero interop tests ever
|
||||||
|
# since (cargo-nextest 0.9.140 now rejects it outright: "operator didn't
|
||||||
|
# match any binary names", exit 94).
|
||||||
|
INTEROP_PACKAGE: rustfs
|
||||||
|
INTEROP_FEATURES: rio-v2
|
||||||
|
INTEROP_FILTER: "test(minio_generated_read_test::)"
|
||||||
|
INTEROP_REQUIRED_TESTS: '["reads_minio_generated_sse_s3_multipart_fixture", "reads_minio_generated_sse_kms_multipart_fixture", "rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key", "rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext"]'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-minio-interop
|
cache-shared-key: ci-minio-interop
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Generate real MinIO fixtures via Docker
|
- name: Generate real MinIO fixtures via Docker
|
||||||
run: bash crates/rio-v2/tests/minio_fixture_lab/capture_via_docker.sh
|
run: bash crates/rio-v2/tests/minio_fixture_lab/capture_via_docker.sh
|
||||||
|
|
||||||
|
# `binary(...)` at least dies loudly when nothing matches, but `test(...)`
|
||||||
|
# is a perfectly valid filterset that matches zero tests, so the next
|
||||||
|
# rename or module move would leave this job selecting nothing and
|
||||||
|
# reporting success without executing a single interop assertion. Count
|
||||||
|
# the selection and require every core reader test, while allowing new
|
||||||
|
# reader cases to be added without changing this guard.
|
||||||
|
#
|
||||||
|
# Count only `filter-match.status == "matches"`: the top-level
|
||||||
|
# `test-count` in the JSON is the package total and ignores `-E` entirely.
|
||||||
|
- name: Assert the interop selector still matches tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
selection="$(cargo nextest list --run-ignored ignored-only \
|
||||||
|
-p "$INTEROP_PACKAGE" --features "$INTEROP_FEATURES" \
|
||||||
|
-E "$INTEROP_FILTER" --message-format json \
|
||||||
|
| python3 -c 'import json,os,sys; d=json.load(sys.stdin); required=json.loads(os.environ["INTEROP_REQUIRED_TESTS"]); matched=[name for suite in d.get("rust-suites", {}).values() for name,test in suite.get("testcases", {}).items() if test.get("filter-match", {}).get("status") == "matches"]; missing=[test for test in required if not any(name.endswith("minio_generated_read_test::" + test) for name in matched)]; print(len(matched)); print(",".join(missing))')"
|
||||||
|
count="$(printf '%s\n' "$selection" | sed -n '1p')"
|
||||||
|
missing="$(printf '%s\n' "$selection" | sed -n '2p')"
|
||||||
|
echo "interop tests selected: ${count}"
|
||||||
|
if [ -n "${missing}" ]; then
|
||||||
|
echo "::error::Selector '${INTEROP_FILTER}' in package '${INTEROP_PACKAGE}' is missing required tests: ${missing}. The MinIO interop reader tests have moved or been renamed; fix the selector instead of running an incomplete matrix. Context: rustfs/backlog#1638."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run MinIO interop reader tests
|
- name: Run MinIO interop reader tests
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --run-ignored ignored-only \
|
cargo nextest run --run-ignored ignored-only --no-tests=fail \
|
||||||
-p rustfs-ecstore --features rio-v2 \
|
-p "$INTEROP_PACKAGE" --features "$INTEROP_FEATURES" \
|
||||||
-E 'binary(minio_generated_read_test)'
|
-E "$INTEROP_FILTER"
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
# 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.
|
# 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
|
name: mint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -118,6 +125,8 @@ jobs:
|
|||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Enable buildx
|
- name: Enable buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
@@ -263,6 +272,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -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: Nightly GNU Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: nightly-gnu-build-main-${{ github.event_name }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build x86_64 GNU
|
||||||
|
runs-on: sm-standard-2
|
||||||
|
timeout-minutes: 150
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout main branch
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: build-x86_64-unknown-linux-gnu
|
||||||
|
cache-save-if: 'false'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
|
- name: Build RustFS
|
||||||
|
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||||
@@ -19,9 +19,12 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 0' # Weekly on Sunday 05:00 UTC (staggered after the midnight ci/build crons)
|
- cron: '0 5 * * 0' # Weekly on Sunday 05:00 UTC (staggered after the midnight ci/build crons)
|
||||||
|
|
||||||
|
# GITHUB_TOKEN only needs to read the repository here: the branch push and the
|
||||||
|
# pull request are both created by update-flake-lock using the
|
||||||
|
# FLAKE_UPDATE_TOKEN PAT below, not by this token. Leaving write on it hands a
|
||||||
|
# repo-write credential to an unattended weekly job that does not use it.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -37,6 +40,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
# persist-credentials-exempt: update-flake-lock pushes the branch and opens
|
||||||
|
# the PR. It passes FLAKE_UPDATE_TOKEN to create-pull-request itself rather
|
||||||
|
# than reusing .git/config, but that is unverified — exempt until a
|
||||||
|
# workflow_dispatch run confirms it (rustfs/backlog#1602).
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/determinate-nix-action@629b284231c2a82554b724e357e47fc6020833c8 # v3
|
uses: DeterminateSystems/determinate-nix-action@629b284231c2a82554b724e357e47fc6020833c8 # v3
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 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: Nix CI
|
name: Nix CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -46,6 +53,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -63,6 +71,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/determinate-nix-action@4eea0b33e3d1f02ecfe37cf16e7204c424009606 # v3.21.0
|
uses: DeterminateSystems/determinate-nix-action@4eea0b33e3d1f02ecfe37cf16e7204c424009606 # v3.21.0
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
||||||
# recorded but does not block (rustfs/backlog#935 correction 1).
|
# recorded but does not block (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
|
name: Performance A/B
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -92,6 +99,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -99,7 +108,6 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build release rustfs
|
- name: Build release rustfs
|
||||||
run: cargo build --release --bin rustfs
|
run: cargo build --release --bin rustfs
|
||||||
@@ -142,6 +150,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0 # baseline is built from origin/main
|
fetch-depth: 0 # baseline is built from origin/main
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -150,7 +159,6 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install warp
|
- name: Install warp
|
||||||
run: |
|
run: |
|
||||||
@@ -162,13 +170,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Decide exemption
|
- name: Decide exemption
|
||||||
id: exempt
|
id: exempt
|
||||||
|
env:
|
||||||
|
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||||
run: |
|
run: |
|
||||||
allow="false"
|
allow="false"
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
||||||
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
||||||
allow="true"
|
allow="true"
|
||||||
fi
|
fi
|
||||||
if [[ "${{ github.event.inputs.allow_regression }}" == "true" ]]; then
|
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||||
allow="true"
|
allow="true"
|
||||||
fi
|
fi
|
||||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||||
@@ -215,8 +225,34 @@ jobs:
|
|||||||
cp target/release/rustfs baseline-bin/rustfs
|
cp target/release/rustfs baseline-bin/rustfs
|
||||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build baseline on cache miss (different candidate)
|
||||||
|
id: baseline_build
|
||||||
|
if: >-
|
||||||
|
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
||||||
|
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
|
||||||
|
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
|
||||||
|
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
|
||||||
|
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
|
||||||
|
mkdir -p baseline-bin
|
||||||
|
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
|
||||||
|
git worktree remove --force "$baseline_root"
|
||||||
|
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build candidate binary
|
||||||
|
id: candidate_build
|
||||||
|
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cargo build --release --bin rustfs
|
||||||
|
mkdir -p candidate-bin
|
||||||
|
cp target/release/rustfs candidate-bin/rustfs
|
||||||
|
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Save self-healed baseline to cache
|
- name: Save self-healed baseline to cache
|
||||||
if: steps.selfheal.outputs.built == 'true'
|
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
|
||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||||
with:
|
with:
|
||||||
path: baseline-bin/rustfs
|
path: baseline-bin/rustfs
|
||||||
@@ -224,54 +260,58 @@ jobs:
|
|||||||
|
|
||||||
- name: Run warp A/B and gate
|
- name: Run warp A/B and gate
|
||||||
id: ab
|
id: ab
|
||||||
|
env:
|
||||||
|
INPUT_DURATION: ${{ github.event.inputs.duration }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Budget note: with perf-3's cached baseline the nightly does no source
|
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
|
||||||
# build on a cache hit, so the wall-clock is dominated by the short warp
|
# -> A2 baseline for each workload and drive-sync cell. It requires three
|
||||||
# matrix — duration/rounds/cooldown are kept small to fit all 24 cells
|
# rounds per leg to emit tail latency and error-rate evidence.
|
||||||
# (6 workloads x 2 phases x 2 drive-sync) rather than dropping cells.
|
|
||||||
# --health-timeout 180 outlasts the server's own 120s startup-readiness
|
# --health-timeout 180 outlasts the server's own 120s startup-readiness
|
||||||
# budget, which the rig's previous 60s health poll undershot (the first
|
# budget, which the rig's previous 60s health poll undershot (the first
|
||||||
# two nightly failures). perf-6 recalibrates these once the noise study
|
# two nightly failures). perf-6 recalibrates these once the noise study
|
||||||
# lands.
|
# lands.
|
||||||
duration="${{ github.event.inputs.duration || '12s' }}"
|
duration="${INPUT_DURATION:-12s}"
|
||||||
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
||||||
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
||||||
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
||||||
selfheal_built="${{ steps.selfheal.outputs.built }}"
|
selfheal_built="${{ steps.selfheal.outputs.built }}"
|
||||||
|
baseline_built="${{ steps.baseline_build.outputs.built }}"
|
||||||
|
candidate_built="${{ steps.candidate_build.outputs.built }}"
|
||||||
|
|
||||||
args=(--duration "$duration" --rounds 2 --cooldown 5 --health-timeout 180)
|
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
|
||||||
|
|
||||||
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" ]]; then
|
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
|
||||||
chmod +x baseline-bin/rustfs
|
chmod +x baseline-bin/rustfs
|
||||||
base_bin="$PWD/baseline-bin/rustfs"
|
base_bin="$PWD/baseline-bin/rustfs"
|
||||||
args+=(--baseline-bin "$base_bin")
|
args+=(--baseline-bin "$base_bin")
|
||||||
if [[ "$baseline_hit" == "true" ]]; then
|
if [[ "$baseline_hit" == "true" ]]; then
|
||||||
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
|
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
|
||||||
else
|
elif [[ "$selfheal_built" == "true" ]]; then
|
||||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
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)"
|
||||||
fi
|
fi
|
||||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
||||||
# Nightly on main: the candidate is the same commit as the baseline,
|
# Nightly on main: the candidate is the same commit as the baseline,
|
||||||
# so reuse the one binary for both phases and skip all builds.
|
# so reuse the one binary for both phases and skip all builds.
|
||||||
args+=(--candidate-bin "$base_bin" --skip-build)
|
args+=(--candidate-bin "$base_bin")
|
||||||
cand_src="same binary as baseline (same commit)"
|
cand_src="same binary as baseline (same commit)"
|
||||||
else
|
elif [[ "$candidate_built" == "true" ]]; then
|
||||||
|
chmod +x candidate-bin/rustfs
|
||||||
|
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
|
||||||
cand_src="source build of the checked-out ref"
|
cand_src="source build of the checked-out ref"
|
||||||
|
else
|
||||||
|
echo "::error::candidate binary was not built" >&2
|
||||||
|
exit 2
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Cache miss with candidate != baseline (opt-in PR gate only): fall
|
echo "::error::baseline binary was not restored or built" >&2
|
||||||
# back to the source double-build. With the post-#4806 LTO profile
|
exit 2
|
||||||
# this will overrun the job budget and alert; rerun once the push
|
|
||||||
# cache build for origin/main has completed, or wait for perf-7's
|
|
||||||
# merge-base caching.
|
|
||||||
args+=(--baseline-ref origin/main)
|
|
||||||
base_src="source build of origin/main (cache miss)"
|
|
||||||
cand_src="source build of the checked-out ref"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
args+=(--provenance-note "baseline commit: $baseline_sha - $base_src")
|
echo "baseline binary: $base_src"
|
||||||
args+=(--provenance-note "candidate commit: $candidate_sha - $cand_src")
|
echo "candidate binary: $cand_src"
|
||||||
|
|
||||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
||||||
args+=(--allow-regression --exemption-reason "labeled perf-deliberate-tradeoff / dispatch override")
|
args+=(--allow-regression --exemption-reason "labeled perf-deliberate-tradeoff / dispatch override")
|
||||||
@@ -279,7 +319,7 @@ jobs:
|
|||||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
# Do not let a gate FAIL abort the job here; capture status and surface
|
||||||
# it after the PR comment is posted.
|
# it after the PR comment is posted.
|
||||||
set +e
|
set +e
|
||||||
bash scripts/run_hotpath_warp_ab.sh "${args[@]}"
|
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
||||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
echo "status=$?" >> "$GITHUB_OUTPUT"
|
||||||
set -e
|
set -e
|
||||||
# Locate the newest run dir + gate.md for the summary/comment/artifact
|
# Locate the newest run dir + gate.md for the summary/comment/artifact
|
||||||
@@ -287,10 +327,10 @@ jobs:
|
|||||||
# holds server-logs/ for diagnosis.
|
# holds server-logs/ for diagnosis.
|
||||||
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
|
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
|
||||||
# shellcheck disable=SC2012
|
# shellcheck disable=SC2012
|
||||||
run_dir="$(ls -td target/hotpath-ab/*/ 2>/dev/null | head -n1 || true)"
|
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
|
||||||
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
|
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
|
||||||
# shellcheck disable=SC2012
|
# shellcheck disable=SC2012
|
||||||
gate_md="$(ls -t target/hotpath-ab/*/gate.md 2>/dev/null | head -n1 || true)"
|
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
|
||||||
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
|
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Upload A/B results
|
- name: Upload A/B results
|
||||||
@@ -298,10 +338,10 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: hotpath-warp-ab-${{ github.run_number }}
|
name: hotpath-warp-ab-${{ github.run_number }}
|
||||||
# Includes per-cell median_summary.csv / baseline_compare.csv, gate.md,
|
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
|
||||||
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
|
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
|
||||||
# is diagnosable. Short retention: this is churny nightly debug data.
|
# is diagnosable. Short retention: this is churny nightly debug data.
|
||||||
path: target/hotpath-ab/
|
path: target/hotpath-abba/
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
@@ -385,6 +425,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Copyright 2026 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.
|
||||||
|
|
||||||
|
# Asserts that the self-hosted runners are still ephemeral — one job per pod.
|
||||||
|
#
|
||||||
|
# This repository is public and its pull_request jobs run on those runners,
|
||||||
|
# executing the PR's own build.rs, proc-macros and tests. The only thing keeping
|
||||||
|
# that code from reaching a later job is that each ARC pod handles exactly one
|
||||||
|
# job and is then destroyed. That guarantee lives in the ARC scale-set
|
||||||
|
# configuration, outside this repository, where it can be changed without any PR
|
||||||
|
# — so it is asserted here from the outside, against real run data, instead of
|
||||||
|
# being assumed.
|
||||||
|
#
|
||||||
|
# Monthly rather than per-PR: the property changes only when someone
|
||||||
|
# reconfigures the scale set, and the check costs a few dozen API calls.
|
||||||
|
# See docs/ci/runners.md and rustfs/backlog#1602.
|
||||||
|
|
||||||
|
name: Runner Hygiene
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 1 * *" # Monthly, 1st at 06:00 UTC (after the daily audit cron)
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: runner-hygiene
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-ephemerality:
|
||||||
|
name: Check runner ephemerality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Exit 2 (inconclusive / broken) is deliberately not a pass: a window
|
||||||
|
# where every sm-* job was still queued would otherwise look identical to
|
||||||
|
# a clean bill of health.
|
||||||
|
- name: Assert one job per self-hosted runner
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./scripts/ci/check_runner_ephemerality.sh 40
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [check-ephemerality]
|
||||||
|
# Same ci-8 mechanism as coverage.yml, audit.yml and the nightly lanes:
|
||||||
|
# scheduled runs file a tracking issue, manual dispatch stays quiet so
|
||||||
|
# debugging never produces a spurious alert.
|
||||||
|
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 }}
|
||||||
@@ -24,6 +24,13 @@
|
|||||||
# The run itself is expected to end red (the forced failure); only the
|
# The run itself is expected to end red (the forced failure); only the
|
||||||
# alert-on-failure job result matters.
|
# alert-on-failure job result matters.
|
||||||
|
|
||||||
|
# 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: Schedule Failure Alert Drill
|
name: Schedule Failure Alert Drill
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -56,6 +63,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 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: "Mark stale issues"
|
name: "Mark stale issues"
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -20,6 +27,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
update:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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: Windows Filesystem Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- "crates/ecstore/src/disk/**"
|
||||||
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
|
- "crates/ecstore/Cargo.toml"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- ".github/actions/setup/**"
|
||||||
|
- ".github/workflows/windows-filesystem.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- "crates/ecstore/src/disk/**"
|
||||||
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
|
- "crates/ecstore/Cargo.toml"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- ".github/actions/setup/**"
|
||||||
|
- ".github/workflows/windows-filesystem.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rename-safety:
|
||||||
|
name: Rename Safety
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
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: build-x86_64-pc-windows-msvc
|
||||||
|
cache-save-if: 'false'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
|
- name: Test guarded rename publication
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
||||||
|
|
||||||
|
- name: Test Windows handle guards
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo test -p rustfs-ecstore --lib windows_ -- --nocapture
|
||||||
|
|
||||||
|
- name: Test startup temporary-directory cleanup
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo test -p rustfs-ecstore --lib cleanup_tmp_on_startup_ -- --nocapture
|
||||||
|
|
||||||
|
- name: Test fresh format publication
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo test -p rustfs-ecstore --lib fresh_format_load_initializes_all_disks -- --nocapture
|
||||||
@@ -21,6 +21,22 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
|||||||
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
||||||
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
||||||
|
|
||||||
|
## Worktree and Disk Hygiene
|
||||||
|
|
||||||
|
- Unless the requester explicitly says otherwise, treat every new implementation task as isolated work: fetch the latest `origin/main`, confirm the requested change is not already present there, and create a dedicated feature branch and worktree from that exact upstream commit before editing. Do not implement new work directly in the primary checkout or reuse a worktree from another task.
|
||||||
|
- Check available disk space before creating the worktree or starting dependency downloads, builds, tests, coverage, or other artifact-heavy commands. For long-running or artifact-heavy work, re-check disk usage at natural phase boundaries and before broad validation; if remaining space may not safely accommodate the next command, stop and reclaim task-owned artifacts before continuing.
|
||||||
|
- Keep cleanup scoped and safe: remove generated build/test/coverage artifacts and temporary files created by the task when they are no longer needed, and never delete another task's worktree or uncommitted files. Prefer shared dependency caches where supported instead of duplicating large artifacts across worktrees.
|
||||||
|
- At handoff, report the disk-space checks, cleanup performed, and any retained worktree or artifacts with the reason they are still needed.
|
||||||
|
|
||||||
|
## PR Lifecycle Monitoring
|
||||||
|
|
||||||
|
- Creating or updating a PR is not the terminal state. Unless the requester explicitly limits the task to PR creation, monitor the PR through its terminal state: merged, closed, or explicitly handed off because progress requires user or maintainer action.
|
||||||
|
- While the task is active, monitor CI/check runs, review decisions and unresolved threads, mergeability and conflicts, and unexpected head/base changes. Prefer event-driven or bounded waits provided by the current environment over frequent polling; report only state changes, actionable failures, or meaningful prolonged delays.
|
||||||
|
- Investigate every failing check and review comment before changing code. Fix failures attributable to the task, run the verification required for the new diff, push the update, respond to or resolve the corresponding review threads, and resume monitoring. Do not weaken checks, dismiss valid feedback, or retry flaky failures merely to obtain a green result.
|
||||||
|
- Treat opening, green CI, approval, and mergeability as intermediate states. Never merge without the required reviewer approval or explicit authority. If progress depends on credentials, infrastructure, a maintainer decision, or another external action, report the exact blocker and the evidence already collected.
|
||||||
|
- If the current execution environment cannot remain active until the next PR event, use a supported automation, monitor, or thread wakeup when available and within scope. Otherwise leave an explicit handoff containing the PR, current state, next event to observe, and pending cleanup; do not imply that background monitoring exists when none is scheduled.
|
||||||
|
- After observing a merge, verify the commits are preserved on the upstream base, ensure the worktree is clean, remove the dedicated worktree, prune stale worktree metadata, and delete the local task branch when it is no longer in use. For a closed or abandoned PR, preserve any unmerged work unless deletion was explicitly authorized. Do not delete remote branches unless explicitly requested or repository automation owns that cleanup.
|
||||||
|
|
||||||
## Autonomy and Approval Boundaries
|
## Autonomy and Approval Boundaries
|
||||||
|
|
||||||
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
||||||
@@ -105,33 +121,78 @@ CI) fails the build if anything is committed under `docs/superpowers/`, even via
|
|||||||
|
|
||||||
## Verification Before PR
|
## Verification Before PR
|
||||||
|
|
||||||
Convert changes into independently verifiable outcomes. Prefer focused tests for behavior changes and run the relevant checks before declaring completion.
|
Convert changes into independently verifiable outcomes. This section controls
|
||||||
Non-exempt changes must also pass Adversarial Validation (next section) before the checks below count as completion.
|
agent-run local validation; preparing a commit or PR does not by itself require
|
||||||
|
the broadest gate. Inspect only the final task-owned diff, classify it by
|
||||||
|
behavioral impact rather than line count or path alone, and run the smallest
|
||||||
|
set of checks that provides meaningful coverage. Do not let unrelated
|
||||||
|
worktree changes or a generic contributor checklist expand the scope.
|
||||||
|
Non-exempt changes must also pass Adversarial Validation (next section) before
|
||||||
|
the checks below count as completion.
|
||||||
|
|
||||||
For code changes, run and pass the following before opening a PR:
|
### Validation floor
|
||||||
|
|
||||||
```bash
|
- Every change that is not documentation-only must finish with
|
||||||
make pre-pr
|
`cargo fmt --all --check` passing. An umbrella gate that runs this exact
|
||||||
```
|
check satisfies the requirement; do not run it twice. Use `cargo fmt --all`
|
||||||
|
only when formatting needs to be fixed. Run the configured formatter or
|
||||||
|
validator for other changed languages when one exists.
|
||||||
|
- Documentation-only or instruction-only means all task-owned changes are
|
||||||
|
prose or documentation assets and cannot affect runtime, builds, CI,
|
||||||
|
dependencies, generated code, or tests. Run `git diff --check` and any
|
||||||
|
relevant documentation guard, but skip Cargo formatting, compilation,
|
||||||
|
Clippy, tests, `make pre-commit`, and `make pre-pr`.
|
||||||
|
- Behavior changes require relevant existing or new tests. Prefer the most
|
||||||
|
focused test or affected package. A passing targeted test can also provide
|
||||||
|
sufficient compilation coverage when it builds every changed target and
|
||||||
|
feature involved; do not add a redundant `cargo check` in that case.
|
||||||
|
- `cargo check` supplements compilation coverage; it never substitutes for a
|
||||||
|
behavioral test. If a relevant test cannot reasonably be added or run, use
|
||||||
|
the narrowest compilation check and report the reason and remaining risk.
|
||||||
|
|
||||||
Before committing code changes, prefer focused verification for the touched
|
### Validation tiers
|
||||||
surface and use the faster local gate when a broad smoke check is needed:
|
|
||||||
|
|
||||||
```bash
|
1. **Documentation/instruction-only:** Apply the exemption above. Run a guard
|
||||||
make pre-commit
|
such as `make doc-paths-check` only when it is relevant to the edited text.
|
||||||
```
|
2. **Non-behavioral source change:** For comments, formatting, or another
|
||||||
|
demonstrably non-executable change, run the formatting floor. Compilation,
|
||||||
|
Clippy, and tests may be skipped only when the edit cannot affect
|
||||||
|
compilation or runtime behavior; run targeted doctests if executable
|
||||||
|
documentation examples changed.
|
||||||
|
3. **Localized or bounded behavior change:** Run the formatting floor and the
|
||||||
|
narrowest relevant tests. Add package-scoped `cargo check` or Clippy only
|
||||||
|
for changed targets, features, APIs, error handling, async behavior, or
|
||||||
|
control flow not already covered. When several crates are affected but the
|
||||||
|
dependency set is identifiable, validate those packages and known
|
||||||
|
dependents instead of the whole workspace. Use `make pre-commit` only when
|
||||||
|
a repository-wide fast gate adds useful confidence beyond those checks.
|
||||||
|
4. **Broad or high-risk change:** Run `make pre-pr` only when targeted coverage
|
||||||
|
cannot bound the impact, including:
|
||||||
|
- dependency, feature, build-script, procedural-macro, code-generation,
|
||||||
|
toolchain, or CI changes that alter compilation or the test matrix;
|
||||||
|
- cross-crate public APIs, shared foundational code, or broad refactors with
|
||||||
|
an unbounded dependent set;
|
||||||
|
- locking, storage durability or formats, erasure coding, replication,
|
||||||
|
RPC/protocol compatibility, IAM/KMS/auth, cryptography, or other
|
||||||
|
security-sensitive behavior;
|
||||||
|
- a targeted check that reveals wider impact, an explicit user request, or
|
||||||
|
a release policy that requires the full gate.
|
||||||
|
|
||||||
For migration batches, do not run the full `make pre-pr` gate before every
|
Documentation-only and non-behavioral classifications take precedence over
|
||||||
intermediate commit. Use focused tests and `make pre-commit` during
|
path-based triggers. A small diff can still be high-risk, while a CI comment,
|
||||||
development, then reserve `make pre-pr` for the final PR-ready branch.
|
manifest comment, or release-note edit does not require full validation.
|
||||||
|
|
||||||
Before pushing code changes, make sure formatting is clean:
|
`make pre-pr` includes `make pre-commit` coverage. Never run both for the same
|
||||||
|
unchanged diff, and do not repeat equivalent checks during PR preparation or
|
||||||
|
because a local hook already ran them. Rerun only checks whose scope is affected
|
||||||
|
by later edits. Full workspace checks do not replace a relevant integration or
|
||||||
|
E2E test for changed behavior; run that focused test when required and
|
||||||
|
available, or report why it was not run and the remaining risk.
|
||||||
|
|
||||||
- Run `cargo fmt --all`.
|
If `make` is unavailable, run the equivalent checks defined under
|
||||||
- Run `cargo fmt --all --check` and ensure no files are modified unexpectedly.
|
`.config/make/`. At handoff, list the checks actually run, checks intentionally
|
||||||
|
skipped, and the reason for the selected tier.
|
||||||
|
|
||||||
If `make` is unavailable, run the equivalent checks defined under `.config/make/`.
|
|
||||||
Documentation-only or instruction-only changes are exempt from the verification commands above (including the `.config/make/` equivalents), though any locally installed git pre-commit hooks may still run on commit unless explicitly skipped.
|
|
||||||
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
||||||
Do not open a PR with code changes when the required checks fail.
|
Do not open a PR with code changes when the required checks fail.
|
||||||
Make a failing check pass by fixing the cause, never by weakening the gate:
|
Make a failing check pass by fixing the cause, never by weakening the gate:
|
||||||
@@ -286,6 +347,11 @@ cargo run -p rustfs-filemeta --example dump_fileinfo -- "/path/to/file/xl.meta"
|
|||||||
absent, empty, and nil all mean "no value", never `Uuid::nil()`.
|
absent, empty, and nil all mean "no value", never `Uuid::nil()`.
|
||||||
- A remote-tier version of `None`/`""` means the tier bucket is unversioned:
|
- A remote-tier version of `None`/`""` means the tier bucket is unversioned:
|
||||||
send **no** `versionId` on tier GET/DELETE.
|
send **no** `versionId` on tier GET/DELETE.
|
||||||
|
- Structs persisted in the scanner data-usage cache (`DataUsageCacheInfo`,
|
||||||
|
`DataUsageEntry`) carry a hand-written map-encoded `Serialize`. MessagePack
|
||||||
|
encodes derived structs as arrays, where an appended field makes the whole
|
||||||
|
cache a decode error for older readers — keep new fields `#[serde(default)]`
|
||||||
|
and keep the map encoding rather than reverting to `derive(Serialize)`.
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
|
|
||||||
|
|||||||
Generated
+170
-53
@@ -290,11 +290,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ar_archive_writer"
|
name = "ar_archive_writer"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348"
|
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"object 0.37.3",
|
"object 0.39.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -599,6 +599,16 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "astral-tokio-tar"
|
name = "astral-tokio-tar"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -943,6 +953,32 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-sdk-kms"
|
||||||
|
version = "1.114.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0b7d906608ee41e7ddea9983577ba82200435644d567d63dc34e822e088b453"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"aws-credential-types",
|
||||||
|
"aws-runtime",
|
||||||
|
"aws-smithy-async",
|
||||||
|
"aws-smithy-http",
|
||||||
|
"aws-smithy-json",
|
||||||
|
"aws-smithy-observability",
|
||||||
|
"aws-smithy-runtime",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"aws-smithy-schema",
|
||||||
|
"aws-smithy-types",
|
||||||
|
"aws-types",
|
||||||
|
"bytes",
|
||||||
|
"fastrand",
|
||||||
|
"http 0.2.12",
|
||||||
|
"http 1.5.0",
|
||||||
|
"regex-lite",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-sdk-s3"
|
name = "aws-sdk-s3"
|
||||||
version = "1.140.0"
|
version = "1.140.0"
|
||||||
@@ -1158,17 +1194,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-smithy-async",
|
"aws-smithy-async",
|
||||||
|
"aws-smithy-protocol-test",
|
||||||
"aws-smithy-runtime-api",
|
"aws-smithy-runtime-api",
|
||||||
"aws-smithy-types",
|
"aws-smithy-types",
|
||||||
|
"bytes",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
|
"http-body 1.1.0",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"indexmap 2.14.0",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -1195,6 +1237,25 @@ dependencies = [
|
|||||||
"aws-smithy-runtime-api",
|
"aws-smithy-runtime-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-smithy-protocol-test"
|
||||||
|
version = "0.64.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f76511a0e223ce78deb6a78b8afebda99cb737cfbc8a58d96dcb190f012dd40a"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"base64-simd",
|
||||||
|
"cbor-diag",
|
||||||
|
"ciborium",
|
||||||
|
"http 0.2.12",
|
||||||
|
"pretty_assertions",
|
||||||
|
"regex-lite",
|
||||||
|
"roxmltree",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-smithy-query"
|
name = "aws-smithy-query"
|
||||||
version = "0.62.0"
|
version = "0.62.0"
|
||||||
@@ -1528,7 +1589,7 @@ version = "0.10.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1547,7 +1608,7 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1598,7 +1659,7 @@ version = "3.9.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
|
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.20.11",
|
"darling 0.23.0",
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -1679,9 +1740,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytesize"
|
name = "bytesize"
|
||||||
version = "2.4.2"
|
version = "2.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e"
|
checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytestring"
|
name = "bytestring"
|
||||||
@@ -1758,6 +1819,25 @@ dependencies = [
|
|||||||
"cipher 0.5.2",
|
"cipher 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbor-diag"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
|
||||||
|
dependencies = [
|
||||||
|
"bs58",
|
||||||
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
|
"half",
|
||||||
|
"nom 7.1.3",
|
||||||
|
"num-bigint",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"separator",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -1870,7 +1950,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.6",
|
||||||
"inout 0.1.4",
|
"inout 0.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1888,9 +1968,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.4"
|
version = "4.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -1898,9 +1978,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.2"
|
version = "4.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -2328,7 +2408,7 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -2353,11 +2433,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3554,7 +3634,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"const-oid 0.9.6",
|
"const-oid 0.9.6",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.6",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3814,7 +3894,7 @@ dependencies = [
|
|||||||
"crypto-bigint 0.5.5",
|
"crypto-bigint 0.5.5",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"ff 0.13.1",
|
"ff 0.13.1",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"group 0.13.0",
|
"group 0.13.0",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"pem-rfc7468 0.7.0",
|
"pem-rfc7468 0.7.0",
|
||||||
@@ -4259,9 +4339,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -4274,7 +4354,7 @@ version = "1.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
|
checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
@@ -4912,9 +4992,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath"
|
name = "hotpath"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66750a77f4f6b408a148be5102ef1f3ba7172def7ee92b1cfc75d9f7a3870453"
|
checksum = "ab303f15e2bbd9633a577338c9813a86bc1aef74beb8b536e27f28c80e84befc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -4946,9 +5026,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-macros"
|
name = "hotpath-macros"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "afe0e1900d2dbe2e2df8e9522b97ebd7a5598ba18478f57e247957022dffedbe"
|
checksum = "4777d4dd3474c9b9c9391be713c6b570da0ac49e992a8cbfed67f60ca0f7e33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4957,15 +5037,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-macros-meta"
|
name = "hotpath-macros-meta"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21f2f70b29b6f42311acd2fb0b2f91a34d9a573c76fb8a7f51970670a1673a49"
|
checksum = "833200923e0ba8150fb91d6a3a39643eef95e604c7394c2f2679905cae13862c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-meta"
|
name = "hotpath-meta"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b771f77b2f409086bb40ff029f850ce99d17118d4e207df0f28cb32bd7568c7"
|
checksum = "eca34dbbafc05f5da2a2696ce2640018fbe29e9932736309efbf3a990f0b2832"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hotpath-macros-meta",
|
"hotpath-macros-meta",
|
||||||
]
|
]
|
||||||
@@ -5301,7 +5381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-padding 0.3.3",
|
"block-padding 0.3.3",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5365,9 +5445,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -5835,8 +5915,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "libmimalloc-sys"
|
name = "libmimalloc-sys"
|
||||||
version = "0.1.49"
|
version = "0.1.49"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=1cdadea43e9c5a0f054b65be21200ce580e4eb13#1cdadea43e9c5a0f054b65be21200ce580e4eb13"
|
||||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cty",
|
"cty",
|
||||||
@@ -5844,9 +5923,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -6245,8 +6324,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "mimalloc"
|
name = "mimalloc"
|
||||||
version = "0.1.52"
|
version = "0.1.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=1cdadea43e9c5a0f054b65be21200ce580e4eb13#1cdadea43e9c5a0f054b65be21200ce580e4eb13"
|
||||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libmimalloc-sys",
|
"libmimalloc-sys",
|
||||||
]
|
]
|
||||||
@@ -6668,6 +6746,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -6732,7 +6821,7 @@ version = "5.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
@@ -7857,7 +7946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"log",
|
"log",
|
||||||
"multimap",
|
"multimap",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -7877,7 +7966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"log",
|
"log",
|
||||||
"multimap",
|
"multimap",
|
||||||
"petgraph 0.8.3",
|
"petgraph 0.8.3",
|
||||||
@@ -7898,7 +7987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
@@ -7911,7 +8000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
@@ -7947,9 +8036,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psm"
|
name = "psm"
|
||||||
version = "0.1.31"
|
version = "0.1.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
|
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ar_archive_writer",
|
"ar_archive_writer",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -8631,6 +8720,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b"
|
||||||
|
dependencies = [
|
||||||
|
"xmlparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -8724,9 +8822,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh"
|
name = "russh"
|
||||||
version = "0.62.4"
|
version = "0.62.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f"
|
checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.2",
|
"aes 0.9.2",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -9027,6 +9125,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
@@ -9511,10 +9610,16 @@ dependencies = [
|
|||||||
"arc-swap",
|
"arc-swap",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"aws-config",
|
||||||
|
"aws-sdk-kms",
|
||||||
|
"aws-smithy-http-client",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"aws-smithy-types",
|
||||||
"base64 0.23.0",
|
"base64 0.23.0",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"hex",
|
"hex",
|
||||||
"hotpath",
|
"hotpath",
|
||||||
|
"http 1.5.0",
|
||||||
"insta",
|
"insta",
|
||||||
"jiff",
|
"jiff",
|
||||||
"md-5 0.11.0",
|
"md-5 0.11.0",
|
||||||
@@ -9523,6 +9628,7 @@ dependencies = [
|
|||||||
"moka",
|
"moka",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustfs-s3-types",
|
||||||
"rustfs-security-governance",
|
"rustfs-security-governance",
|
||||||
"rustfs-utils",
|
"rustfs-utils",
|
||||||
"rustify",
|
"rustify",
|
||||||
@@ -9536,6 +9642,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vaultrs",
|
"vaultrs",
|
||||||
@@ -9708,6 +9815,7 @@ dependencies = [
|
|||||||
"hotpath",
|
"hotpath",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"metrics",
|
"metrics",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"nvml-wrapper",
|
"nvml-wrapper",
|
||||||
@@ -9743,6 +9851,7 @@ dependencies = [
|
|||||||
"tracing-error",
|
"tracing-error",
|
||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -9774,6 +9883,7 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10027,6 +10137,7 @@ dependencies = [
|
|||||||
"rustfs-data-usage",
|
"rustfs-data-usage",
|
||||||
"rustfs-ecstore",
|
"rustfs-ecstore",
|
||||||
"rustfs-filemeta",
|
"rustfs-filemeta",
|
||||||
|
"rustfs-lock",
|
||||||
"rustfs-storage-api",
|
"rustfs-storage-api",
|
||||||
"rustfs-utils",
|
"rustfs-utils",
|
||||||
"s3s",
|
"s3s",
|
||||||
@@ -10607,7 +10718,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct 0.2.0",
|
"base16ct 0.2.0",
|
||||||
"der 0.7.10",
|
"der 0.7.10",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -10660,6 +10771,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "separator"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seq-macro"
|
name = "seq-macro"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -11288,9 +11405,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stacker"
|
name = "stacker"
|
||||||
version = "0.1.24"
|
version = "0.1.25"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
|
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -11707,9 +11824,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.54"
|
version = "0.3.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+8
-6
@@ -173,7 +173,7 @@ tower-http = { version = "0.7.0" }
|
|||||||
# Serialization and Data Formats
|
# Serialization and Data Formats
|
||||||
apache-avro = "0.21.0"
|
apache-avro = "0.21.0"
|
||||||
bytes = { version = "1.12.1" }
|
bytes = { version = "1.12.1" }
|
||||||
bytesize = "2.4.2"
|
bytesize = "2.7.0"
|
||||||
byteorder = "1.5.0"
|
byteorder = "1.5.0"
|
||||||
flatbuffers = "25.12.19"
|
flatbuffers = "25.12.19"
|
||||||
form_urlencoded = "1.2.2"
|
form_urlencoded = "1.2.2"
|
||||||
@@ -212,7 +212,7 @@ zeroize = { version = "1.9.0" }
|
|||||||
chrono = { version = "0.4.45" }
|
chrono = { version = "0.4.45" }
|
||||||
humantime = "2.4.0"
|
humantime = "2.4.0"
|
||||||
jiff = { version = "0.2.35" }
|
jiff = { version = "0.2.35" }
|
||||||
time = { version = "0.3.54" }
|
time = { version = "0.3.55" }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
deadpool-postgres = { version = "0.14" }
|
deadpool-postgres = { version = "0.14" }
|
||||||
@@ -227,6 +227,7 @@ atoi = "3.1.0"
|
|||||||
atomic_enum = "0.3.0"
|
atomic_enum = "0.3.0"
|
||||||
aws-config = { version = "1.10.1" }
|
aws-config = { version = "1.10.1" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
|
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||||
@@ -235,7 +236,7 @@ aws-smithy-types = { version = "1.6.1" }
|
|||||||
base64 = "0.23.0"
|
base64 = "0.23.0"
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "8.0.4"
|
brotli = "8.0.4"
|
||||||
clap = { version = "4.6.4" }
|
clap = { version = "4.6.5" }
|
||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.11.0"
|
convert_case = "0.11.0"
|
||||||
criterion = { version = "0.8" }
|
criterion = { version = "0.8" }
|
||||||
@@ -340,15 +341,16 @@ libunftp = { version = "0.23.0" }
|
|||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "10.0.1" }
|
suppaftp = { version = "10.0.1" }
|
||||||
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.62.4" }
|
russh = { version = "0.62.5" }
|
||||||
russh-sftp = "2.3.0"
|
russh-sftp = "2.3.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
mimalloc = "0.1.52"
|
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13" }
|
||||||
hotpath = { version = "0.22.0", default-features = false }
|
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13", features = ["extended"] }
|
||||||
|
hotpath = { version = "0.23.0", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ consts = "consts"
|
|||||||
Hashi = "Hashi" # HashiCorp
|
Hashi = "Hashi" # HashiCorp
|
||||||
# Accept alternate spelling used in parser/XML comments.
|
# Accept alternate spelling used in parser/XML comments.
|
||||||
unparseable = "unparseable"
|
unparseable = "unparseable"
|
||||||
|
# Disaster-recovery objectives: recovery time and recovery point.
|
||||||
|
RTO = "RTO"
|
||||||
|
rto = "rto"
|
||||||
|
|
||||||
[files]
|
[files]
|
||||||
extend-exclude = []
|
extend-exclude = []
|
||||||
|
|||||||
+375
-14
@@ -17,7 +17,7 @@ use crate::last_minute::{AccElem, LastMinuteLatency};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{BTreeSet, HashMap},
|
||||||
fmt::Display,
|
fmt::Display,
|
||||||
future::Future,
|
future::Future,
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
@@ -708,6 +708,48 @@ struct ScannerDiskBucketScanState {
|
|||||||
active: u64,
|
active: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScannerDiskBucketScanKey = (String, String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ScannerDiskBucketScanSnapshot {
|
||||||
|
pub pool: String,
|
||||||
|
pub set: String,
|
||||||
|
pub concurrency_limit: u64,
|
||||||
|
pub queued: u64,
|
||||||
|
pub active: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
struct ScannerBucketDriveResultKey {
|
||||||
|
bucket: String,
|
||||||
|
drive: String,
|
||||||
|
result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScannerBucketDriveResultKey {
|
||||||
|
fn new(bucket: impl Into<String>, drive: impl Into<String>, result: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
bucket: bucket.into(),
|
||||||
|
drive: drive.into(),
|
||||||
|
result: result.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ScannerBucketDriveResults {
|
||||||
|
counts: HashMap<ScannerBucketDriveResultKey, ScannerBucketDriveResultValue>,
|
||||||
|
eviction_index: BTreeSet<(u64, ScannerBucketDriveResultKey)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ScannerBucketDriveResultValue {
|
||||||
|
count: u64,
|
||||||
|
last_seen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Metrics
|
// Metrics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -738,7 +780,11 @@ pub struct Metrics {
|
|||||||
scanner_set_scan_concurrency_limit: AtomicU64,
|
scanner_set_scan_concurrency_limit: AtomicU64,
|
||||||
scanner_set_scans_queued: AtomicU64,
|
scanner_set_scans_queued: AtomicU64,
|
||||||
scanner_set_scans_active: AtomicU64,
|
scanner_set_scans_active: AtomicU64,
|
||||||
scanner_disk_bucket_scan_states: Mutex<HashMap<String, ScannerDiskBucketScanState>>,
|
scanner_disk_bucket_scan_states: Mutex<HashMap<ScannerDiskBucketScanKey, ScannerDiskBucketScanState>>,
|
||||||
|
scanner_bucket_drive_results: Mutex<ScannerBucketDriveResults>,
|
||||||
|
scanner_bucket_drive_result_clock: AtomicU64,
|
||||||
|
current_scan_cycle_bucket_drive_results_start: Mutex<HashMap<ScannerBucketDriveResultKey, u64>>,
|
||||||
|
last_scan_cycle_bucket_drive_results: Mutex<Vec<ScannerBucketDriveResultSnapshot>>,
|
||||||
scanner_leader_lock_state: RwLock<String>,
|
scanner_leader_lock_state: RwLock<String>,
|
||||||
scanner_leader_lock_held: AtomicBool,
|
scanner_leader_lock_held: AtomicBool,
|
||||||
scanner_leader_lock_last_error: RwLock<String>,
|
scanner_leader_lock_last_error: RwLock<String>,
|
||||||
@@ -958,6 +1004,14 @@ pub struct ScannerSourceWorkSnapshot {
|
|||||||
pub missed: u64,
|
pub missed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ScannerBucketDriveResultSnapshot {
|
||||||
|
pub bucket: String,
|
||||||
|
pub drive: String,
|
||||||
|
pub result: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ScannerReplicationRepairSnapshot {
|
pub struct ScannerReplicationRepairSnapshot {
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -1290,6 +1344,18 @@ pub struct ScannerMetricsReport {
|
|||||||
pub partial_cycles: u64,
|
pub partial_cycles: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ScannerRuntimeDetailsReport {
|
||||||
|
#[serde(default)]
|
||||||
|
pub disk_bucket_scan_states: Vec<ScannerDiskBucketScanSnapshot>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
impl CurrentCycle {
|
impl CurrentCycle {
|
||||||
pub fn unmarshal(&mut self, buf: &[u8]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub fn unmarshal(&mut self, buf: &[u8]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
*self = rmp_serde::from_slice(buf)?;
|
*self = rmp_serde::from_slice(buf)?;
|
||||||
@@ -1657,6 +1723,7 @@ pub fn emit_scan_cycle_superseded(duration: Duration) {
|
|||||||
|
|
||||||
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
||||||
let result = if success { "success" } else { "error" };
|
let result = if success { "success" } else { "error" };
|
||||||
|
global_metrics().record_scanner_bucket_drive_result(bucket, disk, result);
|
||||||
metrics::counter!(
|
metrics::counter!(
|
||||||
OTEL_SCANNER_BUCKETS_SCANNED,
|
OTEL_SCANNER_BUCKETS_SCANNED,
|
||||||
"result" => result,
|
"result" => result,
|
||||||
@@ -1673,6 +1740,7 @@ pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_scan_bucket_drive_partial(bucket: &str, disk: &str, duration: Duration) {
|
pub fn emit_scan_bucket_drive_partial(bucket: &str, disk: &str, duration: Duration) {
|
||||||
|
global_metrics().record_scanner_bucket_drive_result(bucket, disk, SCAN_CYCLE_RESULT_PARTIAL_LABEL);
|
||||||
metrics::counter!(
|
metrics::counter!(
|
||||||
OTEL_SCANNER_BUCKETS_SCANNED,
|
OTEL_SCANNER_BUCKETS_SCANNED,
|
||||||
"result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
"result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
||||||
@@ -1723,6 +1791,10 @@ impl Metrics {
|
|||||||
scanner_set_scans_queued: AtomicU64::new(0),
|
scanner_set_scans_queued: AtomicU64::new(0),
|
||||||
scanner_set_scans_active: AtomicU64::new(0),
|
scanner_set_scans_active: AtomicU64::new(0),
|
||||||
scanner_disk_bucket_scan_states: Mutex::new(HashMap::new()),
|
scanner_disk_bucket_scan_states: Mutex::new(HashMap::new()),
|
||||||
|
scanner_bucket_drive_results: Mutex::new(ScannerBucketDriveResults::default()),
|
||||||
|
scanner_bucket_drive_result_clock: AtomicU64::new(0),
|
||||||
|
current_scan_cycle_bucket_drive_results_start: Mutex::new(HashMap::new()),
|
||||||
|
last_scan_cycle_bucket_drive_results: Mutex::new(Vec::new()),
|
||||||
scanner_leader_lock_state: RwLock::new("unknown".to_string()),
|
scanner_leader_lock_state: RwLock::new("unknown".to_string()),
|
||||||
scanner_leader_lock_held: AtomicBool::new(false),
|
scanner_leader_lock_held: AtomicBool::new(false),
|
||||||
scanner_leader_lock_last_error: RwLock::new(String::new()),
|
scanner_leader_lock_last_error: RwLock::new(String::new()),
|
||||||
@@ -2293,7 +2365,7 @@ impl Metrics {
|
|||||||
queued: Option<usize>,
|
queued: Option<usize>,
|
||||||
active: Option<usize>,
|
active: Option<usize>,
|
||||||
) {
|
) {
|
||||||
let key = format!("{pool}/{set}");
|
let key = (pool.to_string(), set.to_string());
|
||||||
let mut states = self
|
let mut states = self
|
||||||
.scanner_disk_bucket_scan_states
|
.scanner_disk_bucket_scan_states
|
||||||
.lock()
|
.lock()
|
||||||
@@ -2310,6 +2382,41 @@ impl Metrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_scanner_bucket_drive_result(&self, bucket: &str, drive: &str, result: &str) {
|
||||||
|
if bucket.is_empty() || drive.is_empty() || result.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = ScannerBucketDriveResultKey::new(bucket, drive, result);
|
||||||
|
let mut results = self
|
||||||
|
.scanner_bucket_drive_results
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
let last_seen = self.scanner_bucket_drive_result_clock.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if let Some(previous_last_seen) = results.counts.get_mut(&key).map(|value| {
|
||||||
|
let previous_last_seen = value.last_seen;
|
||||||
|
value.count = value.count.saturating_add(1);
|
||||||
|
value.last_seen = last_seen;
|
||||||
|
previous_last_seen
|
||||||
|
}) {
|
||||||
|
results.eviction_index.remove(&(previous_last_seen, key.clone()));
|
||||||
|
results.eviction_index.insert((last_seen, key));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.counts.len() >= MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS
|
||||||
|
&& let Some((_, stale_key)) = results.eviction_index.pop_first()
|
||||||
|
{
|
||||||
|
results.counts.remove(&stale_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.counts.len() < MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS {
|
||||||
|
results
|
||||||
|
.counts
|
||||||
|
.insert(key.clone(), ScannerBucketDriveResultValue { count: 1, last_seen });
|
||||||
|
results.eviction_index.insert((last_seen, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Read-side helpers
|
// Read-side helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -2481,6 +2588,11 @@ impl Metrics {
|
|||||||
&self.current_scan_cycle_replication_repair_work_start,
|
&self.current_scan_cycle_replication_repair_work_start,
|
||||||
&replication_repair_snapshot,
|
&replication_repair_snapshot,
|
||||||
);
|
);
|
||||||
|
let bucket_drive_results = self.scanner_bucket_drive_result_counts();
|
||||||
|
match self.current_scan_cycle_bucket_drive_results_start.lock() {
|
||||||
|
Ok(mut start) => *start = bucket_drive_results,
|
||||||
|
Err(poisoned) => *poisoned.into_inner() = bucket_drive_results,
|
||||||
|
}
|
||||||
self.current_scan_cycle_work_active.store(true, Ordering::Release);
|
self.current_scan_cycle_work_active.store(true, Ordering::Release);
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
@@ -2493,6 +2605,11 @@ impl Metrics {
|
|||||||
self.record_scan_cycle_work(work);
|
self.record_scan_cycle_work(work);
|
||||||
self.record_scan_cycle_source_work(&source_work);
|
self.record_scan_cycle_source_work(&source_work);
|
||||||
self.record_scan_cycle_replication_repair_work(&replication_repair_work);
|
self.record_scan_cycle_replication_repair_work(&replication_repair_work);
|
||||||
|
let bucket_drive_results = self.current_cycle_bucket_drive_result_snapshots();
|
||||||
|
match self.last_scan_cycle_bucket_drive_results.lock() {
|
||||||
|
Ok(mut last) => *last = bucket_drive_results,
|
||||||
|
Err(poisoned) => *poisoned.into_inner() = bucket_drive_results,
|
||||||
|
}
|
||||||
self.current_scan_cycle_work_active.store(false, Ordering::Release);
|
self.current_scan_cycle_work_active.store(false, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2576,6 +2693,105 @@ impl Metrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_bucket_drive_result_counts(&self) -> HashMap<ScannerBucketDriveResultKey, u64> {
|
||||||
|
self.scanner_bucket_drive_results
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.counts
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.count))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_bucket_drive_result_snapshots(
|
||||||
|
counts: impl IntoIterator<Item = (ScannerBucketDriveResultKey, u64)>,
|
||||||
|
) -> Vec<ScannerBucketDriveResultSnapshot> {
|
||||||
|
let mut snapshots = counts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, count)| *count > 0)
|
||||||
|
.map(|(key, count)| ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: key.bucket,
|
||||||
|
drive: key.drive,
|
||||||
|
result: key.result,
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
snapshots.sort_by(|left, right| {
|
||||||
|
left.bucket
|
||||||
|
.cmp(&right.bucket)
|
||||||
|
.then_with(|| left.drive.cmp(&right.drive))
|
||||||
|
.then_with(|| left.result.cmp(&right.result))
|
||||||
|
});
|
||||||
|
snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_bucket_drive_result_counter_snapshots(&self) -> Vec<ScannerBucketDriveResultSnapshot> {
|
||||||
|
Self::scanner_bucket_drive_result_snapshots(self.scanner_bucket_drive_result_counts())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_cycle_bucket_drive_result_snapshots(&self) -> Vec<ScannerBucketDriveResultSnapshot> {
|
||||||
|
let current = self.scanner_bucket_drive_result_counts();
|
||||||
|
let start = self
|
||||||
|
.current_scan_cycle_bucket_drive_results_start
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone();
|
||||||
|
Self::scanner_bucket_drive_result_snapshots(current.into_iter().filter_map(|(key, count)| {
|
||||||
|
let delta = count.saturating_sub(start.get(&key).copied().unwrap_or_default());
|
||||||
|
(delta > 0).then_some((key, delta))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scanner_runtime_details_report(&self) -> ScannerRuntimeDetailsReport {
|
||||||
|
self.scanner_runtime_details_report_for_active(self.current_scan_cycle_work_active.load(Ordering::Acquire))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_runtime_details_report_for_active(&self, current_cycle_active: bool) -> ScannerRuntimeDetailsReport {
|
||||||
|
let current_cycle_bucket_drive_results = if current_cycle_active {
|
||||||
|
self.current_cycle_bucket_drive_result_snapshots()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
ScannerRuntimeDetailsReport {
|
||||||
|
disk_bucket_scan_states: self.scanner_disk_bucket_scan_state_snapshots(),
|
||||||
|
bucket_drive_results: self.scanner_bucket_drive_result_counter_snapshots(),
|
||||||
|
current_cycle_bucket_drive_results,
|
||||||
|
last_cycle_bucket_drive_results: self
|
||||||
|
.last_scan_cycle_bucket_drive_results
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_disk_bucket_scan_state_snapshots(&self) -> Vec<ScannerDiskBucketScanSnapshot> {
|
||||||
|
let mut disk_bucket_scan_states = match self.scanner_disk_bucket_scan_states.lock() {
|
||||||
|
Ok(states) => states
|
||||||
|
.iter()
|
||||||
|
.map(|((pool, set), state)| ScannerDiskBucketScanSnapshot {
|
||||||
|
pool: pool.clone(),
|
||||||
|
set: set.clone(),
|
||||||
|
concurrency_limit: state.concurrency_limit,
|
||||||
|
queued: state.queued,
|
||||||
|
active: state.active,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(poisoned) => poisoned
|
||||||
|
.into_inner()
|
||||||
|
.iter()
|
||||||
|
.map(|((pool, set), state)| ScannerDiskBucketScanSnapshot {
|
||||||
|
pool: pool.clone(),
|
||||||
|
set: set.clone(),
|
||||||
|
concurrency_limit: state.concurrency_limit,
|
||||||
|
queued: state.queued,
|
||||||
|
active: state.active,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
};
|
||||||
|
disk_bucket_scan_states.sort_by(|left, right| left.pool.cmp(&right.pool).then_with(|| left.set.cmp(&right.set)));
|
||||||
|
disk_bucket_scan_states
|
||||||
|
}
|
||||||
|
|
||||||
fn scanner_source_work_values(&self) -> Vec<ScannerSourceWorkValues> {
|
fn scanner_source_work_values(&self) -> Vec<ScannerSourceWorkValues> {
|
||||||
ScannerWorkSource::all()
|
ScannerWorkSource::all()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2761,7 +2977,12 @@ impl Metrics {
|
|||||||
|
|
||||||
/// Build a full metrics report snapshot.
|
/// Build a full metrics report snapshot.
|
||||||
pub async fn report(&self) -> ScannerMetricsReport {
|
pub async fn report(&self) -> ScannerMetricsReport {
|
||||||
|
self.report_with_runtime_details().await.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn report_with_runtime_details(&self) -> (ScannerMetricsReport, ScannerRuntimeDetailsReport) {
|
||||||
let mut m = ScannerMetricsReport::default();
|
let mut m = ScannerMetricsReport::default();
|
||||||
|
let runtime_details;
|
||||||
|
|
||||||
let has_cycle = {
|
let has_cycle = {
|
||||||
let cycle = self.cycle_info.read().await;
|
let cycle = self.cycle_info.read().await;
|
||||||
@@ -2775,6 +2996,7 @@ impl Metrics {
|
|||||||
};
|
};
|
||||||
m.current_cycle_active = self.current_scan_cycle_work_active.load(Ordering::Acquire);
|
m.current_cycle_active = self.current_scan_cycle_work_active.load(Ordering::Acquire);
|
||||||
if m.current_cycle_active {
|
if m.current_cycle_active {
|
||||||
|
// Keep cycle_info before cycle-baseline locks so active scrapes cannot mix two cycle identities.
|
||||||
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
|
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
|
||||||
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
||||||
let current_replication_repair_work =
|
let current_replication_repair_work =
|
||||||
@@ -2797,6 +3019,7 @@ impl Metrics {
|
|||||||
m.current_cycle_replication_repair =
|
m.current_cycle_replication_repair =
|
||||||
self.scanner_replication_repair_work_snapshots(¤t_replication_repair_work);
|
self.scanner_replication_repair_work_snapshots(¤t_replication_repair_work);
|
||||||
}
|
}
|
||||||
|
runtime_details = self.scanner_runtime_details_report_for_active(m.current_cycle_active);
|
||||||
has_cycle
|
has_cycle
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2826,15 +3049,11 @@ impl Metrics {
|
|||||||
m.current_set_scan_concurrency_limit = self.scanner_set_scan_concurrency_limit.load(Ordering::Relaxed);
|
m.current_set_scan_concurrency_limit = self.scanner_set_scan_concurrency_limit.load(Ordering::Relaxed);
|
||||||
m.current_set_scans_queued = self.scanner_set_scans_queued.load(Ordering::Relaxed);
|
m.current_set_scans_queued = self.scanner_set_scans_queued.load(Ordering::Relaxed);
|
||||||
m.current_set_scans_active = self.scanner_set_scans_active.load(Ordering::Relaxed);
|
m.current_set_scans_active = self.scanner_set_scans_active.load(Ordering::Relaxed);
|
||||||
|
let disk_bucket_scan_states = self.scanner_disk_bucket_scan_state_snapshots();
|
||||||
let (disk_scan_concurrency_limit, disk_bucket_scans_queued, disk_bucket_scans_active) =
|
let (disk_scan_concurrency_limit, disk_bucket_scans_queued, disk_bucket_scans_active) =
|
||||||
match self.scanner_disk_bucket_scan_states.lock() {
|
disk_bucket_scan_states.iter().fold((0, 0, 0), |acc, state| {
|
||||||
Ok(states) => states.values().fold((0, 0, 0), |acc, state| {
|
(acc.0 + state.concurrency_limit, acc.1 + state.queued, acc.2 + state.active)
|
||||||
(acc.0 + state.concurrency_limit, acc.1 + state.queued, acc.2 + state.active)
|
});
|
||||||
}),
|
|
||||||
Err(poisoned) => poisoned.into_inner().values().fold((0, 0, 0), |acc, state| {
|
|
||||||
(acc.0 + state.concurrency_limit, acc.1 + state.queued, acc.2 + state.active)
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit;
|
m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit;
|
||||||
m.current_disk_bucket_scans_queued = disk_bucket_scans_queued;
|
m.current_disk_bucket_scans_queued = disk_bucket_scans_queued;
|
||||||
m.current_disk_bucket_scans_active = disk_bucket_scans_active;
|
m.current_disk_bucket_scans_active = disk_bucket_scans_active;
|
||||||
@@ -3003,7 +3222,7 @@ impl Metrics {
|
|||||||
m.pacing_pressure = scanner_pacing_pressure(&m);
|
m.pacing_pressure = scanner_pacing_pressure(&m);
|
||||||
m.maintenance_control = scanner_maintenance_control(&m);
|
m.maintenance_control = scanner_maintenance_control(&m);
|
||||||
|
|
||||||
m
|
(m, runtime_details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4100,6 +4319,137 @@ mod tests {
|
|||||||
assert_eq!(report.life_time_ops.get("scan_bucket_drive_failure"), Some(&1));
|
assert_eq!(report.life_time_ops.get("scan_bucket_drive_failure"), Some(&1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_includes_structured_bucket_drive_results() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
metrics.record_scanner_bucket_drive_result("photos", "/data1", "success");
|
||||||
|
|
||||||
|
let cycle_start = metrics.start_scan_cycle_work();
|
||||||
|
metrics.record_scanner_bucket_drive_result("photos", "/data1", "partial");
|
||||||
|
|
||||||
|
let active_report = metrics.scanner_runtime_details_report();
|
||||||
|
assert_eq!(
|
||||||
|
active_report.current_cycle_bucket_drive_results,
|
||||||
|
vec![ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
drive: "/data1".to_string(),
|
||||||
|
result: "partial".to_string(),
|
||||||
|
count: 1,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.finish_scan_cycle_work(cycle_start);
|
||||||
|
|
||||||
|
let report = metrics.scanner_runtime_details_report();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
report.bucket_drive_results,
|
||||||
|
vec![
|
||||||
|
ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
drive: "/data1".to_string(),
|
||||||
|
result: "partial".to_string(),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
drive: "/data1".to_string(),
|
||||||
|
result: "success".to_string(),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(report.current_cycle_bucket_drive_results.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
report.last_cycle_bucket_drive_results,
|
||||||
|
vec![ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
drive: "/data1".to_string(),
|
||||||
|
result: "partial".to_string(),
|
||||||
|
count: 1,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_bucket_drive_results_are_bounded() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
for index in 0..MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS {
|
||||||
|
metrics.record_scanner_bucket_drive_result(&format!("bucket-{index}"), "/data1", "success");
|
||||||
|
}
|
||||||
|
metrics.record_scanner_bucket_drive_result("overflow", "/data1", "success");
|
||||||
|
|
||||||
|
let report = metrics.scanner_runtime_details_report();
|
||||||
|
|
||||||
|
assert_eq!(report.bucket_drive_results.len(), MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot.bucket == "overflow")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.all(|snapshot| snapshot.bucket != "bucket-0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_bucket_drive_result_eviction_keeps_recent_keys() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
for index in 0..MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS {
|
||||||
|
metrics.record_scanner_bucket_drive_result(&format!("bucket-{index}"), "/data1", "success");
|
||||||
|
}
|
||||||
|
metrics.record_scanner_bucket_drive_result("bucket-0", "/data1", "success");
|
||||||
|
metrics.record_scanner_bucket_drive_result("overflow", "/data1", "success");
|
||||||
|
|
||||||
|
let report = metrics.scanner_runtime_details_report();
|
||||||
|
|
||||||
|
assert_eq!(report.bucket_drive_results.len(), MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot.bucket == "bucket-0" && snapshot.count == 2)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.all(|snapshot| snapshot.bucket != "bucket-1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_bucket_drive_result_eviction_survives_full_refresh() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
for index in 0..MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS {
|
||||||
|
metrics.record_scanner_bucket_drive_result(&format!("bucket-{index}"), "/data1", "success");
|
||||||
|
}
|
||||||
|
for index in 0..MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS {
|
||||||
|
metrics.record_scanner_bucket_drive_result(&format!("bucket-{index}"), "/data1", "success");
|
||||||
|
}
|
||||||
|
metrics.record_scanner_bucket_drive_result("overflow", "/data1", "success");
|
||||||
|
|
||||||
|
let report = metrics.scanner_runtime_details_report();
|
||||||
|
|
||||||
|
assert_eq!(report.bucket_drive_results.len(), MAX_SCANNER_BUCKET_DRIVE_RESULT_KEYS);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.any(|snapshot| snapshot.bucket == "overflow")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.bucket_drive_results
|
||||||
|
.iter()
|
||||||
|
.all(|snapshot| snapshot.bucket != "bucket-0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_includes_usage_freshness_status() {
|
async fn report_includes_usage_freshness_status() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
@@ -4265,9 +4615,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let cycle_ten_start = metrics.start_scan_cycle_work_with_cycle(cycle_ten.clone()).await;
|
let cycle_ten_start = metrics.start_scan_cycle_work_with_cycle(cycle_ten.clone()).await;
|
||||||
metrics.operations[Metric::ScanObject as usize].store(1, Ordering::Relaxed);
|
metrics.operations[Metric::ScanObject as usize].store(1, Ordering::Relaxed);
|
||||||
|
metrics.record_scanner_bucket_drive_result("cycle-ten", "/data1", "partial");
|
||||||
|
|
||||||
let paths = metrics.current_paths.write().await;
|
let paths = metrics.current_paths.write().await;
|
||||||
let mut report = Box::pin(metrics.report());
|
let mut report = Box::pin(metrics.report_with_runtime_details());
|
||||||
let waker = std::task::Waker::noop();
|
let waker = std::task::Waker::noop();
|
||||||
let mut context = std::task::Context::from_waker(waker);
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
assert!(report.as_mut().poll(&mut context).is_pending());
|
assert!(report.as_mut().poll(&mut context).is_pending());
|
||||||
@@ -4284,12 +4635,22 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
metrics.operations[Metric::ScanObject as usize].store(101, Ordering::Relaxed);
|
metrics.operations[Metric::ScanObject as usize].store(101, Ordering::Relaxed);
|
||||||
|
metrics.record_scanner_bucket_drive_result("cycle-eleven", "/data1", "partial");
|
||||||
|
|
||||||
drop(paths);
|
drop(paths);
|
||||||
let snapshot = report.await;
|
let (snapshot, runtime_details) = report.await;
|
||||||
|
|
||||||
assert_eq!(snapshot.current_cycle, 10);
|
assert_eq!(snapshot.current_cycle, 10);
|
||||||
assert_eq!(snapshot.current_cycle_objects_scanned, 1);
|
assert_eq!(snapshot.current_cycle_objects_scanned, 1);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_details.current_cycle_bucket_drive_results,
|
||||||
|
vec![ScannerBucketDriveResultSnapshot {
|
||||||
|
bucket: "cycle-ten".to_string(),
|
||||||
|
drive: "/data1".to_string(),
|
||||||
|
result: "partial".to_string(),
|
||||||
|
count: 1,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
metrics
|
metrics
|
||||||
.finish_scan_cycle_work_with_cycle(cycle_eleven_start, CurrentCycle::default())
|
.finish_scan_cycle_work_with_cycle(cycle_eleven_start, CurrentCycle::default())
|
||||||
|
|||||||
@@ -230,6 +230,19 @@ pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
|
|||||||
/// Default value: false
|
/// Default value: false
|
||||||
pub const DEFAULT_KMS_ENABLE: bool = false;
|
pub const DEFAULT_KMS_ENABLE: bool = false;
|
||||||
|
|
||||||
|
/// Environment variable enabling per-key KMS authorization on the SSE-KMS data path.
|
||||||
|
///
|
||||||
|
/// When enabled, an SSE-KMS write additionally requires `kms:GenerateDataKey` and an
|
||||||
|
/// SSE-KMS read additionally requires `kms:Decrypt` on the resolved key, evaluated as
|
||||||
|
/// the requesting identity. SSE-S3 and SSE-C are unaffected.
|
||||||
|
pub const ENV_RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY: &str = "RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY";
|
||||||
|
|
||||||
|
/// Default per-key KMS authorization mode for the SSE-KMS data path.
|
||||||
|
///
|
||||||
|
/// Off for now so deployments whose identity policies only grant s3 actions keep
|
||||||
|
/// working; the default flips to on in a later release.
|
||||||
|
pub const DEFAULT_KMS_ENFORCE_SSE_KEY_POLICY: bool = false;
|
||||||
|
|
||||||
/// Environment variable for server KMS backend.
|
/// Environment variable for server KMS backend.
|
||||||
pub const ENV_RUSTFS_KMS_BACKEND: &str = "RUSTFS_KMS_BACKEND";
|
pub const ENV_RUSTFS_KMS_BACKEND: &str = "RUSTFS_KMS_BACKEND";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize, ser::SerializeMap as _};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
@@ -51,24 +51,36 @@ pub fn usage_last_update_is_untrusted_future(existing_last_update: SystemTime, n
|
|||||||
existing_last_update > now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE
|
existing_last_update > now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TierStats {
|
pub struct TierStats {
|
||||||
pub total_size: u64,
|
pub total_size: u64,
|
||||||
pub num_versions: i32,
|
pub num_versions: u64,
|
||||||
pub num_objects: i32,
|
pub num_objects: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TierStats {
|
impl TierStats {
|
||||||
pub fn add(&self, u: &TierStats) -> TierStats {
|
pub fn add(&self, u: &TierStats) -> TierStats {
|
||||||
TierStats {
|
TierStats {
|
||||||
total_size: self.total_size + u.total_size,
|
total_size: self.total_size.saturating_add(u.total_size),
|
||||||
num_versions: self.num_versions + u.num_versions,
|
num_versions: self.num_versions.saturating_add(u.num_versions),
|
||||||
num_objects: self.num_objects + u.num_objects,
|
num_objects: self.num_objects.saturating_add(u.num_objects),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when [`TierStats::add`] would report the exact sum instead of saturating.
|
||||||
|
pub fn fits_add(&self, u: &TierStats) -> bool {
|
||||||
|
self.total_size.checked_add(u.total_size).is_some()
|
||||||
|
&& self.num_versions.checked_add(u.num_versions).is_some()
|
||||||
|
&& self.num_objects.checked_add(u.num_objects).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this tier contributed nothing, i.e. merging it is a no-op.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.total_size == 0 && self.num_versions == 0 && self.num_objects == 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AllTierStats {
|
pub struct AllTierStats {
|
||||||
pub tiers: HashMap<String, TierStats>,
|
pub tiers: HashMap<String, TierStats>,
|
||||||
}
|
}
|
||||||
@@ -78,31 +90,35 @@ impl AllTierStats {
|
|||||||
Self { tiers: HashMap::new() }
|
Self { tiers: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_sizes(&mut self, tiers: HashMap<String, TierStats>) {
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tiers.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds a scan summary's per-tier map in.
|
||||||
|
///
|
||||||
|
/// Scanners seed the map with a zeroed entry for every configured tier, so
|
||||||
|
/// empty contributions are skipped to keep the persisted cache from growing
|
||||||
|
/// one key per tier on every folder that never held tiered data.
|
||||||
|
pub fn add_sizes(&mut self, tiers: &HashMap<String, TierStats>) {
|
||||||
for (tier, st) in tiers {
|
for (tier, st) in tiers {
|
||||||
self.tiers
|
if st.is_empty() {
|
||||||
.insert(tier.clone(), self.tiers.get(&tier).copied().unwrap_or_default().add(&st));
|
continue;
|
||||||
|
}
|
||||||
|
let entry = self.tiers.entry(tier.clone()).or_default();
|
||||||
|
*entry = entry.add(st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, other: AllTierStats) {
|
pub fn merge(&mut self, other: &AllTierStats) {
|
||||||
for (tier, st) in other.tiers {
|
self.add_sizes(&other.tiers);
|
||||||
self.tiers
|
|
||||||
.insert(tier.clone(), self.tiers.get(&tier).copied().unwrap_or_default().add(&st));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn populate_stats(&self, stats: &mut HashMap<String, TierStats>) {
|
/// True when [`AllTierStats::merge`] would report exact sums for every tier.
|
||||||
for (tier, st) in &self.tiers {
|
pub fn fits_merge(&self, other: &AllTierStats) -> bool {
|
||||||
stats.insert(
|
other
|
||||||
tier.clone(),
|
.tiers
|
||||||
TierStats {
|
.iter()
|
||||||
total_size: st.total_size,
|
.all(|(tier, right)| self.tiers.get(tier).is_none_or(|left| left.fits_add(right)))
|
||||||
num_versions: st.num_versions,
|
|
||||||
num_objects: st.num_objects,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +199,14 @@ pub struct DataUsageInfo {
|
|||||||
pub objects_total_size: u64,
|
pub objects_total_size: u64,
|
||||||
/// Replication info across all buckets
|
/// Replication info across all buckets
|
||||||
pub replication_info: HashMap<String, BucketTargetUsageInfo>,
|
pub replication_info: HashMap<String, BucketTargetUsageInfo>,
|
||||||
|
/// Usage per storage class and remote tier across all buckets.
|
||||||
|
///
|
||||||
|
/// Absent on snapshots written before per-tier accounting was published,
|
||||||
|
/// and on clusters with no remote tier configured: the scanner classifies
|
||||||
|
/// objects by tier (including `STANDARD`/`REDUCED_REDUNDANCY`) only once a
|
||||||
|
/// tier exists, so an absent value means "not accounted", never "zero".
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tier_stats: Option<AllTierStats>,
|
||||||
|
|
||||||
/// Total number of buckets in this cluster
|
/// Total number of buckets in this cluster
|
||||||
pub buckets_count: u64,
|
pub buckets_count: u64,
|
||||||
@@ -562,7 +586,7 @@ impl ReplicationAllStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Data usage cache entry
|
/// Data usage cache entry
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
pub struct DataUsageEntry {
|
pub struct DataUsageEntry {
|
||||||
pub children: DataUsageHashMap,
|
pub children: DataUsageHashMap,
|
||||||
// These fields do not include any children.
|
// These fields do not include any children.
|
||||||
@@ -577,6 +601,34 @@ pub struct DataUsageEntry {
|
|||||||
/// Number of objects that failed to scan (e.g., IO errors)
|
/// Number of objects that failed to scan (e.g., IO errors)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub failed_objects: usize,
|
pub failed_objects: usize,
|
||||||
|
/// Per-tier usage contributed by this entry, present only once a scan
|
||||||
|
/// observed tier-classified objects.
|
||||||
|
#[serde(default)]
|
||||||
|
pub all_tier_stats: Option<AllTierStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for DataUsageEntry {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
// Keep entries map-encoded so older readers can ignore fields appended
|
||||||
|
// by newer scanner versions during rolling upgrades. The derived
|
||||||
|
// (array) encoding made any appended field a decode error for them.
|
||||||
|
let mut state = serializer.serialize_map(Some(11))?;
|
||||||
|
state.serialize_entry("children", &self.children)?;
|
||||||
|
state.serialize_entry("size", &self.size)?;
|
||||||
|
state.serialize_entry("objects", &self.objects)?;
|
||||||
|
state.serialize_entry("versions", &self.versions)?;
|
||||||
|
state.serialize_entry("delete_markers", &self.delete_markers)?;
|
||||||
|
state.serialize_entry("obj_sizes", &self.obj_sizes)?;
|
||||||
|
state.serialize_entry("obj_versions", &self.obj_versions)?;
|
||||||
|
state.serialize_entry("replication_stats", &self.replication_stats)?;
|
||||||
|
state.serialize_entry("compacted", &self.compacted)?;
|
||||||
|
state.serialize_entry("failed_objects", &self.failed_objects)?;
|
||||||
|
state.serialize_entry("all_tier_stats", &self.all_tier_stats)?;
|
||||||
|
state.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataUsageEntry {
|
impl DataUsageEntry {
|
||||||
@@ -635,10 +687,22 @@ impl DataUsageEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(o_tiers) = other.all_tier_stats.as_ref().filter(|tiers| !tiers.is_empty()) {
|
||||||
|
self.all_tier_stats.get_or_insert_with(AllTierStats::new).merge(o_tiers);
|
||||||
|
}
|
||||||
|
|
||||||
self.obj_sizes.merge_from(&other.obj_sizes);
|
self.obj_sizes.merge_from(&other.obj_sizes);
|
||||||
self.obj_versions.merge_from(&other.obj_versions);
|
self.obj_versions.merge_from(&other.obj_versions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Folds a scan summary's per-tier map into this entry.
|
||||||
|
pub fn add_tier_sizes(&mut self, tiers: &HashMap<String, TierStats>) {
|
||||||
|
if tiers.values().all(TierStats::is_empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.all_tier_stats.get_or_insert_with(AllTierStats::new).add_sizes(tiers);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
|
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
|
||||||
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
|
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
|
||||||
&& self.versions.checked_add(other.versions).is_some()
|
&& self.versions.checked_add(other.versions).is_some()
|
||||||
@@ -698,7 +762,12 @@ impl DataUsageEntry {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !scalar_counts_fit || !histograms_fit || !replication_fits {
|
let tier_stats_fit = match (&self.all_tier_stats, &other.all_tier_stats) {
|
||||||
|
(_, None) | (None, Some(_)) => true,
|
||||||
|
(Some(left), Some(right)) => left.fits_merge(right),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !scalar_counts_fit || !histograms_fit || !replication_fits || !tier_stats_fit {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.merge(other);
|
self.merge(other);
|
||||||
@@ -1038,6 +1107,7 @@ impl DataUsageCache {
|
|||||||
versions_total_count: flat.versions as u64,
|
versions_total_count: flat.versions as u64,
|
||||||
delete_markers_total_count: flat.delete_markers as u64,
|
delete_markers_total_count: flat.delete_markers as u64,
|
||||||
objects_total_size: flat.size as u64,
|
objects_total_size: flat.size as u64,
|
||||||
|
tier_stats: flat.all_tier_stats.filter(|tiers| !tiers.is_empty()),
|
||||||
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
||||||
buckets_usage,
|
buckets_usage,
|
||||||
usage_snapshot_complete: self.info.snapshot_complete,
|
usage_snapshot_complete: self.info.snapshot_complete,
|
||||||
@@ -1525,6 +1595,172 @@ mod tests {
|
|||||||
buckets_count: u64,
|
buckets_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tier_entry(tier: &str, stats: TierStats) -> DataUsageEntry {
|
||||||
|
let mut entry = DataUsageEntry::default();
|
||||||
|
entry.add_tier_sizes(&HashMap::from([(tier.to_string(), stats)]));
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_stats_survive_entry_merge() {
|
||||||
|
let mut left = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 10,
|
||||||
|
num_versions: 2,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut right = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 5,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
right.add_tier_sizes(&HashMap::from([(
|
||||||
|
"COLD".to_string(),
|
||||||
|
TierStats {
|
||||||
|
total_size: 7,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 0,
|
||||||
|
},
|
||||||
|
)]));
|
||||||
|
|
||||||
|
assert!(left.checked_merge(&right), "merging exact tier totals must be accepted");
|
||||||
|
|
||||||
|
let tiers = &left.all_tier_stats.expect("merged entry keeps tier stats").tiers;
|
||||||
|
assert_eq!(
|
||||||
|
tiers.get("WARM"),
|
||||||
|
Some(&TierStats {
|
||||||
|
total_size: 15,
|
||||||
|
num_versions: 3,
|
||||||
|
num_objects: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tiers.get("COLD"),
|
||||||
|
Some(&TierStats {
|
||||||
|
total_size: 7,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_stats_merge_into_an_untiered_entry() {
|
||||||
|
let mut left = DataUsageEntry::default();
|
||||||
|
let right = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 10,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(left.checked_merge(&right));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
left.all_tier_stats.expect("tier stats adopted from the merged entry").tiers["WARM"],
|
||||||
|
TierStats {
|
||||||
|
total_size: 10,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_merge_rejects_overflowing_tier_totals() {
|
||||||
|
let mut left = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: u64::MAX,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let right = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 1,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!left.checked_merge(&right), "saturating tier totals must not be published");
|
||||||
|
assert_eq!(left.all_tier_stats.expect("left is untouched").tiers["WARM"].total_size, u64::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry shape released before per-tier accounting, using the derived
|
||||||
|
/// (array) encoding those writers produced.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct LegacyEntry {
|
||||||
|
children: DataUsageHashMap,
|
||||||
|
size: usize,
|
||||||
|
objects: usize,
|
||||||
|
versions: usize,
|
||||||
|
delete_markers: usize,
|
||||||
|
obj_sizes: SizeHistogram,
|
||||||
|
obj_versions: VersionsHistogram,
|
||||||
|
replication_stats: Option<ReplicationAllStats>,
|
||||||
|
compacted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
failed_objects: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries_are_map_encoded_so_appended_fields_stay_readable() {
|
||||||
|
// A derived (array) encoding turns every appended field into a decode
|
||||||
|
// error for readers built before it existed, which would cost a mixed
|
||||||
|
// -version cluster its whole scan cache. Entries must stay map-encoded.
|
||||||
|
let current = tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 3,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
current
|
||||||
|
.serialize(&mut rmp_serde::Serializer::new(&mut encoded))
|
||||||
|
.expect("encode current entry");
|
||||||
|
|
||||||
|
let legacy: LegacyEntry = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore the appended field");
|
||||||
|
assert_eq!(legacy.objects, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_array_encoded_entries_still_load() {
|
||||||
|
let legacy = LegacyEntry {
|
||||||
|
children: DataUsageHashMap::default(),
|
||||||
|
size: 12,
|
||||||
|
objects: 3,
|
||||||
|
versions: 4,
|
||||||
|
delete_markers: 1,
|
||||||
|
obj_sizes: SizeHistogram::default(),
|
||||||
|
obj_versions: VersionsHistogram::default(),
|
||||||
|
replication_stats: None,
|
||||||
|
compacted: false,
|
||||||
|
failed_objects: 2,
|
||||||
|
};
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
legacy
|
||||||
|
.serialize(&mut rmp_serde::Serializer::new(&mut encoded))
|
||||||
|
.expect("encode legacy entry");
|
||||||
|
|
||||||
|
let decoded: DataUsageEntry = rmp_serde::from_slice(&encoded).expect("current reader should default the missing field");
|
||||||
|
|
||||||
|
assert_eq!(decoded.size, 12);
|
||||||
|
assert_eq!(decoded.failed_objects, 2);
|
||||||
|
assert!(decoded.all_tier_stats.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_path_uses_portable_slash_semantics() {
|
fn hash_path_uses_portable_slash_semantics() {
|
||||||
for (input, expected) in [
|
for (input, expected) in [
|
||||||
@@ -1901,6 +2137,44 @@ mod tests {
|
|||||||
assert_eq!(info.buckets_count, 2);
|
assert_eq!(info.buckets_count, 2);
|
||||||
assert!(info.buckets_usage.is_empty());
|
assert!(info.buckets_usage.is_empty());
|
||||||
assert_eq!(info.objects_total_count, 3);
|
assert_eq!(info.objects_total_count, 3);
|
||||||
|
assert!(info.tier_stats.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dui_reports_tier_usage_from_the_flattened_tree() {
|
||||||
|
let root_hash = hash_path("root");
|
||||||
|
let bucket_hash = hash_path("bucket-a");
|
||||||
|
let mut cache = DataUsageCache {
|
||||||
|
info: DataUsageCacheInfo {
|
||||||
|
name: "root".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
cache.replace_hashed(&root_hash, &None, &DataUsageEntry::default());
|
||||||
|
cache.replace_hashed(
|
||||||
|
&bucket_hash,
|
||||||
|
&Some(root_hash),
|
||||||
|
&tier_entry(
|
||||||
|
"WARM",
|
||||||
|
TierStats {
|
||||||
|
total_size: 40,
|
||||||
|
num_versions: 2,
|
||||||
|
num_objects: 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let info = cache.dui("root", &["bucket-a".to_string()]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info.tier_stats.expect("child tier usage should roll up to the root").tiers["WARM"],
|
||||||
|
TierStats {
|
||||||
|
total_size: 40,
|
||||||
|
num_versions: 2,
|
||||||
|
num_objects: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -26,75 +26,16 @@
|
|||||||
//! Later batches tracked on backlog#1154: config get/set, info, pools status,
|
//! Later batches tracked on backlog#1154: config get/set, info, pools status,
|
||||||
//! group lifecycle, import/export IAM.
|
//! group lifecycle, import/export IAM.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|
||||||
use rustfs_signer::sign_v4;
|
|
||||||
use s3s::Body;
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
type BoxError = Box<dyn Error + Send + Sync>;
|
|
||||||
|
|
||||||
/// Signs and sends an admin HTTP request with the given credential, returning
|
|
||||||
/// status and body. Native `/rustfs/admin/v3` requests and responses are plain
|
|
||||||
/// JSON (the MinIO-compat encryption applies only to `/minio/admin/v3` paths).
|
|
||||||
async fn admin_request(
|
|
||||||
base_url: &str,
|
|
||||||
method: http::Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
body: Option<String>,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
) -> Result<(StatusCode, String), BoxError> {
|
|
||||||
let url = format!("{base_url}{path_and_query}");
|
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
|
||||||
let mut builder = http::Request::builder()
|
|
||||||
.method(method.clone())
|
|
||||||
.uri(uri)
|
|
||||||
.header(HOST, authority)
|
|
||||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
if body.is_some() {
|
|
||||||
builder = builder.header(CONTENT_TYPE, "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = body.as_ref().map(|b| b.len() as i64).unwrap_or_default();
|
|
||||||
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
|
||||||
let mut request = local_http_client().request(reqwest_method, &url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request = request.header(name, value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request = request.body(body);
|
|
||||||
}
|
|
||||||
let response = request.send().await?;
|
|
||||||
let status = response.status();
|
|
||||||
let text = response.text().await.unwrap_or_default();
|
|
||||||
Ok((status, text))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Root-credential admin request that must succeed; returns the response body.
|
|
||||||
async fn admin_ok(
|
|
||||||
env: &RustFSTestEnvironment,
|
|
||||||
method: http::Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
body: Option<String>,
|
|
||||||
) -> Result<String, BoxError> {
|
|
||||||
let (status, text) = admin_request(&env.url, method.clone(), path_and_query, body, &env.access_key, &env.secret_key).await?;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(format!("{method} {path_and_query} failed: {status} {text}").into());
|
|
||||||
}
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_s3_client(url: &str, access_key: &str, secret_key: &str) -> Client {
|
fn build_s3_client(url: &str, access_key: &str, secret_key: &str) -> Client {
|
||||||
let config = Config::builder()
|
let config = Config::builder()
|
||||||
|
|||||||
@@ -24,7 +24,12 @@
|
|||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -45,6 +50,21 @@ pub const ENV_RUSTFS_BUILD_FEATURES: &str = "RUSTFS_BUILD_FEATURES";
|
|||||||
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
||||||
const RUSTFS_FULL_FEATURE: &str = "full";
|
const RUSTFS_FULL_FEATURE: &str = "full";
|
||||||
|
|
||||||
|
fn capture_log_path(log_dir: &Path, temp_dir: &str) -> Option<PathBuf> {
|
||||||
|
let temp_name = Path::new(temp_dir).file_name()?.to_string_lossy();
|
||||||
|
Some(log_dir.join(format!("{temp_name}.log")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
|
||||||
|
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR")?;
|
||||||
|
if stdfs::create_dir_all(&log_dir).is_err() {
|
||||||
|
warn!(?log_dir, "failed to create configured E2E server log directory");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config {
|
fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config {
|
||||||
let credentials = Credentials::new(access_key, secret_key, None, None, provider_name);
|
let credentials = Credentials::new(access_key, secret_key, None, None, provider_name);
|
||||||
let mut config = Config::builder()
|
let mut config = Config::builder()
|
||||||
@@ -75,6 +95,58 @@ pub fn local_http_client() -> HttpClient {
|
|||||||
.expect("failed to build local reqwest client")
|
.expect("failed to build local reqwest client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signs and sends an admin HTTP request with the given credentials.
|
||||||
|
pub(crate) async fn admin_request(
|
||||||
|
base_url: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let url = format!("{base_url}{path_and_query}");
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||||
|
let mut request = http::Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
if body.is_some() {
|
||||||
|
request = request.header(CONTENT_TYPE, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "admin request body is too large")?;
|
||||||
|
let signed = sign_v4(request.body(Body::empty())?, content_length, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let mut request = local_http_client().request(method, &url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.body(body);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
Ok((status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a root-credential admin request and returns its successful response body.
|
||||||
|
pub(crate) async fn admin_ok(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
method: http::Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (status, response_body) =
|
||||||
|
admin_request(&env.url, method.clone(), path_and_query, body, &env.access_key, &env.secret_key).await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("{method} {path_and_query} failed: {status} {response_body}").into());
|
||||||
|
}
|
||||||
|
Ok(response_body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the RustFS binary relative to the workspace.
|
/// Resolve the RustFS binary relative to the workspace.
|
||||||
pub fn rustfs_binary_path() -> PathBuf {
|
pub fn rustfs_binary_path() -> PathBuf {
|
||||||
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
||||||
@@ -304,6 +376,7 @@ impl RustFSTestEnvironment {
|
|||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let temp_dir = format!("/tmp/rustfs_e2e_test_{}", Uuid::new_v4());
|
let temp_dir = format!("/tmp/rustfs_e2e_test_{}", Uuid::new_v4());
|
||||||
fs::create_dir_all(&temp_dir).await?;
|
fs::create_dir_all(&temp_dir).await?;
|
||||||
|
let capture_log_path = configured_capture_log_path(&temp_dir);
|
||||||
|
|
||||||
// Use a unique port for each test environment
|
// Use a unique port for each test environment
|
||||||
let port = Self::find_available_port().await?;
|
let port = Self::find_available_port().await?;
|
||||||
@@ -317,7 +390,7 @@ impl RustFSTestEnvironment {
|
|||||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||||
process: None,
|
process: None,
|
||||||
capture_log_path: None,
|
capture_log_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +398,7 @@ impl RustFSTestEnvironment {
|
|||||||
pub async fn with_address(address: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn with_address(address: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let temp_dir = format!("/tmp/rustfs_e2e_test_{}", Uuid::new_v4());
|
let temp_dir = format!("/tmp/rustfs_e2e_test_{}", Uuid::new_v4());
|
||||||
fs::create_dir_all(&temp_dir).await?;
|
fs::create_dir_all(&temp_dir).await?;
|
||||||
|
let capture_log_path = configured_capture_log_path(&temp_dir);
|
||||||
|
|
||||||
let url = format!("http://{address}");
|
let url = format!("http://{address}");
|
||||||
|
|
||||||
@@ -335,7 +409,7 @@ impl RustFSTestEnvironment {
|
|||||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||||
process: None,
|
process: None,
|
||||||
capture_log_path: None,
|
capture_log_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1335,6 +1409,14 @@ mod tests {
|
|||||||
assert_eq!(normalize_rustfs_build_features(" , "), None);
|
assert_eq!(normalize_rustfs_build_features(" , "), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_log_path_uses_temp_directory_basename() {
|
||||||
|
assert_eq!(
|
||||||
|
capture_log_path(Path::new("/tmp/e2e-logs"), "/tmp/rustfs_e2e_test_abc"),
|
||||||
|
Some(PathBuf::from("/tmp/e2e-logs/rustfs_e2e_test_abc.log"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_feature_enables_any_required_feature() {
|
fn full_feature_enables_any_required_feature() {
|
||||||
assert!(rustfs_build_feature_enabled(Some("full"), "sftp"));
|
assert!(rustfs_build_feature_enabled(Some("full"), "sftp"));
|
||||||
|
|||||||
@@ -117,9 +117,14 @@ mod tests {
|
|||||||
.key("assets/explicit-copy.js")
|
.key("assets/explicit-copy.js")
|
||||||
.copy_source(format!("{bucket}/{key}"))
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
.metadata_directive(MetadataDirective::Copy)
|
.metadata_directive(MetadataDirective::Copy)
|
||||||
|
.customize()
|
||||||
|
.mutate_request(|request| {
|
||||||
|
request.headers_mut().insert("content-type", "application/octet-stream");
|
||||||
|
request.headers_mut().insert("x-amz-meta-request-only", "ignored");
|
||||||
|
})
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("explicit COPY directive failed");
|
.expect("explicit COPY directive with request metadata failed");
|
||||||
let explicit_copy_head = client
|
let explicit_copy_head = client
|
||||||
.head_object()
|
.head_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -128,6 +133,18 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("HEAD failed after explicit COPY");
|
.expect("HEAD failed after explicit COPY");
|
||||||
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
||||||
|
assert_eq!(explicit_copy_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||||
|
assert_eq!(
|
||||||
|
explicit_copy_head.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||||
|
Some(&"1777992333".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explicit_copy_head
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("request-only")),
|
||||||
|
None,
|
||||||
|
"COPY must ignore request metadata"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
explicit_copy_head.website_redirect_location(),
|
explicit_copy_head.website_redirect_location(),
|
||||||
None,
|
None,
|
||||||
@@ -571,20 +588,6 @@ mod tests {
|
|||||||
Some("InvalidArgument")
|
Some("InvalidArgument")
|
||||||
);
|
);
|
||||||
|
|
||||||
let ignored_replacement = client
|
|
||||||
.copy_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.copy_source(format!("{bucket}/{key}"))
|
|
||||||
.content_type("application/ignored")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect_err("Replacement fields without REPLACE should be rejected");
|
|
||||||
assert_eq!(
|
|
||||||
ignored_replacement.as_service_error().and_then(|error| error.code()),
|
|
||||||
Some("InvalidRequest")
|
|
||||||
);
|
|
||||||
|
|
||||||
let unchanged = client
|
let unchanged = client
|
||||||
.get_object()
|
.get_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn assert_current_list_hides_delete_marker(client: &Client, bucket: &str, key: &str) {
|
||||||
|
let listed = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("list current objects after delete marker");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
listed.contents().iter().all(|object| object.key() != Some(key)),
|
||||||
|
"ListObjectsV2 must hide an object whose latest version is a delete marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
|
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
|
||||||
@@ -94,6 +109,7 @@ mod tests {
|
|||||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||||
assert_eq!(markers[0].is_latest(), Some(true));
|
assert_eq!(markers[0].is_latest(), Some(true));
|
||||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||||
|
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -118,6 +134,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("put historical version");
|
.expect("put historical version");
|
||||||
let data_version_id = put.version_id().expect("put should return data version id");
|
let data_version_id = put.version_id().expect("put should return data version id");
|
||||||
|
let listed_before_delete = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("list current object before creating delete marker");
|
||||||
|
assert!(
|
||||||
|
listed_before_delete.contents().iter().any(|object| object.key() == Some(key)),
|
||||||
|
"ListObjectsV2 must include the current object before it is deleted"
|
||||||
|
);
|
||||||
|
|
||||||
let delete_marker = client
|
let delete_marker = client
|
||||||
.delete_object()
|
.delete_object()
|
||||||
@@ -145,6 +172,7 @@ mod tests {
|
|||||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||||
assert_eq!(markers[0].is_latest(), Some(true));
|
assert_eq!(markers[0].is_latest(), Some(true));
|
||||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||||
|
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||||
|
|
||||||
let historical = client
|
let historical = client
|
||||||
.get_object()
|
.get_object()
|
||||||
|
|||||||
@@ -126,7 +126,55 @@ async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_
|
|||||||
assert_eq!(cancelled["success"], true);
|
assert_eq!(cancelled["success"], true);
|
||||||
assert_eq!(cancelled["key_metadata"]["key_state"], "Enabled");
|
assert_eq!(cancelled["key_metadata"]["key_state"], "Enabled");
|
||||||
|
|
||||||
let removed = kms_admin_request(
|
// A window outside 7-30 days is refused at the endpoint, whatever the
|
||||||
|
// backend: the bound is enforced once in the service, so no backend can
|
||||||
|
// stretch or skip it (rustfs/backlog#1585).
|
||||||
|
for days in [6, 31] {
|
||||||
|
let refused = kms_admin_request(
|
||||||
|
base_url,
|
||||||
|
http::Method::DELETE,
|
||||||
|
"/rustfs/admin/v3/kms/keys/delete",
|
||||||
|
Some(
|
||||||
|
&serde_json::json!({
|
||||||
|
"key_id": key_id,
|
||||||
|
"pending_window_in_days": days
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.ok_or_else(|| format!("a {days}-day deletion window must be refused"))?;
|
||||||
|
assert!(
|
||||||
|
refused.to_string().contains("400 Bad Request"),
|
||||||
|
"a {days}-day deletion window must report a client error: {refused}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate deletion is no longer reachable through the query string, so it
|
||||||
|
// fails before the service gate is even consulted.
|
||||||
|
let refused = kms_admin_request(
|
||||||
|
base_url,
|
||||||
|
http::Method::DELETE,
|
||||||
|
&format!("/rustfs/admin/v3/kms/keys/delete?keyId={key_id}&force_immediate=true"),
|
||||||
|
None,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.ok_or("immediate KMS key deletion must not be reachable through the query string")?;
|
||||||
|
assert!(
|
||||||
|
refused.to_string().contains("400 Bad Request"),
|
||||||
|
"a query-string immediate deletion must report a client error: {refused}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A default server refuses to skip the waiting window (rustfs/backlog#1585):
|
||||||
|
// immediate deletion is unrecoverable and takes every object encrypted under
|
||||||
|
// the key with it, so the endpoint must reject it rather than honour it.
|
||||||
|
let refused = kms_admin_request(
|
||||||
base_url,
|
base_url,
|
||||||
http::Method::DELETE,
|
http::Method::DELETE,
|
||||||
"/rustfs/admin/v3/kms/keys/delete",
|
"/rustfs/admin/v3/kms/keys/delete",
|
||||||
@@ -140,37 +188,49 @@ async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_
|
|||||||
access_key,
|
access_key,
|
||||||
secret_key,
|
secret_key,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
.err()
|
||||||
assert_eq!(removed["success"], true);
|
.ok_or("immediate KMS key deletion must be refused on a default server")?;
|
||||||
|
assert!(
|
||||||
|
refused.to_string().contains("400 Bad Request"),
|
||||||
|
"refused immediate deletion must report a client error: {refused}"
|
||||||
|
);
|
||||||
|
|
||||||
let listed =
|
// The refused requests left the key alone, so the window-bounded path still
|
||||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
// has something to schedule.
|
||||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
let described = kms_admin_request(
|
||||||
assert_eq!(listed["success"], true);
|
base_url,
|
||||||
let keys = listed["keys"]
|
http::Method::GET,
|
||||||
.as_array()
|
&format!("/rustfs/admin/v3/kms/keys/{key_id}"),
|
||||||
.ok_or("list KMS keys response omitted keys after deletion")?;
|
None,
|
||||||
if let Some(key) = keys.iter().find(|key| key["key_id"] == key_id) {
|
access_key,
|
||||||
assert_eq!(key["status"], "PendingDeletion", "a retained force-deleted key must be pending deletion");
|
secret_key,
|
||||||
let removed = kms_admin_request(
|
)
|
||||||
base_url,
|
.await?;
|
||||||
http::Method::DELETE,
|
let described: serde_json::Value = serde_json::from_str(&described)?;
|
||||||
"/rustfs/admin/v3/kms/keys/delete",
|
assert_eq!(
|
||||||
Some(
|
described["key_metadata"]["key_state"], "Enabled",
|
||||||
&serde_json::json!({
|
"a refused immediate deletion must leave the key usable"
|
||||||
"key_id": key_id,
|
);
|
||||||
"force_immediate": true
|
|
||||||
})
|
let rescheduled = kms_admin_request(
|
||||||
.to_string(),
|
base_url,
|
||||||
),
|
http::Method::DELETE,
|
||||||
access_key,
|
"/rustfs/admin/v3/kms/keys/delete",
|
||||||
secret_key,
|
Some(
|
||||||
)
|
&serde_json::json!({
|
||||||
.await?;
|
"key_id": key_id,
|
||||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
"pending_window_in_days": 7
|
||||||
assert_eq!(removed["success"], true);
|
})
|
||||||
}
|
.to_string(),
|
||||||
|
),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let rescheduled: serde_json::Value = serde_json::from_str(&rescheduled)?;
|
||||||
|
assert_eq!(rescheduled["success"], true);
|
||||||
|
assert!(rescheduled["deletion_date"].is_string());
|
||||||
|
|
||||||
let listed =
|
let listed =
|
||||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||||
@@ -179,10 +239,11 @@ async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_
|
|||||||
let keys = listed["keys"]
|
let keys = listed["keys"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or("final list KMS keys response omitted keys after deletion")?;
|
.ok_or("final list KMS keys response omitted keys after deletion")?;
|
||||||
assert!(
|
let key = keys
|
||||||
keys.iter().all(|key| key["key_id"] != key_id),
|
.iter()
|
||||||
"force-deleted KMS key must no longer appear in list"
|
.find(|key| key["key_id"] == key_id)
|
||||||
);
|
.ok_or("a key awaiting its deletion window must still be listed")?;
|
||||||
|
assert_eq!(key["status"], "PendingDeletion", "a scheduled key must be pending deletion");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Regression test: a same-key CopyObject that only rewrites metadata must never re-key a
|
||||||
|
//! managed-SSE (SSE-S3 / SSE-KMS) object.
|
||||||
|
//!
|
||||||
|
//! On an **unversioned** bucket the handler marks a same-name copy `metadata_only`, and the
|
||||||
|
//! store layer then updates `xl.meta` in place without touching the data blocks. The handler
|
||||||
|
//! nevertheless strips the source encryption metadata and generates a *fresh* DEK for the
|
||||||
|
//! destination. Combining the two writes "new DEK + old ciphertext": the object is permanently
|
||||||
|
//! undecryptable. The fix forces a full data rewrite whenever the copy re-derives managed
|
||||||
|
//! encryption material, so the stored bytes always match the key metadata beside them.
|
||||||
|
//!
|
||||||
|
//! Companion to `copy_object_version_restore_sse_test` (issue #4238), which pins the same
|
||||||
|
//! invariant for the versioned historical-restore path.
|
||||||
|
|
||||||
|
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
MetadataDirective, ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
|
||||||
|
ServerSideEncryptionRule,
|
||||||
|
};
|
||||||
|
use serial_test::serial;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_metadata_replace_self_copy_of_sse_object_stays_decryptable() {
|
||||||
|
init_logging();
|
||||||
|
info!("same-key CopyObject with REPLACE metadata must not re-key an SSE-S3 object");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await.expect("failed to create local KMS env");
|
||||||
|
let default_key_id = "rustfs-e2e-test-default-key";
|
||||||
|
let keys_dir = kms_env.kms_keys_dir.clone();
|
||||||
|
create_key_with_specific_id(&keys_dir, default_key_id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create local KMS key");
|
||||||
|
kms_env
|
||||||
|
.base_env
|
||||||
|
.start_rustfs_server_with_env(
|
||||||
|
vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
&keys_dir,
|
||||||
|
"--kms-default-key-id",
|
||||||
|
default_key_id,
|
||||||
|
],
|
||||||
|
&[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failed to start RustFS with local KMS");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let client = kms_env.base_env.create_s3_client();
|
||||||
|
// Deliberately an UNVERSIONED bucket: that is the branch where the store layer can service
|
||||||
|
// the self-copy as a pure metadata update.
|
||||||
|
let bucket = "copy-object-self-copy-sse-test";
|
||||||
|
let key = "secrets/report.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to create bucket");
|
||||||
|
|
||||||
|
// Content long enough that a truncated/garbled decrypt cannot coincidentally match.
|
||||||
|
let content = b"encrypted payload that must survive a metadata-only self copy -- 0123456789";
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("text/plain; charset=utf-8")
|
||||||
|
.metadata("stage", "before")
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT failed");
|
||||||
|
assert_eq!(put.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
// Copy the object onto itself, replacing user metadata. This is the `mc cp --attr` /
|
||||||
|
// "edit metadata in place" shape that AWS supports on an existing object.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.content_type("text/plain; charset=utf-8")
|
||||||
|
.metadata("stage", "after")
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("same-key CopyObject with REPLACE metadata must succeed");
|
||||||
|
assert_eq!(copy_out.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
// The object must still decrypt to the original plaintext. Before the fix the stored
|
||||||
|
// ciphertext was left untouched while the metadata carried a brand-new DEK, so this GET
|
||||||
|
// either failed outright or returned garbage.
|
||||||
|
let get = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET after self-copy failed: the object was re-keyed without rewriting the ciphertext");
|
||||||
|
assert_eq!(get.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(
|
||||||
|
get.metadata().and_then(|m| m.get("stage")),
|
||||||
|
Some(&"after".to_string()),
|
||||||
|
"REPLACE metadata must take effect"
|
||||||
|
);
|
||||||
|
let body = get.body.collect().await.expect("collect body").into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
body.as_ref(),
|
||||||
|
content,
|
||||||
|
"object must still decrypt to the original plaintext after a metadata-only self copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
kms_env.base_env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_metadata_replace_self_copy_dropping_sse_rewrites_plaintext() {
|
||||||
|
init_logging();
|
||||||
|
info!("same-key CopyObject that drops SSE must rewrite the data, not orphan the ciphertext");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await.expect("failed to create local KMS env");
|
||||||
|
let default_key_id = "rustfs-e2e-test-default-key";
|
||||||
|
let keys_dir = kms_env.kms_keys_dir.clone();
|
||||||
|
create_key_with_specific_id(&keys_dir, default_key_id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create local KMS key");
|
||||||
|
kms_env
|
||||||
|
.base_env
|
||||||
|
.start_rustfs_server_with_env(
|
||||||
|
vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
&keys_dir,
|
||||||
|
"--kms-default-key-id",
|
||||||
|
default_key_id,
|
||||||
|
],
|
||||||
|
&[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failed to start RustFS with local KMS");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let client = kms_env.base_env.create_s3_client();
|
||||||
|
// Unversioned, and deliberately WITHOUT a bucket default-encryption rule, so the copy below
|
||||||
|
// resolves to "no destination encryption".
|
||||||
|
let bucket = "copy-object-self-copy-drop-sse-test";
|
||||||
|
let key = "secrets/report.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to create bucket");
|
||||||
|
|
||||||
|
let content = b"encrypted payload whose ciphertext must not survive as bogus plaintext -- 0123456789";
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.metadata("stage", "before")
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT failed");
|
||||||
|
|
||||||
|
// Self-copy with REPLACE and no SSE header. Per AWS semantics the destination ends up
|
||||||
|
// unencrypted. The dangerous outcome is the silent one: the handler strips the source key
|
||||||
|
// metadata while a metadata-only copy leaves the ciphertext in place, so a later GET would
|
||||||
|
// hand back raw ciphertext as if it were plaintext — corruption with no error anywhere.
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.metadata("stage", "after")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("same-key CopyObject dropping SSE must succeed");
|
||||||
|
|
||||||
|
let get = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET after self-copy failed");
|
||||||
|
assert_eq!(
|
||||||
|
get.server_side_encryption(),
|
||||||
|
None,
|
||||||
|
"destination must be unencrypted once the copy drops SSE"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get.metadata().and_then(|m| m.get("stage")),
|
||||||
|
Some(&"after".to_string()),
|
||||||
|
"REPLACE metadata must take effect"
|
||||||
|
);
|
||||||
|
let body = get.body.collect().await.expect("collect body").into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
body.as_ref(),
|
||||||
|
content,
|
||||||
|
"object must read back as the original plaintext, not the orphaned ciphertext"
|
||||||
|
);
|
||||||
|
|
||||||
|
kms_env.base_env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_metadata_replace_self_copy_under_bucket_default_sse_stays_decryptable() {
|
||||||
|
init_logging();
|
||||||
|
info!("bucket default encryption must also keep a same-key copy off the metadata-only path");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await.expect("failed to create local KMS env");
|
||||||
|
let default_key_id = "rustfs-e2e-test-default-key";
|
||||||
|
let keys_dir = kms_env.kms_keys_dir.clone();
|
||||||
|
create_key_with_specific_id(&keys_dir, default_key_id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create local KMS key");
|
||||||
|
kms_env
|
||||||
|
.base_env
|
||||||
|
.start_rustfs_server_with_env(
|
||||||
|
vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
&keys_dir,
|
||||||
|
"--kms-default-key-id",
|
||||||
|
default_key_id,
|
||||||
|
],
|
||||||
|
&[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failed to start RustFS with local KMS");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let client = kms_env.base_env.create_s3_client();
|
||||||
|
let bucket = "copy-object-self-copy-bucket-default-sse-test";
|
||||||
|
let key = "secrets/report.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to create bucket");
|
||||||
|
|
||||||
|
// Store the object as PLAINTEXT first: no SSE header and no bucket default rule yet. This is
|
||||||
|
// what makes the case sharp — at copy time the source metadata carries no encryption markers,
|
||||||
|
// so the source-side half of the guard cannot fire.
|
||||||
|
let content = b"plaintext payload that must not be orphaned under a new DEK -- 0123456789";
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.metadata("stage", "before")
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT failed");
|
||||||
|
assert_eq!(put.server_side_encryption(), None, "the object must start out unencrypted");
|
||||||
|
|
||||||
|
// Only NOW enable bucket default encryption. The destination's encryption therefore comes
|
||||||
|
// from the bucket rule and from nowhere else: the source is unencrypted and the copy request
|
||||||
|
// carries no SSE header. A guard that only inspects request headers (MinIO decides
|
||||||
|
// `isTargetEncrypted` from `crypto.S3.IsRequested(r.Header)`) would let this through, yet
|
||||||
|
// `sse_encryption` still mints a fresh DEK from the resolved bucket default — which is why
|
||||||
|
// the guard keys off the *effective* encryption rather than the requested one.
|
||||||
|
let encryption_config = ServerSideEncryptionConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
ServerSideEncryptionRule::builder()
|
||||||
|
.apply_server_side_encryption_by_default(
|
||||||
|
ServerSideEncryptionByDefault::builder()
|
||||||
|
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
client
|
||||||
|
.put_bucket_encryption()
|
||||||
|
.bucket(bucket)
|
||||||
|
.server_side_encryption_configuration(encryption_config)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to set bucket default encryption");
|
||||||
|
|
||||||
|
// No SSE header on the copy — the bucket default alone drives the destination encryption.
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.metadata("stage", "after")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("same-key CopyObject under bucket default encryption must succeed");
|
||||||
|
|
||||||
|
let get = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET after self-copy failed: the object was re-keyed without rewriting the ciphertext");
|
||||||
|
assert_eq!(get.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(
|
||||||
|
get.metadata().and_then(|m| m.get("stage")),
|
||||||
|
Some(&"after".to_string()),
|
||||||
|
"REPLACE metadata must take effect"
|
||||||
|
);
|
||||||
|
let body = get.body.collect().await.expect("collect body").into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
body.as_ref(),
|
||||||
|
content,
|
||||||
|
"object must still decrypt to the original plaintext after a metadata-only self copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
kms_env.base_env.stop_server();
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Negative authorization matrix for per-key KMS access control.
|
||||||
|
//!
|
||||||
|
//! Every case here is an end-to-end denial that the pre-`kms` resource server
|
||||||
|
//! allowed, so a regression that reopens one of them fails this file rather than
|
||||||
|
//! only a unit test. The matrix varies one dimension at a time:
|
||||||
|
//!
|
||||||
|
//! - **wrong identity**: a caller holding S3 rights but no `kms` grant at all
|
||||||
|
//! - **wrong key**: a caller scoped to key A naming key B
|
||||||
|
//! - **wrong action**: a caller holding `kms:GenerateDataKey` but not `kms:Decrypt`
|
||||||
|
//! (and, on the admin plane, `kms:DisableKey` but not `kms:RotateKey`)
|
||||||
|
//! - **wrong context**: an explicit `Deny` beating a wildcard `Allow`, and SSE-S3
|
||||||
|
//! staying exempt from `kms` authorization
|
||||||
|
//!
|
||||||
|
//! Each matrix opens with a positive control. Without it a denial proves nothing:
|
||||||
|
//! an identity whose policy has not propagated yet is denied everything.
|
||||||
|
|
||||||
|
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
|
||||||
|
use crate::common::{admin_ok, admin_request, init_logging};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::config::{Config, Credentials, Region};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
const ALLOWED_KEY: &str = "kms-matrix-allowed-key";
|
||||||
|
const OTHER_KEY: &str = "kms-matrix-other-key";
|
||||||
|
const BUCKET: &str = "kms-authz-matrix";
|
||||||
|
const SECRET: &str = "kms-matrix-secret";
|
||||||
|
const PAYLOAD: &[u8] = b"kms authorization matrix payload";
|
||||||
|
|
||||||
|
/// How long an identity change may take to reach the request path.
|
||||||
|
const IAM_PROPAGATION: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
fn s3_client(url: &str, access_key: &str, secret_key: &str) -> Client {
|
||||||
|
let config = Config::builder()
|
||||||
|
.credentials_provider(Credentials::new(access_key, secret_key, None, None, "kms-authz-matrix"))
|
||||||
|
.region(Region::new("us-east-1"))
|
||||||
|
.endpoint_url(url)
|
||||||
|
.force_path_style(true)
|
||||||
|
.behavior_version_latest()
|
||||||
|
.build();
|
||||||
|
Client::from_conf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a server whose SSE-KMS data path authorizes against the named key.
|
||||||
|
///
|
||||||
|
/// The enforcement switch defaults to off for compatibility, so it has to be set
|
||||||
|
/// explicitly; without it every negative case below would silently pass as an allow.
|
||||||
|
async fn start_enforcing_server(env: &mut LocalKMSTestEnvironment, extra_env: &[(&str, &str)]) -> TestResult {
|
||||||
|
create_key_with_specific_id(&env.kms_keys_dir, ALLOWED_KEY).await?;
|
||||||
|
create_key_with_specific_id(&env.kms_keys_dir, OTHER_KEY).await?;
|
||||||
|
|
||||||
|
let key_dir = env.kms_keys_dir.clone();
|
||||||
|
let args = vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
key_dir.as_str(),
|
||||||
|
"--kms-default-key-id",
|
||||||
|
ALLOWED_KEY,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut envs = vec![("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")];
|
||||||
|
envs.extend_from_slice(extra_env);
|
||||||
|
|
||||||
|
env.base_env.start_rustfs_server_with_env(args, &envs).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `user` with `policy_document` attached under a canned policy of the same name.
|
||||||
|
async fn provision_user(env: &LocalKMSTestEnvironment, user: &str, policy_document: &str) -> TestResult {
|
||||||
|
admin_ok(
|
||||||
|
&env.base_env,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/add-canned-policy?name={user}"),
|
||||||
|
Some(policy_document.to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
provision_user_with_policy(env, user, user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `user` and attach an existing policy (built-in or canned) by name.
|
||||||
|
async fn provision_user_with_policy(env: &LocalKMSTestEnvironment, user: &str, policy_name: &str) -> TestResult {
|
||||||
|
admin_ok(
|
||||||
|
&env.base_env,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/add-user?accessKey={user}"),
|
||||||
|
Some(serde_json::json!({ "secretKey": SECRET, "status": "enabled" }).to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
admin_ok(
|
||||||
|
&env.base_env,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={user}&isGroup=false"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The S3 half of every data-path policy below: full object access, no KMS grant.
|
||||||
|
fn s3_full_access_statement() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_document(statements: Vec<serde_json::Value>) -> String {
|
||||||
|
serde_json::json!({ "Version": "2012-10-17", "Statement": statements }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_sse_kms(client: &Client, key: &str, kms_key_id: &str) -> Result<(), aws_sdk_s3::Error> {
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(PAYLOAD))
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id(kms_key_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(aws_sdk_s3::Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert the operation failed with `AccessDenied` rather than any other error.
|
||||||
|
///
|
||||||
|
/// A bare `is_err` would also accept `KMSKeyDisabled` or an internal error, which
|
||||||
|
/// would hide both a leak of key state and an outage masquerading as a denial.
|
||||||
|
fn assert_access_denied<T: std::fmt::Debug>(result: Result<T, aws_sdk_s3::Error>, what: &str) {
|
||||||
|
let error = result.expect_err(&format!("{what} must be denied"));
|
||||||
|
assert_eq!(error.code(), Some("AccessDenied"), "{what} must fail with AccessDenied: {error:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry an SSE-KMS write until the identity's policy has reached the request path.
|
||||||
|
async fn wait_for_sse_kms_write(client: &Client, key: &str, kms_key_id: &str) -> TestResult {
|
||||||
|
let deadline = tokio::time::Instant::now() + IAM_PROPAGATION;
|
||||||
|
loop {
|
||||||
|
match put_sse_kms(client, key, kms_key_id).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) if tokio::time::Instant::now() >= deadline => {
|
||||||
|
return Err(format!("positive control never became authorized: {error:?}").into());
|
||||||
|
}
|
||||||
|
Err(_) => tokio::time::sleep(Duration::from_millis(500)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry an admin call until it stops returning 403, i.e. the policy is live.
|
||||||
|
async fn wait_for_admin_success(
|
||||||
|
env: &LocalKMSTestEnvironment,
|
||||||
|
user: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
) -> TestResult {
|
||||||
|
let deadline = tokio::time::Instant::now() + IAM_PROPAGATION;
|
||||||
|
loop {
|
||||||
|
let (status, response) = admin_request(&env.base_env.url, method.clone(), path, body.clone(), user, SECRET).await?;
|
||||||
|
if status.is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("positive control never became authorized: {method} {path} -> {status} {response}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_admin_denied(
|
||||||
|
env: &LocalKMSTestEnvironment,
|
||||||
|
user: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
what: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
let (status, response) = admin_request(&env.base_env.url, method, path, body, user, SECRET).await?;
|
||||||
|
assert_eq!(status.as_u16(), 403, "{what} must be denied, got {status}: {response}");
|
||||||
|
assert!(response.contains("AccessDenied"), "{what} must carry AccessDenied: {response}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_body(key_id: &str) -> String {
|
||||||
|
serde_json::json!({ "key_id": key_id }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data-path matrix: SSE-KMS writes and reads are authorized against the resolved key.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn sse_kms_per_key_authorization_negative_matrix() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
start_enforcing_server(&mut env, &[("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true")]).await?;
|
||||||
|
env.base_env.create_test_bucket(BUCKET).await?;
|
||||||
|
|
||||||
|
// Scoped to ALLOWED_KEY only.
|
||||||
|
provision_user(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixscoped",
|
||||||
|
&policy_document(vec![
|
||||||
|
s3_full_access_statement(),
|
||||||
|
serde_json::json!({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
|
||||||
|
"Resource": [format!("arn:aws:kms:::key/{ALLOWED_KEY}")]
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// S3 rights only: the identity shape that existed before per-key authorization.
|
||||||
|
provision_user(&env, "kmsmatrixs3only", &policy_document(vec![s3_full_access_statement()])).await?;
|
||||||
|
// May wrap a data key but may never unwrap one.
|
||||||
|
provision_user(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixwriter",
|
||||||
|
&policy_document(vec![
|
||||||
|
s3_full_access_statement(),
|
||||||
|
serde_json::json!({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:GenerateDataKey"],
|
||||||
|
"Resource": ["arn:aws:kms:::*"]
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Wildcard allow, explicit deny on one key.
|
||||||
|
provision_user(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixdenied",
|
||||||
|
&policy_document(vec![
|
||||||
|
s3_full_access_statement(),
|
||||||
|
serde_json::json!({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:*"],
|
||||||
|
"Resource": ["arn:aws:kms:::*"]
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Action": ["kms:*"],
|
||||||
|
"Resource": [format!("arn:aws:kms:::key/{OTHER_KEY}")]
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let scoped = s3_client(&env.base_env.url, "kmsmatrixscoped", SECRET);
|
||||||
|
let s3_only = s3_client(&env.base_env.url, "kmsmatrixs3only", SECRET);
|
||||||
|
let writer = s3_client(&env.base_env.url, "kmsmatrixwriter", SECRET);
|
||||||
|
let denied = s3_client(&env.base_env.url, "kmsmatrixdenied", SECRET);
|
||||||
|
|
||||||
|
// --- positive control -----------------------------------------------------
|
||||||
|
wait_for_sse_kms_write(&scoped, "scoped/allowed", ALLOWED_KEY).await?;
|
||||||
|
let read = scoped.get_object().bucket(BUCKET).key("scoped/allowed").send().await?;
|
||||||
|
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), PAYLOAD);
|
||||||
|
info!("positive control: scoped identity may write and read under its own key");
|
||||||
|
|
||||||
|
// --- wrong key ------------------------------------------------------------
|
||||||
|
assert_access_denied(
|
||||||
|
put_sse_kms(&scoped, "scoped/other", OTHER_KEY).await,
|
||||||
|
"SSE-KMS write under a key outside the identity's scope",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- wrong identity -------------------------------------------------------
|
||||||
|
assert_access_denied(
|
||||||
|
put_sse_kms(&s3_only, "s3only/allowed", ALLOWED_KEY).await,
|
||||||
|
"SSE-KMS write by an identity holding no kms grant",
|
||||||
|
);
|
||||||
|
// The object the scoped identity wrote is readable by its owner only.
|
||||||
|
assert_access_denied(
|
||||||
|
s3_only
|
||||||
|
.get_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key("scoped/allowed")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(aws_sdk_s3::Error::from),
|
||||||
|
"SSE-KMS read by an identity holding no kms grant",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- wrong action ---------------------------------------------------------
|
||||||
|
wait_for_sse_kms_write(&writer, "writer/allowed", ALLOWED_KEY).await?;
|
||||||
|
assert_access_denied(
|
||||||
|
writer
|
||||||
|
.get_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key("writer/allowed")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(aws_sdk_s3::Error::from),
|
||||||
|
"SSE-KMS read by an identity holding kms:GenerateDataKey but not kms:Decrypt",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- wrong context: explicit Deny beats a wildcard Allow -------------------
|
||||||
|
wait_for_sse_kms_write(&denied, "denied/allowed", ALLOWED_KEY).await?;
|
||||||
|
assert_access_denied(
|
||||||
|
put_sse_kms(&denied, "denied/other", OTHER_KEY).await,
|
||||||
|
"SSE-KMS write under a key covered by an explicit Deny",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- wrong context: SSE-S3 is out of scope --------------------------------
|
||||||
|
// SSE-S3 wraps its data key with a server-owned key the caller never names, so
|
||||||
|
// it must stay reachable for an identity with no kms grant at all.
|
||||||
|
s3_only
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key("s3only/sse-s3")
|
||||||
|
.body(ByteStream::from_static(PAYLOAD))
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let sse_s3_read = s3_only.get_object().bucket(BUCKET).key("s3only/sse-s3").send().await?;
|
||||||
|
assert_eq!(sse_s3_read.body.collect().await?.into_bytes().as_ref(), PAYLOAD);
|
||||||
|
|
||||||
|
// ... and so must an unencrypted object.
|
||||||
|
s3_only
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key("s3only/plain")
|
||||||
|
.body(ByteStream::from_static(PAYLOAD))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
s3_only.get_object().bucket(BUCKET).key("s3only/plain").send().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin-plane matrix: KMS key endpoints are authorized against the key they name.
|
||||||
|
///
|
||||||
|
/// Runs without the SSE enforcement switch: admin scoping is unconditional, and
|
||||||
|
/// leaving the switch off proves the two planes are independent.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn kms_admin_per_key_authorization_negative_matrix() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
start_enforcing_server(&mut env, &[]).await?;
|
||||||
|
|
||||||
|
// Built-in role templates, attached by name.
|
||||||
|
provision_user_with_policy(&env, "kmsmatrixkeyadmin", "KMSKeyAdministrator").await?;
|
||||||
|
provision_user_with_policy(&env, "kmsmatrixauditor", "KMSAuditor").await?;
|
||||||
|
// A narrowed copy of the administrator template, scoped to one key.
|
||||||
|
provision_user(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixscopedadmin",
|
||||||
|
&policy_document(vec![serde_json::json!({
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["kms:DisableKey", "kms:EnableKey"],
|
||||||
|
"Resource": [format!("arn:aws:kms:::key/{ALLOWED_KEY}")]
|
||||||
|
})]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- positive control -----------------------------------------------------
|
||||||
|
wait_for_admin_success(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixkeyadmin",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/disable",
|
||||||
|
Some(disable_body(OTHER_KEY)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
admin_request(
|
||||||
|
&env.base_env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/enable",
|
||||||
|
Some(disable_body(OTHER_KEY)),
|
||||||
|
"kmsmatrixkeyadmin",
|
||||||
|
SECRET,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- wrong action: the administrator template withholds service-wide powers -
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixkeyadmin",
|
||||||
|
http::Method::GET,
|
||||||
|
"/rustfs/admin/v3/kms/config",
|
||||||
|
None,
|
||||||
|
"KMSKeyAdministrator reading the KMS backend configuration (kms:Configure)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixkeyadmin",
|
||||||
|
http::Method::GET,
|
||||||
|
"/rustfs/admin/v3/kms/backup",
|
||||||
|
None,
|
||||||
|
"KMSKeyAdministrator exporting a backup bundle (kms:Backup)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Separation of duties: managing a key never implies using it.
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixkeyadmin",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/generate-data-key",
|
||||||
|
Some(serde_json::json!({ "key_id": ALLOWED_KEY }).to_string()),
|
||||||
|
"KMSKeyAdministrator generating a data key (kms:GenerateDataKey)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- wrong action: the auditor template is read-only ----------------------
|
||||||
|
wait_for_admin_success(&env, "kmsmatrixauditor", http::Method::GET, "/rustfs/admin/v3/kms/keys", None).await?;
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixauditor",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/disable",
|
||||||
|
Some(disable_body(ALLOWED_KEY)),
|
||||||
|
"KMSAuditor disabling a key (kms:DisableKey)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- wrong key ------------------------------------------------------------
|
||||||
|
wait_for_admin_success(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixscopedadmin",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/disable",
|
||||||
|
Some(disable_body(ALLOWED_KEY)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixscopedadmin",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/disable",
|
||||||
|
Some(disable_body(OTHER_KEY)),
|
||||||
|
"key-scoped administrator disabling a key outside its scope",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// --- wrong action, same key ----------------------------------------------
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixscopedadmin",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/rotate",
|
||||||
|
Some(disable_body(ALLOWED_KEY)),
|
||||||
|
"key-scoped administrator rotating a key it may only enable and disable",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// --- wrong identity -------------------------------------------------------
|
||||||
|
provision_user(&env, "kmsmatrixnokms", &policy_document(vec![s3_full_access_statement()])).await?;
|
||||||
|
assert_admin_denied(
|
||||||
|
&env,
|
||||||
|
"kmsmatrixnokms",
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/disable",
|
||||||
|
Some(disable_body(ALLOWED_KEY)),
|
||||||
|
"identity holding no kms grant disabling a key",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -417,6 +417,22 @@ async fn test_vault_kms_key_crud(
|
|||||||
|
|
||||||
info!("✅ Read: Successfully listed keys, found test key");
|
info!("✅ Read: Successfully listed keys, found test key");
|
||||||
|
|
||||||
|
// A waiting window outside 7-30 days is refused at the endpoint for this
|
||||||
|
// backend too: the bound is enforced once in the service (rustfs/backlog#1585).
|
||||||
|
for days in [6, 31] {
|
||||||
|
let window_error = crate::common::execute_awscurl(
|
||||||
|
&format!("{base_url}/rustfs/admin/v3/kms/keys/delete?keyId={key_id}&pending_window_in_days={days}"),
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.ok_or_else(|| format!("A {days}-day deletion window must be refused"))?;
|
||||||
|
info!("✅ Delete window {} correctly refused: {}", days, window_error);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
let delete_response = crate::common::execute_awscurl(
|
let delete_response = crate::common::execute_awscurl(
|
||||||
&format!("{base_url}/rustfs/admin/v3/kms/keys/delete?keyId={key_id}"),
|
&format!("{base_url}/rustfs/admin/v3/kms/keys/delete?keyId={key_id}"),
|
||||||
@@ -449,29 +465,32 @@ async fn test_vault_kms_key_crud(
|
|||||||
|
|
||||||
info!("✅ Delete verification: Key state correctly changed to: {}", key_state);
|
info!("✅ Delete verification: Key state correctly changed to: {}", key_state);
|
||||||
|
|
||||||
// Force Delete - Force immediate deletion for PendingDeletion key
|
// Force Delete - the query string can no longer ask for immediate deletion,
|
||||||
let force_delete_response = crate::common::execute_awscurl(
|
// and a default server refuses it in any case (rustfs/backlog#1585):
|
||||||
|
// destroying the key material immediately would take every object encrypted
|
||||||
|
// under the key with it.
|
||||||
|
let force_delete_error = crate::common::execute_awscurl(
|
||||||
&format!("{base_url}/rustfs/admin/v3/kms/keys/delete?keyId={key_id}&force_immediate=true"),
|
&format!("{base_url}/rustfs/admin/v3/kms/keys/delete?keyId={key_id}&force_immediate=true"),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
None,
|
None,
|
||||||
access_key,
|
access_key,
|
||||||
secret_key,
|
secret_key,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
.expect_err("Immediate KMS key deletion must be refused on a default server");
|
||||||
|
info!("✅ Force Delete: correctly refused for key {}: {}", key_id, force_delete_error);
|
||||||
|
|
||||||
// Parse and validate the force delete response
|
// The refused request must leave the key exactly as it was: still present,
|
||||||
let force_delete_result: serde_json::Value = serde_json::from_str(&force_delete_response)?;
|
// still pending deletion, still recoverable through cancel-deletion.
|
||||||
assert_eq!(force_delete_result["success"], true, "Force delete operation must return success=true");
|
let describe_after_refusal =
|
||||||
info!("✅ Force Delete: Successfully force deleted key: {}", key_id);
|
crate::common::awscurl_get(&format!("{base_url}/rustfs/admin/v3/kms/keys/{key_id}"), access_key, secret_key).await?;
|
||||||
|
let describe_after_refusal: serde_json::Value = serde_json::from_str(&describe_after_refusal)?;
|
||||||
|
assert_eq!(
|
||||||
|
describe_after_refusal["key_metadata"]["key_state"], "PendingDeletion",
|
||||||
|
"A refused immediate deletion must leave the key pending deletion"
|
||||||
|
);
|
||||||
|
|
||||||
// Verify key no longer exists after force deletion (should return error)
|
info!("✅ Force Delete verification: Key survived the refused immediate deletion");
|
||||||
let describe_force_deleted_result =
|
|
||||||
crate::common::awscurl_get(&format!("{base_url}/rustfs/admin/v3/kms/keys/{key_id}"), access_key, secret_key).await;
|
|
||||||
|
|
||||||
// After force deletion, key should not be found (GET should fail)
|
|
||||||
assert!(describe_force_deleted_result.is_err(), "Force deleted key should not be found");
|
|
||||||
|
|
||||||
info!("✅ Force Delete verification: Key was permanently deleted and is no longer accessible");
|
|
||||||
|
|
||||||
info!("Vault KMS key CRUD operations completed successfully");
|
info!("Vault KMS key CRUD operations completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ mod bucket_default_encryption_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod encryption_metadata_test;
|
mod encryption_metadata_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod copy_object_self_copy_sse_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod copy_object_version_restore_sse_test;
|
mod copy_object_version_restore_sse_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod configured_roundtrip_test;
|
mod configured_roundtrip_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod kms_authorization_negative_matrix_test;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
use super::common::*;
|
use super::common::*;
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
||||||
@@ -2120,6 +2121,127 @@ async fn test_multipart_default_retention_fixed_at_create() {
|
|||||||
// Versioning Auto-Enable Tests
|
// Versioning Auto-Enable Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_unretained_object_lock_object_delete_and_bucket_cleanup() {
|
||||||
|
init_logging();
|
||||||
|
info!("🧪 Test: Unretained Object Lock object delete and bucket cleanup (Issue #5339)");
|
||||||
|
|
||||||
|
let mut env = ObjectLockTestEnvironment::new()
|
||||||
|
.await
|
||||||
|
.expect("failed to create Object Lock test environment");
|
||||||
|
env.start_rustfs().await.expect("failed to start RustFS");
|
||||||
|
|
||||||
|
let bucket = "test-object-lock-delete-cleanup";
|
||||||
|
let key = "unretained-object";
|
||||||
|
|
||||||
|
env.create_object_lock_bucket(bucket)
|
||||||
|
.await
|
||||||
|
.expect("failed to create Object Lock bucket");
|
||||||
|
let client = env.s3_client();
|
||||||
|
|
||||||
|
let put_response = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"unretained data"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to upload unretained object");
|
||||||
|
let object_version_id = put_response
|
||||||
|
.version_id()
|
||||||
|
.expect("Object Lock buckets must create versioned objects")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let delete_response = client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to create delete marker");
|
||||||
|
assert_eq!(delete_response.delete_marker(), Some(true));
|
||||||
|
let delete_marker_version_id = delete_response
|
||||||
|
.version_id()
|
||||||
|
.expect("Deleting without a version ID must create a delete marker")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let get_error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("GET must not return an object hidden by a delete marker");
|
||||||
|
assert_eq!(get_error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(get_error.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
|
||||||
|
|
||||||
|
let listed_objects = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to list current objects");
|
||||||
|
assert!(
|
||||||
|
listed_objects.contents().iter().all(|object| object.key() != Some(key)),
|
||||||
|
"ListObjectsV2 must hide objects whose latest version is a delete marker"
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed_versions = client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to list object versions");
|
||||||
|
assert!(
|
||||||
|
listed_versions
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.key() == Some(key) && version.version_id() == Some(object_version_id.as_str())),
|
||||||
|
"The data version must remain until it is explicitly deleted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed_versions
|
||||||
|
.delete_markers()
|
||||||
|
.iter()
|
||||||
|
.any(|marker| marker.key() == Some(key) && marker.version_id() == Some(delete_marker_version_id.as_str())),
|
||||||
|
"ListObjectVersions must expose the delete marker"
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(object_version_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to delete the data version");
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(delete_marker_version_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to delete the delete marker");
|
||||||
|
|
||||||
|
let remaining_versions = client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to list versions after cleanup");
|
||||||
|
assert!(remaining_versions.versions().is_empty());
|
||||||
|
assert!(remaining_versions.delete_markers().is_empty());
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Deleting every version must remove xl.meta so the bucket can be deleted normally");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_versioning_auto_enabled_with_object_lock() {
|
async fn test_versioning_auto_enabled_with_object_lock() {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@
|
|||||||
|
|
||||||
use super::{grpc_lock_client::GrpcLockClient, grpc_lock_server::spawn_lock_server};
|
use super::{grpc_lock_client::GrpcLockClient, grpc_lock_server::spawn_lock_server};
|
||||||
use rustfs_lock::client::{LockClient, local::LocalClient};
|
use rustfs_lock::client::{LockClient, local::LocalClient};
|
||||||
use rustfs_lock::{
|
use rustfs_lock::{GlobalLockManager, LockInfo, LockRequest, LockResponse, LockStats, LockType, NamespaceLock, ObjectKey};
|
||||||
GlobalLockManager, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockType, NamespaceLock, ObjectKey,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -35,7 +33,11 @@ struct FailingClient;
|
|||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl rustfs_lock::LockClient for FailingClient {
|
impl rustfs_lock::LockClient for FailingClient {
|
||||||
async fn acquire_lock(&self, _request: &rustfs_lock::LockRequest) -> rustfs_lock::Result<LockResponse> {
|
async fn acquire_lock(&self, _request: &rustfs_lock::LockRequest) -> rustfs_lock::Result<LockResponse> {
|
||||||
Err(LockError::internal("simulated gRPC node failure"))
|
// Match RemoteClient's transport-failure response so the coordinator can count this node toward quorum loss.
|
||||||
|
Ok(LockResponse::failure(
|
||||||
|
"Remote lock RPC failed: simulated gRPC node failure",
|
||||||
|
Duration::ZERO,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
|
async fn release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
|
||||||
|
|||||||
@@ -382,7 +382,6 @@ struct ManualTransitionRunReport {
|
|||||||
skipped_delete_marker: u64,
|
skipped_delete_marker: u64,
|
||||||
skipped_directory: u64,
|
skipped_directory: u64,
|
||||||
skipped_replication: u64,
|
skipped_replication: u64,
|
||||||
skipped_already_transitioned: u64,
|
|
||||||
skipped_already_in_flight: u64,
|
skipped_already_in_flight: u64,
|
||||||
skipped_queue_full: u64,
|
skipped_queue_full: u64,
|
||||||
skipped_queue_closed: u64,
|
skipped_queue_closed: u64,
|
||||||
@@ -408,41 +407,6 @@ fn assert_completed_or_in_flight_partial(state: &str, report: &ManualTransitionR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_conflict_winner_report(state: &str, report: &ManualTransitionRunReport, expected_objects: u64, context: &str) {
|
|
||||||
assert_completed_or_in_flight_partial(state, report, context);
|
|
||||||
if report.skipped_already_in_flight > 0 {
|
|
||||||
assert!(
|
|
||||||
report.scanned <= expected_objects,
|
|
||||||
"{context}: scanned more objects than the conflict scope contains: {report:#?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
report.eligible <= expected_objects,
|
|
||||||
"{context}: marked more objects eligible than the conflict scope contains: {report:#?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
report.enqueued + report.skipped_already_in_flight,
|
|
||||||
report.eligible,
|
|
||||||
"{context}: partial in-flight accounting must cover every eligible object: {report:#?}"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert_eq!(report.scanned, expected_objects, "{context}: {report:#?}");
|
|
||||||
assert_eq!(
|
|
||||||
report.eligible + report.skipped_already_transitioned,
|
|
||||||
expected_objects,
|
|
||||||
"{context}: {report:#?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
report.enqueued + report.skipped_already_in_flight,
|
|
||||||
expected_objects,
|
|
||||||
"{context}: {report:#?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
report.transition_completed, report.enqueued,
|
|
||||||
"{context}: winner must wait for all queued transitions: {report:#?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ManualTransitionQueueSnapshot {
|
struct ManualTransitionQueueSnapshot {
|
||||||
queue_capacity: u64,
|
queue_capacity: u64,
|
||||||
@@ -1276,8 +1240,15 @@ async fn test_manual_transition_async_scope_conflicts_report_active_job() -> Tes
|
|||||||
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
||||||
|
|
||||||
let mut hot = RustFSTestEnvironment::new().await?;
|
let mut hot = RustFSTestEnvironment::new().await?;
|
||||||
hot.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_ENABLED", "false"), ("RUSTFS_SCANNER_CYCLE", "3600")])
|
hot.start_rustfs_server_with_env(
|
||||||
.await?;
|
vec![],
|
||||||
|
&[
|
||||||
|
("RUSTFS_SCANNER_ENABLED", "false"),
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "3600"),
|
||||||
|
(MANUAL_TRANSITION_CANCEL_BARRIER_ENV, "1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let hot_client = hot.create_s3_client();
|
let hot_client = hot.create_s3_client();
|
||||||
add_rustfs_tier(&hot, &cold).await?;
|
add_rustfs_tier(&hot, &cold).await?;
|
||||||
|
|
||||||
@@ -1347,20 +1318,21 @@ async fn test_manual_transition_async_scope_conflicts_report_active_job() -> Tes
|
|||||||
assert_eq!(conflict.cancel_endpoint, status_endpoint);
|
assert_eq!(conflict.cancel_endpoint, status_endpoint);
|
||||||
assert!(!conflict.scope_key.is_empty());
|
assert!(!conflict.scope_key.is_empty());
|
||||||
|
|
||||||
|
manual_transition_job_cancel(&hot, cancel_endpoint).await?;
|
||||||
|
|
||||||
let terminal = wait_for_manual_transition_job_terminal(&hot, status_endpoint, MANUAL_ASYNC_CONFLICT_TERMINAL_TIMEOUT).await?;
|
let terminal = wait_for_manual_transition_job_terminal(&hot, status_endpoint, MANUAL_ASYNC_CONFLICT_TERMINAL_TIMEOUT).await?;
|
||||||
assert_eq!(terminal.job_id, job_id);
|
assert_eq!(terminal.job_id, job_id);
|
||||||
|
assert_eq!(terminal.status, "cancelled", "terminal conflict winner response: {terminal:#?}");
|
||||||
assert!(!terminal.report.dry_run);
|
assert!(!terminal.report.dry_run);
|
||||||
assert_eq!(terminal.report.bucket, MANUAL_ASYNC_CONFLICT_BUCKET);
|
assert_eq!(terminal.report.bucket, MANUAL_ASYNC_CONFLICT_BUCKET);
|
||||||
assert_eq!(terminal.report.prefix, accepted.report.prefix);
|
assert_eq!(terminal.report.prefix, accepted.report.prefix);
|
||||||
assert_conflict_winner_report(
|
assert!(terminal.report.cancelled, "terminal conflict winner response: {terminal:#?}");
|
||||||
&terminal.status,
|
assert_eq!(terminal.report.scanned, 0, "terminal conflict winner response: {terminal:#?}");
|
||||||
&terminal.report,
|
assert_eq!(terminal.report.enqueued, 0, "terminal conflict winner response: {terminal:#?}");
|
||||||
MANUAL_ASYNC_CONFLICT_OBJECTS as u64,
|
assert_eq!(
|
||||||
"terminal conflict winner response",
|
terminal.report.transition_completed, 0,
|
||||||
|
"terminal conflict winner response: {terminal:#?}"
|
||||||
);
|
);
|
||||||
assert_eq!(terminal.report.dry_run_eligible, 0, "terminal conflict winner response: {terminal:#?}");
|
|
||||||
assert_eq!(terminal.report.transition_failed, 0, "terminal conflict winner response: {terminal:#?}");
|
|
||||||
assert_eq!(terminal.report.tier_failure, 0, "terminal conflict winner response: {terminal:#?}");
|
|
||||||
let after_remote_count = cold_tier_object_count(&cold_client).await?;
|
let after_remote_count = cold_tier_object_count(&cold_client).await?;
|
||||||
assert!(after_remote_count >= before_remote_count);
|
assert!(after_remote_count >= before_remote_count);
|
||||||
assert!(after_remote_count <= before_remote_count + MANUAL_ASYNC_CONFLICT_OBJECTS);
|
assert!(after_remote_count <= before_remote_count + MANUAL_ASYNC_CONFLICT_OBJECTS);
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ use aws_sdk_s3::types::{
|
|||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_ENCODING, CONTENT_TYPE, HOST};
|
||||||
use http_body_util::{BodyExt, Full};
|
use http_body_util::{BodyExt, Full};
|
||||||
use hyper::body::Incoming;
|
use hyper::body::Incoming;
|
||||||
use hyper::server::conn::http1;
|
use hyper::server::conn::http1;
|
||||||
@@ -38,6 +39,10 @@ use hyper::service::service_fn;
|
|||||||
use hyper::{Request, Response};
|
use hyper::{Request, Response};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
|
use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
|
||||||
|
use opentelemetry_proto::tonic::common::v1::{KeyValue, any_value::Value as AnyValue};
|
||||||
|
use opentelemetry_proto::tonic::metrics::v1::{Metric, metric, number_data_point};
|
||||||
|
use prost::Message;
|
||||||
use rcgen::{
|
use rcgen::{
|
||||||
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
|
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
|
||||||
SanType, generate_simple_self_signed,
|
SanType, generate_simple_self_signed,
|
||||||
@@ -56,6 +61,7 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::io::Read;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -64,11 +70,13 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::{Mutex, watch};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
|
type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64)>>>>;
|
||||||
|
|
||||||
/// A replication source server validates the remote target endpoint, and the e2e
|
/// A replication source server validates the remote target endpoint, and the e2e
|
||||||
/// target runs on loopback (127.0.0.1), which RustFS's SSRF egress guard rejects by
|
/// target runs on loopback (127.0.0.1), which RustFS's SSRF egress guard rejects by
|
||||||
@@ -107,6 +115,252 @@ const REPL17_KMS_KEY_ID: &str = "repl17-local-key";
|
|||||||
const REPL17_SSEC_KEY: &str = "01234567890123456789012345678901";
|
const REPL17_SSEC_KEY: &str = "01234567890123456789012345678901";
|
||||||
const REPLICATION_FAILED_EVENT: &str = "s3:Replication:OperationFailedReplication";
|
const REPLICATION_FAILED_EVENT: &str = "s3:Replication:OperationFailedReplication";
|
||||||
const REPLICATION_EVENT_MAX_BUFFER_BYTES: usize = 1024 * 1024;
|
const REPLICATION_EVENT_MAX_BUFFER_BYTES: usize = 1024 * 1024;
|
||||||
|
const OTLP_METRICS_BODY_LIMIT: u64 = 4 * 1024 * 1024;
|
||||||
|
const BUCKET_LABEL: &str = "bucket";
|
||||||
|
const TOTAL_FAILED_COUNT_METRIC: &str = "rustfs_bucket_replication_total_failed_count";
|
||||||
|
const CURRENT_BACKLOG_COUNT_METRIC: &str = "rustfs_bucket_replication_current_backlog_count";
|
||||||
|
const CURRENT_BACKLOG_BYTES_METRIC: &str = "rustfs_bucket_replication_current_backlog_bytes";
|
||||||
|
const MRF_PENDING_COUNT_METRIC: &str = "rustfs_bucket_replication_mrf_pending_count";
|
||||||
|
const MRF_PENDING_BYTES_METRIC: &str = "rustfs_bucket_replication_mrf_pending_bytes";
|
||||||
|
|
||||||
|
struct ReplicationBacklogMetricCollector {
|
||||||
|
endpoint: String,
|
||||||
|
values: BacklogMetricPoints,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplicationBacklogMetricCollector {
|
||||||
|
async fn start() -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let endpoint = format!("http://{}/v1/metrics", listener.local_addr()?);
|
||||||
|
let values = Arc::new(Mutex::new(BTreeMap::new()));
|
||||||
|
let task_values = values.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let values = task_values.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = http1::Builder::new()
|
||||||
|
.serve_connection(
|
||||||
|
TokioIo::new(stream),
|
||||||
|
service_fn(move |request| handle_backlog_metric_export(request, values.clone())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self { endpoint, values, task })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_endpoint(&self) -> &str {
|
||||||
|
self.endpoint.trim_end_matches("/v1/metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bucket_metric_value(&self, metric: &str, bucket: &str) -> f64 {
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(metric)
|
||||||
|
.and_then(|buckets| buckets.get(bucket))
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_bucket_metric(
|
||||||
|
&self,
|
||||||
|
metric: &str,
|
||||||
|
bucket: &str,
|
||||||
|
expected: impl Fn(f64) -> bool,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<f64, Box<dyn Error + Send + Sync>> {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let value = self.bucket_metric_value(metric, bucket).await;
|
||||||
|
if expected(value) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
let snapshot = self.values.lock().await.clone();
|
||||||
|
return Err(format!("timed out waiting for {metric} on bucket {bucket} to satisfy {description}; last={value}, snapshot={snapshot:?}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReplicationBacklogMetricCollector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_backlog_metric_export(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
values: BacklogMetricPoints,
|
||||||
|
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||||
|
if request.uri().path() != "/v1/metrics" {
|
||||||
|
return Ok(empty_http_response(StatusCode::NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gzip = request
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_ENCODING)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case("gzip"));
|
||||||
|
let Ok(collected) = request.into_body().collect().await else {
|
||||||
|
return Ok(empty_http_response(StatusCode::BAD_REQUEST));
|
||||||
|
};
|
||||||
|
let body = collected.to_bytes();
|
||||||
|
if body.len() as u64 > OTLP_METRICS_BODY_LIMIT {
|
||||||
|
return Ok(empty_http_response(StatusCode::PAYLOAD_TOO_LARGE));
|
||||||
|
}
|
||||||
|
let payload = if gzip {
|
||||||
|
let mut decoder = GzDecoder::new(body.as_ref());
|
||||||
|
let mut decoded = Vec::new();
|
||||||
|
if decoder
|
||||||
|
.by_ref()
|
||||||
|
.take(OTLP_METRICS_BODY_LIMIT + 1)
|
||||||
|
.read_to_end(&mut decoded)
|
||||||
|
.is_err()
|
||||||
|
|| decoded.len() as u64 > OTLP_METRICS_BODY_LIMIT
|
||||||
|
{
|
||||||
|
return Ok(empty_http_response(StatusCode::BAD_REQUEST));
|
||||||
|
}
|
||||||
|
decoded
|
||||||
|
} else {
|
||||||
|
body.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
match ExportMetricsServiceRequest::decode(payload.as_slice()) {
|
||||||
|
Ok(export) => {
|
||||||
|
let mut values = values.lock().await;
|
||||||
|
record_backlog_metrics(&export, &mut values);
|
||||||
|
Ok(empty_http_response(StatusCode::OK))
|
||||||
|
}
|
||||||
|
Err(_) => Ok(empty_http_response(StatusCode::BAD_REQUEST)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_http_response(status: StatusCode) -> Response<Full<Bytes>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.expect("static HTTP response is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_backlog_metrics(export: &ExportMetricsServiceRequest, values: &mut BTreeMap<String, BTreeMap<String, (u64, f64)>>) {
|
||||||
|
for resource_metrics in &export.resource_metrics {
|
||||||
|
for scope_metrics in &resource_metrics.scope_metrics {
|
||||||
|
for metric in &scope_metrics.metrics {
|
||||||
|
record_backlog_metric(metric, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_backlog_metric(metric: &Metric, values: &mut BTreeMap<String, BTreeMap<String, (u64, f64)>>) {
|
||||||
|
if ![
|
||||||
|
TOTAL_FAILED_COUNT_METRIC,
|
||||||
|
CURRENT_BACKLOG_COUNT_METRIC,
|
||||||
|
CURRENT_BACKLOG_BYTES_METRIC,
|
||||||
|
MRF_PENDING_COUNT_METRIC,
|
||||||
|
MRF_PENDING_BYTES_METRIC,
|
||||||
|
]
|
||||||
|
.contains(&metric.name.as_str())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let points = match &metric.data {
|
||||||
|
Some(metric::Data::Gauge(gauge)) => gauge.data_points.as_slice(),
|
||||||
|
Some(metric::Data::Sum(sum)) => sum.data_points.as_slice(),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
for point in points {
|
||||||
|
let Some(bucket) = attribute_string(&point.attributes, BUCKET_LABEL) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(value) = number_point_value(point.value.as_ref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
values
|
||||||
|
.entry(metric.name.clone())
|
||||||
|
.or_default()
|
||||||
|
.entry(bucket.to_string())
|
||||||
|
.and_modify(|current| {
|
||||||
|
if point.time_unix_nano >= current.0 {
|
||||||
|
*current = (point.time_unix_nano, value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert((point.time_unix_nano, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_point_value(value: Option<&number_data_point::Value>) -> Option<f64> {
|
||||||
|
match value? {
|
||||||
|
number_data_point::Value::AsDouble(value) => Some(*value),
|
||||||
|
number_data_point::Value::AsInt(value) => Some(*value as f64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute_string<'a>(attributes: &'a [KeyValue], wanted_key: &str) -> Option<&'a str> {
|
||||||
|
attributes.iter().find_map(|attribute| {
|
||||||
|
if attribute.key != wanted_key {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match attribute.value.as_ref()?.value.as_ref()? {
|
||||||
|
AnyValue::StringValue(value) => Some(value.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SlowReplicationTargetGuard {
|
||||||
|
task: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlowReplicationTargetGuard {
|
||||||
|
async fn bind(address: &str, response_delay: Duration) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||||
|
let listener = TcpListener::bind(address).await?;
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = http1::Builder::new()
|
||||||
|
.serve_connection(
|
||||||
|
TokioIo::new(stream),
|
||||||
|
service_fn(move |_request| async move {
|
||||||
|
sleep(response_delay).await;
|
||||||
|
Ok::<_, Infallible>(empty_http_response(StatusCode::SERVICE_UNAVAILABLE))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self { task: Some(task) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(mut self) {
|
||||||
|
if let Some(task) = self.task.take() {
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SlowReplicationTargetGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(task) = self.task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
struct ReplicationResetStatusResponse {
|
struct ReplicationResetStatusResponse {
|
||||||
@@ -480,15 +734,19 @@ async fn get_bucket_replication(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_bucket_versioning(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
bucket: &str,
|
||||||
|
status: BucketVersioningStatus,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let client = env.create_s3_client();
|
let client = env.create_s3_client();
|
||||||
client
|
client
|
||||||
.put_bucket_versioning()
|
.put_bucket_versioning()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.versioning_configuration(
|
.versioning_configuration(VersioningConfiguration::builder().status(status).build())
|
||||||
VersioningConfiguration::builder()
|
|
||||||
.status(BucketVersioningStatus::Enabled)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1015,6 +1273,48 @@ async fn assert_replication_converged(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_replication_state<F>(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
description: &str,
|
||||||
|
predicate: F,
|
||||||
|
) -> Result<Vec<ReplicatedVersion>, Box<dyn Error + Send + Sync>>
|
||||||
|
where
|
||||||
|
F: Fn(&[ReplicatedVersion]) -> bool,
|
||||||
|
{
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let state = list_replication_state(client, bucket).await?;
|
||||||
|
if predicate(&state) {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("{description}; last target state: {state:?}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_replication_key_absent(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
observation: Duration,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let deadline = tokio::time::Instant::now() + observation;
|
||||||
|
loop {
|
||||||
|
let state = list_replication_state(client, bucket).await?;
|
||||||
|
assert!(
|
||||||
|
state.iter().all(|entry| entry.key != key),
|
||||||
|
"unexpected replicated key {bucket}/{key}: {state:?}"
|
||||||
|
);
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_version_body(
|
async fn get_version_body(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1253,6 +1553,10 @@ async fn build_sse_replication_pair(
|
|||||||
("RUSTFS_KMS_KEY_DIR", source_kms_key_dir.as_str()),
|
("RUSTFS_KMS_KEY_DIR", source_kms_key_dir.as_str()),
|
||||||
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
|
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
|
||||||
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
||||||
|
// Per-key KMS authorization is on so this contract is pinned in the
|
||||||
|
// configuration replication will eventually ship with: the replication
|
||||||
|
// worker carries no request identity and must stay exempt.
|
||||||
|
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
|
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
|
||||||
@@ -1265,6 +1569,7 @@ async fn build_sse_replication_pair(
|
|||||||
("RUSTFS_KMS_KEY_DIR", target_kms_key_dir.as_str()),
|
("RUSTFS_KMS_KEY_DIR", target_kms_key_dir.as_str()),
|
||||||
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
|
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
|
||||||
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
||||||
|
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
target_env
|
target_env
|
||||||
@@ -1686,6 +1991,28 @@ async fn wait_for_remote_target_arn(env: &RustFSTestEnvironment, bucket: &str) -
|
|||||||
Err(format!("site replication did not configure a remote target for bucket {bucket} in time").into())
|
Err(format!("site replication did not configure a remote target for bucket {bucket} in time").into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_remote_target_health_check(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
bucket: &str,
|
||||||
|
arn: &str,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
for _ in 0..40 {
|
||||||
|
let response = list_replication_targets_request(env, Some(bucket)).await?;
|
||||||
|
if response.status() == StatusCode::OK {
|
||||||
|
let targets: Vec<serde_json::Value> = response.json().await?;
|
||||||
|
if targets.iter().any(|target| {
|
||||||
|
target.get("arn").and_then(|value| value.as_str()) == Some(arn)
|
||||||
|
&& target.get("lastOnline").is_some_and(|value| !value.is_null())
|
||||||
|
}) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("replication target {arn} did not complete a successful health check in time").into())
|
||||||
|
}
|
||||||
|
|
||||||
async fn site_replication_add(
|
async fn site_replication_add(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
sites: &[PeerSite],
|
sites: &[PeerSite],
|
||||||
@@ -2653,6 +2980,8 @@ async fn test_set_remote_target_allows_self_signed_https_target_with_skip_tls_ve
|
|||||||
let target_bucket = "replication-self-signed-ok-dst";
|
let target_bucket = "replication-self-signed-ok-dst";
|
||||||
let object_key = "self-signed-replication.txt";
|
let object_key = "self-signed-replication.txt";
|
||||||
let body = "replication over self-signed https should succeed";
|
let body = "replication over self-signed https should succeed";
|
||||||
|
let post_health_check_key = "self-signed-replication-after-health-check.txt";
|
||||||
|
let post_health_check_body = "replication should remain available after the target health check";
|
||||||
|
|
||||||
let source_client = source_env.create_s3_client();
|
let source_client = source_env.create_s3_client();
|
||||||
source_client
|
source_client
|
||||||
@@ -2701,6 +3030,24 @@ async fn test_set_remote_target_allows_self_signed_https_target_with_skip_tls_ve
|
|||||||
|
|
||||||
wait_for_replicated_object_over_https(&https_client, &target_env, target_bucket, object_key, body).await?;
|
wait_for_replicated_object_over_https(&https_client, &target_env, target_bucket, object_key, body).await?;
|
||||||
|
|
||||||
|
wait_for_remote_target_health_check(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(post_health_check_key)
|
||||||
|
.body(ByteStream::from(post_health_check_body.as_bytes().to_vec()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
wait_for_replicated_object_over_https(
|
||||||
|
&https_client,
|
||||||
|
&target_env,
|
||||||
|
target_bucket,
|
||||||
|
post_health_check_key,
|
||||||
|
post_health_check_body,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3323,6 +3670,326 @@ async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded executable slice for backlog#1620. It deliberately uses real
|
||||||
|
/// source and target RustFS processes and leaves the full MinIO
|
||||||
|
/// interoperability profile for a runner that provisions MinIO credentials
|
||||||
|
/// and a reachable endpoint.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||||
|
let mut source_env_vars = replication_fast_env();
|
||||||
|
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||||
|
|
||||||
|
let mut target_env_a = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env_a
|
||||||
|
.start_rustfs_server_without_cleanup_with_env(&source_env_vars)
|
||||||
|
.await?;
|
||||||
|
let mut target_env_b = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env_b
|
||||||
|
.start_rustfs_server_without_cleanup_with_env(&source_env_vars)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let source_bucket = "replication-acceptance-src";
|
||||||
|
let target_bucket_a = "replication-acceptance-dst-a";
|
||||||
|
let target_bucket_b = "replication-acceptance-dst-b";
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client_a = target_env_a.create_s3_client();
|
||||||
|
let target_client_b = target_env_b.create_s3_client();
|
||||||
|
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client_a.create_bucket().bucket(target_bucket_a).send().await?;
|
||||||
|
target_client_b.create_bucket().bucket(target_bucket_b).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env_a, target_bucket_a).await?;
|
||||||
|
enable_bucket_versioning(&target_env_b, target_bucket_b).await?;
|
||||||
|
|
||||||
|
let target_a_arn = set_replication_target(&source_env, source_bucket, &target_env_a, target_bucket_a).await?;
|
||||||
|
let target_b_arn = set_replication_target(&source_env, source_bucket, &target_env_b, target_bucket_b).await?;
|
||||||
|
let body = format!(
|
||||||
|
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Role></Role>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-prefix</ID>
|
||||||
|
<Priority>100</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><Prefix>prefix/</Prefix></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<SourceSelectionCriteria><ReplicaModifications><Status>Enabled</Status></ReplicaModifications></SourceSelectionCriteria>
|
||||||
|
<Destination><Bucket>{target_a_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-both-prefix</ID>
|
||||||
|
<Priority>100</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><Prefix>both/</Prefix></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_a_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-tag</ID>
|
||||||
|
<Priority>100</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><Tag><Key>route</Key><Value>tagged</Value></Tag></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-disabled</ID>
|
||||||
|
<Priority>100</Priority>
|
||||||
|
<Status>Disabled</Status>
|
||||||
|
<Filter><Prefix>disabled/</Prefix></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-priority-high</ID>
|
||||||
|
<Priority>200</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><Prefix>priority/</Prefix></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_a_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-priority-low</ID>
|
||||||
|
<Priority>100</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><Prefix>priority/</Prefix></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_a_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
|
</ReplicationConfiguration>"#
|
||||||
|
);
|
||||||
|
let url = format!("{}/{source_bucket}?replication", source_env.url);
|
||||||
|
let response = signed_request(
|
||||||
|
http::Method::PUT,
|
||||||
|
&url,
|
||||||
|
&source_env.access_key,
|
||||||
|
&source_env.secret_key,
|
||||||
|
Some(body.into_bytes()),
|
||||||
|
Some("application/xml"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status() != StatusCode::OK {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("put replication acceptance matrix failed: {status} {body}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved_config = get_bucket_replication(&source_env, source_bucket).await?.text().await?;
|
||||||
|
for expected in [
|
||||||
|
"matrix-prefix",
|
||||||
|
"matrix-tag",
|
||||||
|
"matrix-disabled",
|
||||||
|
"matrix-priority-high",
|
||||||
|
"Priority>200",
|
||||||
|
"<Status>Disabled</Status>",
|
||||||
|
"<Key>route</Key>",
|
||||||
|
] {
|
||||||
|
assert!(saved_config.contains(expected), "replication config omitted {expected}: {saved_config}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_one = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/versions.txt")
|
||||||
|
.body(ByteStream::from_static(b"version-one"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let version_one_id = version_one
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first matrix PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version_two = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/versions.txt")
|
||||||
|
.body(ByteStream::from_static(b"version-two"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let version_two_id = version_two
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second matrix PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
wait_for_replication_state(&target_client_a, target_bucket_a, "prefix object did not replicate", |state| {
|
||||||
|
state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "prefix/versions.txt" && entry.version_id == version_two_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let delete_marker = source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/versions.txt")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let delete_marker_id = delete_marker
|
||||||
|
.version_id()
|
||||||
|
.ok_or("matrix DELETE omitted marker version ID")?
|
||||||
|
.to_string();
|
||||||
|
wait_for_replication_state(&target_client_a, target_bucket_a, "enabled delete marker did not replicate", |state| {
|
||||||
|
state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "prefix/versions.txt" && entry.delete_marker && entry.version_id == delete_marker_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/versions.txt")
|
||||||
|
.version_id(&version_one_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_replication_state(&target_client_a, target_bucket_a, "enabled version purge did not replicate", |state| {
|
||||||
|
state.iter().all(|entry| entry.version_id != version_one_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("priority/object.txt")
|
||||||
|
.body(ByteStream::from_static(b"priority winner"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_user_get_object(&target_client_a, target_bucket_a, "priority/object.txt").await?;
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("priority/object.txt")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
let priority_state = list_replication_state(&target_client_a, target_bucket_a).await?;
|
||||||
|
assert!(
|
||||||
|
priority_state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "priority/object.txt" && !entry.delete_marker),
|
||||||
|
"priority rule did not retain the object version: {priority_state:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
priority_state
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !(entry.key == "priority/object.txt" && entry.delete_marker)),
|
||||||
|
"lower-priority delete-marker rule overrode the higher-priority disabled rule: {priority_state:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("tagged/object.txt")
|
||||||
|
.tagging("route=tagged")
|
||||||
|
.body(ByteStream::from_static(b"tag filter"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_user_get_object(&target_client_b, target_bucket_b, "tagged/object.txt").await?;
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("tagged/no-match.txt")
|
||||||
|
.body(ByteStream::from_static(b"not tagged"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("both/object.txt")
|
||||||
|
.tagging("route=tagged")
|
||||||
|
.body(ByteStream::from_static(b"mixed targets"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
tokio::try_join!(
|
||||||
|
wait_for_user_get_object(&target_client_a, target_bucket_a, "both/object.txt"),
|
||||||
|
wait_for_user_get_object(&target_client_b, target_bucket_b, "both/object.txt"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("disabled/object.txt")
|
||||||
|
.body(ByteStream::from_static(b"disabled"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_replication_key_absent(&target_client_a, target_bucket_a, "disabled/object.txt", Duration::from_secs(3)).await?;
|
||||||
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "disabled/object.txt", Duration::from_secs(3)).await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("tagged/object.txt")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
let tagged_state = list_replication_state(&target_client_b, target_bucket_b).await?;
|
||||||
|
assert!(
|
||||||
|
tagged_state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "tagged/object.txt" && !entry.delete_marker),
|
||||||
|
"tag rule should retain the replicated data version: {tagged_state:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tagged_state
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !(entry.key == "tagged/object.txt" && entry.delete_marker)),
|
||||||
|
"tag rule with disabled delete-marker replication created a marker: {tagged_state:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?;
|
||||||
|
set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?;
|
||||||
|
let null_put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/null.txt")
|
||||||
|
.body(ByteStream::from_static(b"null version"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version");
|
||||||
|
wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| {
|
||||||
|
state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let null_delete = source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("prefix/null.txt")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
null_delete.version_id().is_none(),
|
||||||
|
"suspended source DELETE must create a null delete marker"
|
||||||
|
);
|
||||||
|
wait_for_replication_state(&target_client_a, target_bucket_a, "null delete marker did not replicate", |state| {
|
||||||
|
state
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && entry.delete_marker)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_single_bucket_multipart_replication_fans_out_to_multiple_targets() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_single_bucket_multipart_replication_fans_out_to_multiple_targets() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
@@ -3659,6 +4326,139 @@ async fn test_bucket_replication_recovers_after_target_outage() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#1610 - black-box bucket replication backlog observability.
|
||||||
|
///
|
||||||
|
/// The source exports metrics through the same OTLP path production uses. A slow
|
||||||
|
/// loopback target keeps replication workers occupied long enough for the metrics
|
||||||
|
/// runtime to publish non-zero bucket backlog gauges; after the real target
|
||||||
|
/// returns, replication must converge and the exported current/MRF pending gauges
|
||||||
|
/// must settle back to zero even though the historical failed counter remains
|
||||||
|
/// non-zero.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_bucket_replication_backlog_metrics_observe_outage_and_recovery() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let collector = ReplicationBacklogMetricCollector::start().await?;
|
||||||
|
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||||
|
let metric_root = collector.root_endpoint().to_string();
|
||||||
|
let metric_endpoint = collector.endpoint.clone();
|
||||||
|
let mut source_env_vars: Vec<(&str, &str)> = replication_fast_env().into_iter().collect();
|
||||||
|
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
source_env_vars.extend_from_slice(FAST_SCANNER_ENV);
|
||||||
|
source_env_vars.extend_from_slice(&[
|
||||||
|
("RUSTFS_OBS_ENDPOINT", metric_root.as_str()),
|
||||||
|
("RUSTFS_OBS_METRIC_ENDPOINT", metric_endpoint.as_str()),
|
||||||
|
("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true"),
|
||||||
|
("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false"),
|
||||||
|
("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false"),
|
||||||
|
("RUSTFS_OBS_METER_INTERVAL", "1"),
|
||||||
|
("RUSTFS_OBS_USE_STDOUT", "false"),
|
||||||
|
("RUSTFS_METRICS_BUCKET_REPLICATION_BANDWIDTH_INTERVAL_SEC", "1"),
|
||||||
|
]);
|
||||||
|
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||||
|
|
||||||
|
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
|
||||||
|
let source_bucket = "repl-backlog-metrics-src";
|
||||||
|
let target_bucket = "repl-backlog-metrics-dst";
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client = target_env.create_s3_client();
|
||||||
|
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||||
|
|
||||||
|
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||||
|
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("before-outage.txt")
|
||||||
|
.body(ByteStream::from_static(b"baseline written before backlog metrics outage"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
|
||||||
|
|
||||||
|
target_env.stop_server();
|
||||||
|
let slow_target = SlowReplicationTargetGuard::bind(&target_env.address, Duration::from_secs(5)).await?;
|
||||||
|
|
||||||
|
let outage_keys = ["metrics-outage-1.txt", "metrics-outage-2.txt", "metrics-outage-3.txt"];
|
||||||
|
for key in outage_keys {
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(
|
||||||
|
format!("written while backlog metrics target was slow: {key}").into_bytes(),
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_backlog = collector
|
||||||
|
.wait_for_bucket_metric(
|
||||||
|
CURRENT_BACKLOG_COUNT_METRIC,
|
||||||
|
source_bucket,
|
||||||
|
|value| value >= 1.0,
|
||||||
|
"be at least 1 during outage",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let current_bytes = collector
|
||||||
|
.wait_for_bucket_metric(
|
||||||
|
CURRENT_BACKLOG_BYTES_METRIC,
|
||||||
|
source_bucket,
|
||||||
|
|value| value > 0.0,
|
||||||
|
"report bytes during outage",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
current_bytes >= current_backlog,
|
||||||
|
"backlog bytes should be at least the object count while queued; count={current_backlog}, bytes={current_bytes}"
|
||||||
|
);
|
||||||
|
let failed_count = collector
|
||||||
|
.wait_for_bucket_metric(
|
||||||
|
TOTAL_FAILED_COUNT_METRIC,
|
||||||
|
source_bucket,
|
||||||
|
|value| value >= 1.0,
|
||||||
|
"record at least one failed replication attempt during outage",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
slow_target.stop().await;
|
||||||
|
target_env.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
|
||||||
|
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
|
||||||
|
for metric in [
|
||||||
|
CURRENT_BACKLOG_COUNT_METRIC,
|
||||||
|
CURRENT_BACKLOG_BYTES_METRIC,
|
||||||
|
MRF_PENDING_COUNT_METRIC,
|
||||||
|
MRF_PENDING_BYTES_METRIC,
|
||||||
|
] {
|
||||||
|
collector
|
||||||
|
.wait_for_bucket_metric(metric, source_bucket, |value| value == 0.0, "settle back to zero after recovery")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let final_failed_count = collector.bucket_metric_value(TOTAL_FAILED_COUNT_METRIC, source_bucket).await;
|
||||||
|
assert!(
|
||||||
|
final_failed_count >= failed_count,
|
||||||
|
"historical failed counter should remain non-zero after recovery while current backlog is zero; before={failed_count}, after={final_failed_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_state = list_replication_state(&target_client, target_bucket).await?;
|
||||||
|
for key in ["before-outage.txt"].into_iter().chain(outage_keys) {
|
||||||
|
assert!(
|
||||||
|
target_state.iter().any(|entry| entry.key == key && !entry.delete_marker),
|
||||||
|
"target missing object {key} after backlog metrics recovery; state={target_state:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// backlog#1147 repl-5, scenario (b) — failure state survives a source restart
|
/// backlog#1147 repl-5, scenario (b) — failure state survives a source restart
|
||||||
/// (mirrors backlog#858 delete-decision re-derivation and #859 no-drop).
|
/// (mirrors backlog#858 delete-decision re-derivation and #859 no-drop).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -12,22 +12,35 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
|
||||||
use aws_sdk_sts::config::retry::RetryConfig;
|
use aws_sdk_sts::config::retry::RetryConfig;
|
||||||
use aws_sdk_sts::config::{Credentials, Region};
|
use aws_sdk_sts::config::{Credentials, Region};
|
||||||
use aws_sdk_sts::error::ProvideErrorMetadata;
|
use aws_sdk_sts::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_sts::operation::RequestId;
|
use aws_sdk_sts::operation::RequestId;
|
||||||
use aws_sdk_sts::{Client, Config};
|
use aws_sdk_sts::{Client, Config};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use bytes::Bytes;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use http::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||||
use rustfs_signer::sign_v4;
|
use http::{Request, Response};
|
||||||
use s3s::Body;
|
use http_body_util::{BodyExt, Full};
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::server::conn::http1;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use serde_json::Value;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::convert::Infallible;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::{Notify, mpsc};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
type BoxError = Box<dyn Error + Send + Sync>;
|
type BoxError = Box<dyn Error + Send + Sync>;
|
||||||
type TestResult = Result<(), BoxError>;
|
type TestResult = Result<(), BoxError>;
|
||||||
|
const OPA_AUTH_TOKEN: &str = "sts-opa-token";
|
||||||
|
|
||||||
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
||||||
let mut config = Config::builder()
|
let mut config = Config::builder()
|
||||||
@@ -49,32 +62,14 @@ fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Opti
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
|
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
|
||||||
let path = "/rustfs/admin/v3/add-service-accounts";
|
let body = admin_ok(
|
||||||
let url = format!("{}{path}", env.url);
|
env,
|
||||||
let uri = url.parse::<http::Uri>()?;
|
http::Method::PUT,
|
||||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
"/rustfs/admin/v3/add-service-accounts",
|
||||||
let body = serde_json::json!({ "targetUser": env.access_key.clone() }).to_string();
|
Some(serde_json::json!({ "targetUser": env.access_key.clone() }).to_string()),
|
||||||
let request = http::Request::builder()
|
)
|
||||||
.method(http::Method::PUT)
|
.await?;
|
||||||
.uri(uri)
|
let response: Value = serde_json::from_str(&body)?;
|
||||||
.header(HOST, authority)
|
|
||||||
.header(CONTENT_TYPE, "application/json")
|
|
||||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let content_length = i64::try_from(body.len()).map_err(|_| "service account request body is too large")?;
|
|
||||||
let signed = sign_v4(request, content_length, &env.access_key, &env.secret_key, "", "us-east-1");
|
|
||||||
let mut request = local_http_client().put(&url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request = request.header(name, value);
|
|
||||||
}
|
|
||||||
let response = request.body(body).send().await?;
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await?;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(format!("create service account failed: {status} {body}").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
|
||||||
let access_key = response["credentials"]["accessKey"]
|
let access_key = response["credentials"]["accessKey"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or("service account response should contain credentials.accessKey")?
|
.ok_or("service account response should contain credentials.accessKey")?
|
||||||
@@ -86,28 +81,237 @@ async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(Str
|
|||||||
Ok((access_key, secret_key))
|
Ok((access_key, secret_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn assert_chaining_denied(client: &Client, credential_kind: &str) -> TestResult {
|
async fn create_user_with_policy(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
user: &str,
|
||||||
|
secret: &str,
|
||||||
|
policy_name: &str,
|
||||||
|
statements: Value,
|
||||||
|
) -> TestResult {
|
||||||
|
create_user(env, user, secret).await?;
|
||||||
|
admin_ok(
|
||||||
|
env,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}"),
|
||||||
|
Some(
|
||||||
|
serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": statements,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
admin_ok(
|
||||||
|
env,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/idp/builtin/policy/attach",
|
||||||
|
Some(serde_json::json!({ "policies": [policy_name], "user": user }).to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(env: &RustFSTestEnvironment, user: &str, secret: &str) -> TestResult {
|
||||||
|
admin_ok(
|
||||||
|
env,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/add-user?accessKey={user}"),
|
||||||
|
Some(serde_json::json!({ "secretKey": secret, "status": "enabled" }).to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_access_denied(client: &Client, context: &str) -> TestResult {
|
||||||
let error = client
|
let error = client
|
||||||
.assume_role()
|
.assume_role()
|
||||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||||
.role_session_name("sts-query-compat-e2e")
|
.role_session_name("sts-query-compat-e2e")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect_err("credential chaining must be denied");
|
.expect_err("AssumeRole must be denied");
|
||||||
let service_error = error
|
let service_error = error
|
||||||
.as_service_error()
|
.as_service_error()
|
||||||
.ok_or_else(|| format!("{credential_kind} denial should deserialize as an STS service error: {error:?}"))?;
|
.ok_or_else(|| format!("{context} should deserialize as an STS service error: {error:?}"))?;
|
||||||
|
|
||||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
|
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||||
assert_eq!(service_error.code(), Some("AccessDenied"));
|
assert_eq!(service_error.code(), Some("AccessDenied"));
|
||||||
assert_eq!(service_error.message(), Some("Access Denied"));
|
assert_eq!(service_error.message(), Some("Access Denied"));
|
||||||
assert!(
|
assert!(
|
||||||
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||||
"{credential_kind} denial should include a request ID"
|
"{context} should include a request ID"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_opa_request(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
requests: mpsc::UnboundedSender<Value>,
|
||||||
|
validation_started: mpsc::UnboundedSender<()>,
|
||||||
|
validation_mode: OpaValidationMode,
|
||||||
|
expected_authorization: Option<String>,
|
||||||
|
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||||
|
if let Some(expected_authorization) = expected_authorization
|
||||||
|
&& request.headers().get(AUTHORIZATION).and_then(|value| value.to_str().ok()) != Some(expected_authorization.as_str())
|
||||||
|
{
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(401)
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.expect("static OPA unauthorized response must be valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = match request.into_body().collect().await {
|
||||||
|
Ok(body) => body.to_bytes(),
|
||||||
|
Err(error) => {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(400)
|
||||||
|
.body(Full::new(Bytes::from(error.to_string())))
|
||||||
|
.expect("static OPA error response must be valid"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = if body.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match serde_json::from_slice::<Value>(&body) {
|
||||||
|
Ok(payload) => Some(payload),
|
||||||
|
Err(error) => {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(400)
|
||||||
|
.body(Full::new(Bytes::from(error.to_string())))
|
||||||
|
.expect("static OPA error response must be valid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if payload.is_none() {
|
||||||
|
let _ = validation_started.send(());
|
||||||
|
if let OpaValidationMode::DelayedUnavailable(release) = validation_mode {
|
||||||
|
release.notified().await;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(503)
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.expect("static OPA unavailable response must be valid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let allow = match payload.as_ref().and_then(|value| value.pointer("/input/identity/account")) {
|
||||||
|
Some(Value::String(account)) if account == "opaallow" => payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.pointer("/input/context/deny_only"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
Some(Value::String(account)) if account == "opadeny" => false,
|
||||||
|
None => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if let Some(payload) = payload {
|
||||||
|
let _ = requests.send(payload);
|
||||||
|
}
|
||||||
|
let body =
|
||||||
|
serde_json::to_vec(&serde_json::json!({ "result": { "allow": allow } })).expect("static OPA response must serialize");
|
||||||
|
Ok(Response::builder()
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Full::new(Bytes::from(body)))
|
||||||
|
.expect("static OPA response must be valid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum OpaValidationMode {
|
||||||
|
Ready,
|
||||||
|
DelayedUnavailable(Arc<Notify>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OpaMock {
|
||||||
|
url: String,
|
||||||
|
requests: mpsc::UnboundedReceiver<Value>,
|
||||||
|
validation_started: mpsc::UnboundedReceiver<()>,
|
||||||
|
validation_release: Option<Arc<Notify>>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpaMock {
|
||||||
|
async fn start() -> Result<Self, BoxError> {
|
||||||
|
Self::start_with_mode(OpaValidationMode::Ready, Some(OPA_AUTH_TOKEN)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_delayed_unavailable() -> Result<Self, BoxError> {
|
||||||
|
let release = Arc::new(Notify::new());
|
||||||
|
Self::start_with_mode(OpaValidationMode::DelayedUnavailable(release), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_with_mode(validation_mode: OpaValidationMode, auth_token: Option<&str>) -> Result<Self, BoxError> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let url = format!("http://{}/v1/data/rustfs/authz/allow", listener.local_addr()?);
|
||||||
|
let (requests_tx, requests) = mpsc::unbounded_channel();
|
||||||
|
let (validation_started_tx, validation_started) = mpsc::unbounded_channel();
|
||||||
|
let expected_authorization = auth_token.map(|token| format!("Bearer {token}"));
|
||||||
|
let validation_release = match &validation_mode {
|
||||||
|
OpaValidationMode::Ready => None,
|
||||||
|
OpaValidationMode::DelayedUnavailable(release) => Some(Arc::clone(release)),
|
||||||
|
};
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut connections = JoinSet::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let Ok((stream, _)) = accepted else { break };
|
||||||
|
let requests = requests_tx.clone();
|
||||||
|
let validation_started = validation_started_tx.clone();
|
||||||
|
let validation_mode = validation_mode.clone();
|
||||||
|
let expected_authorization = expected_authorization.clone();
|
||||||
|
connections.spawn(async move {
|
||||||
|
let handler = service_fn(move |request| {
|
||||||
|
handle_opa_request(
|
||||||
|
request,
|
||||||
|
requests.clone(),
|
||||||
|
validation_started.clone(),
|
||||||
|
validation_mode.clone(),
|
||||||
|
expected_authorization.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let _ = http1::Builder::new()
|
||||||
|
.serve_connection(TokioIo::new(stream), handler)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = connections.join_next(), if !connections.is_empty() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
url,
|
||||||
|
requests,
|
||||||
|
validation_started,
|
||||||
|
validation_release,
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_request(&mut self) -> Result<Value, BoxError> {
|
||||||
|
timeout(Duration::from_secs(5), self.requests.recv())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| "OPA request channel closed".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_validation(&mut self) -> TestResult {
|
||||||
|
timeout(Duration::from_secs(5), self.validation_started.recv())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| "OPA validation channel closed".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_validation(&self) {
|
||||||
|
if let Some(release) = &self.validation_release {
|
||||||
|
release.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for OpaMock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
||||||
@@ -155,7 +359,7 @@ async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
|||||||
"signature rejection should include a request ID"
|
"signature rejection should include a request ID"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_chaining_denied(
|
assert_access_denied(
|
||||||
&sts_client(
|
&sts_client(
|
||||||
&env.url,
|
&env.url,
|
||||||
temporary.access_key_id(),
|
temporary.access_key_id(),
|
||||||
@@ -167,7 +371,187 @@ async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (service_access_key, service_secret_key) = create_root_service_account(&env).await?;
|
let (service_access_key, service_secret_key) = create_root_service_account(&env).await?;
|
||||||
assert_chaining_denied(&sts_client(&env.url, &service_access_key, &service_secret_key, None), "service account").await?;
|
assert_access_denied(
|
||||||
|
&sts_client(&env.url, &service_access_key, &service_secret_key, None),
|
||||||
|
"service-account denial",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let implicit_user = "stsimplicit";
|
||||||
|
let explicit_allow_user = "stsallow";
|
||||||
|
let explicit_deny_user = "stsdeny";
|
||||||
|
let policyless_user = "stspolicyless";
|
||||||
|
let secret = "stsAuthzSecret123";
|
||||||
|
create_user(&env, policyless_user, secret).await?;
|
||||||
|
assert_access_denied(&sts_client(&env.url, policyless_user, secret, None), "policyless user").await?;
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
implicit_user,
|
||||||
|
secret,
|
||||||
|
"sts-implicit-policy",
|
||||||
|
serde_json::json!([{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:ListAllMyBuckets"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
explicit_allow_user,
|
||||||
|
secret,
|
||||||
|
"sts-allow-policy",
|
||||||
|
serde_json::json!([{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["sts:AssumeRole"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
explicit_deny_user,
|
||||||
|
secret,
|
||||||
|
"sts-deny-policy",
|
||||||
|
serde_json::json!([
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["sts:AssumeRole"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Action": ["sts:AssumeRole"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for user in [implicit_user, explicit_allow_user] {
|
||||||
|
let output = sts_client(&env.url, user, secret, None)
|
||||||
|
.assume_role()
|
||||||
|
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||||
|
.role_session_name("sts-authz-e2e")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("{user} should be allowed to call AssumeRole: {error:?}"))?;
|
||||||
|
let credentials = output
|
||||||
|
.credentials()
|
||||||
|
.ok_or_else(|| format!("{user} AssumeRole response should contain credentials"))?;
|
||||||
|
assert!(!credentials.access_key_id().is_empty());
|
||||||
|
assert!(!credentials.secret_access_key().is_empty());
|
||||||
|
assert!(!credentials.session_token().is_empty());
|
||||||
|
}
|
||||||
|
assert_access_denied(&sts_client(&env.url, explicit_deny_user, secret, None), "explicit sts:AssumeRole Deny").await?;
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_sts_assume_role_opa_contract() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut opa = OpaMock::start().await?;
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(
|
||||||
|
vec![],
|
||||||
|
&[
|
||||||
|
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||||
|
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let secret = "stsOpaSecret123";
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
"opaallow",
|
||||||
|
secret,
|
||||||
|
"sts-opa-local-deny-policy",
|
||||||
|
serde_json::json!([{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Action": ["sts:AssumeRole"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
"opadeny",
|
||||||
|
secret,
|
||||||
|
"sts-opa-local-allow-policy",
|
||||||
|
serde_json::json!([{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["sts:AssumeRole"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sts_client(&env.url, "opaallow", secret, None)
|
||||||
|
.assume_role()
|
||||||
|
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||||
|
.role_session_name("sts-opa-contract")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("OPA allow should override the local explicit Deny: {error:?}"))?;
|
||||||
|
assert_access_denied(
|
||||||
|
&sts_client(&env.url, "opadeny", secret, None),
|
||||||
|
"OPA denial despite local sts:AssumeRole Allow",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut accounts = BTreeSet::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let request = opa.next_request().await?;
|
||||||
|
assert_eq!(request.pointer("/input/action").and_then(Value::as_str), Some("sts:AssumeRole"));
|
||||||
|
assert_eq!(request.pointer("/input/context/deny_only").and_then(Value::as_bool), Some(true));
|
||||||
|
let account = request
|
||||||
|
.pointer("/input/identity/account")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("OPA input should include identity.account")?;
|
||||||
|
accounts.insert(account.to_owned());
|
||||||
|
}
|
||||||
|
assert_eq!(accounts, BTreeSet::from(["opaallow".to_owned(), "opadeny".to_owned()]));
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_sts_assume_role_fails_closed_while_opa_is_unavailable() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut opa = OpaMock::start_delayed_unavailable().await?;
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str())])
|
||||||
|
.await?;
|
||||||
|
opa.wait_for_validation().await?;
|
||||||
|
|
||||||
|
let user = "opaunavailable";
|
||||||
|
let secret = "stsOpaUnavailableSecret123";
|
||||||
|
create_user_with_policy(
|
||||||
|
&env,
|
||||||
|
user,
|
||||||
|
secret,
|
||||||
|
"sts-opa-unavailable-local-policy",
|
||||||
|
serde_json::json!([{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:ListAllMyBuckets"],
|
||||||
|
"Resource": ["arn:aws:s3:::*"],
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA initialization").await?;
|
||||||
|
|
||||||
|
opa.release_validation();
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA validation failure").await?;
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -172,20 +172,30 @@ pub mod bucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod replication {
|
pub mod replication {
|
||||||
|
pub use crate::bucket::replication::replication_pool::{
|
||||||
|
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
|
||||||
|
MrfBucketBacklogObservability, durable_mrf_backlog_summary_snapshot, durable_mrf_target_backlog_snapshot,
|
||||||
|
mrf_backlog_observability_snapshot,
|
||||||
|
};
|
||||||
pub use crate::bucket::replication::{
|
pub use crate::bucket::replication::{
|
||||||
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool,
|
BucketReplicationResyncStatus, BucketReplicationStats, BucketStats, DeleteReplicationConfigSnapshot,
|
||||||
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision,
|
DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, MrfOpKind, MrfReplicateEntry,
|
||||||
ReplicateObjectInfo, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
|
||||||
|
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION,
|
||||||
|
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo,
|
||||||
|
ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
||||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||||
ReplicationType, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType,
|
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
|
||||||
|
VersionPurgeStatusType, commit_force_delete_intent, complete_force_delete_intent,
|
||||||
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||||
get_global_replication_stats, init_background_replication, read_durable_mrf_backlog, replication_state_to_filemeta,
|
get_global_replication_stats, init_background_replication, invalid_replication_config_status_field,
|
||||||
replication_status_to_filemeta, replication_statuses_map, replication_target_arns, resync_start_conflict_id,
|
persist_force_delete_intent, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta,
|
||||||
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
replication_statuses_map, replication_target_arns, resync_start_conflict_id, should_remove_replication_target,
|
||||||
should_use_existing_delete_replication_source, validate_replication_config_target_arns,
|
should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||||
version_purge_status_to_filemeta,
|
should_use_existing_delete_replication_source, unsupported_replication_config_field,
|
||||||
|
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,12 +277,13 @@ pub mod config {
|
|||||||
pub mod com {
|
pub mod com {
|
||||||
pub use crate::config::com::{
|
pub use crate::config::com::{
|
||||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||||
ServerConfigCorruptError, ServerConfigSnapshot, delete_config, is_server_config_corrupt_error, lookup_configs,
|
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config,
|
||||||
read_config, read_config_no_lock, read_config_with_metadata, read_config_without_migrate,
|
is_server_config_corrupt_error, lookup_configs, read_config, read_config_no_lock, read_config_with_metadata,
|
||||||
read_config_without_migrate_no_lock, read_existing_server_config_no_lock, read_server_config_snapshot, save_config,
|
read_config_without_migrate, read_config_without_migrate_no_lock, read_existing_server_config_no_lock,
|
||||||
save_config_no_lock, save_config_with_opts, save_server_config, save_server_config_no_lock,
|
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_server_config,
|
||||||
save_server_config_snapshot, server_config_path, try_migrate_server_config, with_config_object_read_lock,
|
save_server_config_no_lock, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||||
with_config_object_write_lock, with_server_config_read_lock, with_server_config_write_lock,
|
server_config_path, try_migrate_server_config, with_config_object_read_lock, with_config_object_write_lock,
|
||||||
|
with_server_config_read_lock, with_server_config_write_lock,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,15 +429,15 @@ pub mod rio {
|
|||||||
|
|
||||||
pub mod rpc {
|
pub mod rpc {
|
||||||
pub use crate::cluster::rpc::{
|
pub use crate::cluster::rpc::{
|
||||||
AuthenticatedChannel, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient,
|
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
||||||
PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing,
|
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||||
ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers, gen_tonic_replay_scope_headers,
|
ScannerBucketListing, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers,
|
||||||
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
|
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
|
||||||
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
|
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
|
||||||
sign_ns_scanner_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge,
|
||||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
tonic_boot_epoch_response_headers, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||||
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||||
verify_tonic_rpc_signature_with_bootstrap,
|
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ use aws_smithy_runtime_api::client::http::{
|
|||||||
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
|
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
|
||||||
use aws_smithy_runtime_api::client::result::ConnectorError;
|
use aws_smithy_runtime_api::client::result::ConnectorError;
|
||||||
use aws_smithy_types::body::SdkBody;
|
use aws_smithy_types::body::SdkBody;
|
||||||
|
use futures::{StreamExt, stream};
|
||||||
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||||
use hyper_util::client::legacy::Client as HyperClient;
|
use hyper_util::client::legacy::Client as HyperClient;
|
||||||
use hyper_util::rt::{TokioExecutor, TokioTimer};
|
use hyper_util::rt::{TokioExecutor, TokioTimer};
|
||||||
@@ -68,6 +69,7 @@ use std::path::Path;
|
|||||||
use std::str::FromStr as _;
|
use std::str::FromStr as _;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use std::sync::Weak;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -79,6 +81,7 @@ use url::Url;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const DEFAULT_HEALTH_CHECK_RELOAD_DURATION: Duration = Duration::from_secs(30 * 60);
|
const DEFAULT_HEALTH_CHECK_RELOAD_DURATION: Duration = Duration::from_secs(30 * 60);
|
||||||
|
const MAX_CONCURRENT_TARGET_HEALTH_CHECKS: usize = 16;
|
||||||
const REDACTED_CREDENTIAL: &str = "<redacted>";
|
const REDACTED_CREDENTIAL: &str = "<redacted>";
|
||||||
|
|
||||||
pub static GLOBAL_BUCKET_TARGET_SYS: OnceLock<BucketTargetSys> = OnceLock::new();
|
pub static GLOBAL_BUCKET_TARGET_SYS: OnceLock<BucketTargetSys> = OnceLock::new();
|
||||||
@@ -255,6 +258,16 @@ fn endpoint_health_key(url: &Url) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn target_health(target: &TargetClient) -> EpHealth {
|
||||||
|
let url = target.to_url();
|
||||||
|
EpHealth {
|
||||||
|
endpoint: endpoint_health_key(&url),
|
||||||
|
scheme: url.scheme().to_string(),
|
||||||
|
online: true,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_endpoint_health(health: &mut EpHealth, online: bool, latency: Duration, now: OffsetDateTime) {
|
fn update_endpoint_health(health: &mut EpHealth, online: bool, latency: Duration, now: OffsetDateTime) {
|
||||||
let prev_online = health.online;
|
let prev_online = health.online;
|
||||||
health.online = online;
|
health.online = online;
|
||||||
@@ -272,14 +285,26 @@ fn update_endpoint_health(health: &mut EpHealth, online: bool, latency: Duration
|
|||||||
health.offline_duration += latency;
|
health.offline_duration += latency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct TargetClientBuildProbe {
|
||||||
|
arn: String,
|
||||||
|
started: Arc<tokio::sync::Semaphore>,
|
||||||
|
release: Arc<tokio::sync::Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct BucketTargetSys {
|
pub struct BucketTargetSys {
|
||||||
pub arn_remotes_map: Arc<RwLock<HashMap<String, ArnTarget>>>,
|
pub arn_remotes_map: Arc<RwLock<HashMap<String, ArnTarget>>>,
|
||||||
pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>,
|
pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>,
|
||||||
pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
||||||
|
target_h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
||||||
pub hc_client: Arc<HttpClient>,
|
pub hc_client: Arc<HttpClient>,
|
||||||
pub a_mutex: Arc<Mutex<HashMap<String, ArnErrs>>>,
|
pub a_mutex: Arc<Mutex<HashMap<String, ArnErrs>>>,
|
||||||
pub arn_errs_map: Arc<RwLock<HashMap<String, ArnErrs>>>,
|
pub arn_errs_map: Arc<RwLock<HashMap<String, ArnErrs>>>,
|
||||||
|
target_update_mutexes: Arc<Mutex<HashMap<String, Weak<Mutex<()>>>>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
target_client_build_probe: Arc<Mutex<Option<TargetClientBuildProbe>>>,
|
||||||
heartbeat_started: OnceLock<()>,
|
heartbeat_started: OnceLock<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +318,13 @@ impl BucketTargetSys {
|
|||||||
arn_remotes_map: Arc::new(RwLock::new(HashMap::new())),
|
arn_remotes_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
targets_map: Arc::new(RwLock::new(HashMap::new())),
|
targets_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
target_h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
||||||
hc_client: Arc::new(HttpClient::new()),
|
hc_client: Arc::new(HttpClient::new()),
|
||||||
a_mutex: Arc::new(Mutex::new(HashMap::new())),
|
a_mutex: Arc::new(Mutex::new(HashMap::new())),
|
||||||
arn_errs_map: Arc::new(RwLock::new(HashMap::new())),
|
arn_errs_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
target_update_mutexes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
#[cfg(test)]
|
||||||
|
target_client_build_probe: Arc::new(Mutex::new(None)),
|
||||||
heartbeat_started: OnceLock::new(),
|
heartbeat_started: OnceLock::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,6 +339,17 @@ impl BucketTargetSys {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn target_update_mutex(&self, bucket: &str) -> Arc<Mutex<()>> {
|
||||||
|
let mut mutexes = self.target_update_mutexes.lock().await;
|
||||||
|
mutexes.retain(|_, mutex| mutex.strong_count() > 0);
|
||||||
|
if let Some(mutex) = mutexes.get(bucket).and_then(Weak::upgrade) {
|
||||||
|
return mutex;
|
||||||
|
}
|
||||||
|
let mutex = Arc::new(Mutex::new(()));
|
||||||
|
mutexes.insert(bucket.to_string(), Arc::downgrade(&mutex));
|
||||||
|
mutex
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn is_offline(&self, url: &Url) -> bool {
|
pub async fn is_offline(&self, url: &Url) -> bool {
|
||||||
let key = endpoint_health_key(url);
|
let key = endpoint_health_key(url);
|
||||||
{
|
{
|
||||||
@@ -318,7 +358,6 @@ impl BucketTargetSys {
|
|||||||
return !health.online;
|
return !health.online;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Initialize health check if not exists
|
|
||||||
self.init_hc(url).await;
|
self.init_hc(url).await;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -345,42 +384,121 @@ impl BucketTargetSys {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn is_target_offline(&self, target: &Arc<TargetClient>) -> bool {
|
||||||
|
// Lock order: arn_remotes_map, then target_h_mutex. A stale client must not
|
||||||
|
// read or initialize the health state of its replacement.
|
||||||
|
let remotes = self.arn_remotes_map.read().await;
|
||||||
|
let Some(current) = remotes.get(&target.arn).and_then(|remote| remote.client.as_ref()) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !Arc::ptr_eq(current, target) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let health_map = self.target_h_mutex.read().await;
|
||||||
|
if let Some(health) = health_map.get(&target.arn) {
|
||||||
|
return !health.online;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
|
let health = health_map.entry(target.arn.clone()).or_insert_with(|| target_health(target));
|
||||||
|
!health.online
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn mark_target_offline(&self, target: &Arc<TargetClient>) {
|
||||||
|
// Lock order: arn_remotes_map, then target_h_mutex. Ignore failures reported
|
||||||
|
// by a client that has already been replaced.
|
||||||
|
let remotes = self.arn_remotes_map.read().await;
|
||||||
|
let Some(current) = remotes.get(&target.arn).and_then(|remote| remote.client.as_ref()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !Arc::ptr_eq(current, target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
|
let health = health_map.entry(target.arn.clone()).or_insert_with(|| target_health(target));
|
||||||
|
update_endpoint_health(health, false, Duration::from_secs(0), OffsetDateTime::now_utc());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn init_target_health(&self, target: &TargetClient) {
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
|
health_map.insert(target.arn.clone(), target_health(target));
|
||||||
|
drop(health_map);
|
||||||
|
self.init_hc(&target.to_url()).await;
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn heartbeat(&self) {
|
pub async fn heartbeat(&self) {
|
||||||
// Probe interval: `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` (default 5000ms,
|
// Probe interval: `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` (default 5000ms,
|
||||||
// clamped to >=10ms), read once when the heartbeat task starts.
|
// clamped to >=10ms), read once when the heartbeat task starts.
|
||||||
let mut interval = tokio::time::interval(crate::bucket::replication::replication_timing::health_check_interval());
|
let mut interval = tokio::time::interval(crate::bucket::replication::replication_timing::health_check_interval());
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
self.heartbeat_once().await;
|
||||||
let endpoints = {
|
|
||||||
let health_map = self.h_mutex.read().await;
|
|
||||||
health_map
|
|
||||||
.iter()
|
|
||||||
.map(|(endpoint, health)| (endpoint.clone(), health.scheme.clone()))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
|
|
||||||
for (endpoint, scheme) in endpoints {
|
|
||||||
// Perform health check
|
|
||||||
let start = Instant::now();
|
|
||||||
let online = self.check_endpoint_health(&endpoint, &scheme).await;
|
|
||||||
let duration = start.elapsed();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut health_map = self.h_mutex.write().await;
|
|
||||||
if let Some(health) = health_map.get_mut(&endpoint) {
|
|
||||||
update_endpoint_health(health, online, duration, OffsetDateTime::now_utc());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_endpoint_health(&self, endpoint: &str, scheme: &str) -> bool {
|
async fn heartbeat_once(&self) {
|
||||||
let scheme = if scheme.is_empty() { "https" } else { scheme };
|
let targets = {
|
||||||
let url = format!("{scheme}://{endpoint}/");
|
let remotes = self.arn_remotes_map.read().await;
|
||||||
match self.hc_client.get(url).timeout(Duration::from_secs(3)).send().await {
|
remotes
|
||||||
Ok(response) => response.status().as_u16() < 500,
|
.values()
|
||||||
|
.filter_map(|target| target.client.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let checks = stream::iter(targets.into_iter().map(|target| async move {
|
||||||
|
let start = Instant::now();
|
||||||
|
let online = Self::check_endpoint_health(&target).await;
|
||||||
|
(target, online, start.elapsed())
|
||||||
|
}));
|
||||||
|
let mut checks = checks.buffer_unordered(MAX_CONCURRENT_TARGET_HEALTH_CHECKS);
|
||||||
|
let mut endpoint_checks = HashMap::<String, (String, bool, Duration)>::new();
|
||||||
|
|
||||||
|
while let Some((target, online, duration)) = checks.next().await {
|
||||||
|
let url = target.to_url();
|
||||||
|
|
||||||
|
{
|
||||||
|
// Lock order: arn_remotes_map, then target_h_mutex. Keeping the remote
|
||||||
|
// read guard prevents a replaced client from receiving stale health.
|
||||||
|
let remotes = self.arn_remotes_map.read().await;
|
||||||
|
let Some(current) = remotes.get(&target.arn).and_then(|remote| remote.client.as_ref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !Arc::ptr_eq(current, &target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
|
let health = health_map.entry(target.arn.clone()).or_insert_with(|| target_health(&target));
|
||||||
|
update_endpoint_health(health, online, duration, OffsetDateTime::now_utc());
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint = endpoint_health_key(&url);
|
||||||
|
endpoint_checks
|
||||||
|
.entry(endpoint)
|
||||||
|
.and_modify(|(_, endpoint_online, endpoint_duration)| {
|
||||||
|
*endpoint_online |= online;
|
||||||
|
*endpoint_duration = (*endpoint_duration).max(duration);
|
||||||
|
})
|
||||||
|
.or_insert_with(|| (url.scheme().to_string(), online, duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut health_map = self.h_mutex.write().await;
|
||||||
|
for (endpoint, (scheme, online, duration)) in endpoint_checks {
|
||||||
|
let health = health_map.entry(endpoint.clone()).or_insert_with(|| EpHealth {
|
||||||
|
endpoint,
|
||||||
|
scheme,
|
||||||
|
online: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
update_endpoint_health(health, online, duration, OffsetDateTime::now_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_endpoint_health(target: &TargetClient) -> bool {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(3), target.client.head_bucket().bucket(&target.bucket).send()).await {
|
||||||
|
Ok(Ok(_)) => true,
|
||||||
|
Ok(Err(err)) => err.raw_response().is_some_and(|response| response.status().as_u16() < 500),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,15 +508,20 @@ impl BucketTargetSys {
|
|||||||
health_map.clone()
|
health_map.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn target_health_stats(&self) -> HashMap<String, EpHealth> {
|
||||||
|
let health_map = self.target_h_mutex.read().await;
|
||||||
|
health_map.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Vec<BucketTarget> {
|
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Vec<BucketTarget> {
|
||||||
let health_stats = self.health_stats().await;
|
let health_stats = self.target_health_stats().await;
|
||||||
let mut targets = Vec::new();
|
let mut targets = Vec::new();
|
||||||
|
|
||||||
if !bucket.is_empty() {
|
if !bucket.is_empty() {
|
||||||
if let Ok(bucket_targets) = self.list_bucket_targets(bucket).await {
|
if let Ok(bucket_targets) = self.list_bucket_targets(bucket).await {
|
||||||
for mut target in bucket_targets.targets {
|
for mut target in bucket_targets.targets {
|
||||||
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
||||||
if let Some(health) = health_stats.get(&target.endpoint) {
|
if let Some(health) = health_stats.get(&target.arn) {
|
||||||
target.total_downtime = health.offline_duration;
|
target.total_downtime = health.offline_duration;
|
||||||
target.online = health.online;
|
target.online = health.online;
|
||||||
target.last_online = health.last_online;
|
target.last_online = health.last_online;
|
||||||
@@ -420,7 +543,7 @@ impl BucketTargetSys {
|
|||||||
for bucket_targets in targets_map.values() {
|
for bucket_targets in targets_map.values() {
|
||||||
for mut target in bucket_targets.iter().cloned() {
|
for mut target in bucket_targets.iter().cloned() {
|
||||||
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
||||||
if let Some(health) = health_stats.get(&target.endpoint) {
|
if let Some(health) = health_stats.get(&target.arn) {
|
||||||
target.total_downtime = health.offline_duration;
|
target.total_downtime = health.offline_duration;
|
||||||
target.online = health.online;
|
target.online = health.online;
|
||||||
target.last_online = health.last_online;
|
target.last_online = health.last_online;
|
||||||
@@ -453,12 +576,18 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, bucket: &str) {
|
pub async fn delete(&self, bucket: &str) {
|
||||||
|
let update_mutex = self.target_update_mutex(bucket).await;
|
||||||
|
let _update_guard = update_mutex.lock().await;
|
||||||
|
|
||||||
|
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex.
|
||||||
let mut targets_map = self.targets_map.write().await;
|
let mut targets_map = self.targets_map.write().await;
|
||||||
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
|
|
||||||
if let Some(targets) = targets_map.remove(bucket) {
|
if let Some(targets) = targets_map.remove(bucket) {
|
||||||
for target in targets {
|
for target in targets {
|
||||||
arn_remotes_map.remove(&target.arn);
|
arn_remotes_map.remove(&target.arn);
|
||||||
|
health_map.remove(&target.arn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,6 +819,22 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_remote_target_client_internal(&self, target: &BucketTarget) -> Result<TargetClient, BucketTargetError> {
|
pub async fn get_remote_target_client_internal(&self, target: &BucketTarget) -> Result<TargetClient, BucketTargetError> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
let probe = self.target_client_build_probe.lock().await.clone();
|
||||||
|
if let Some(probe) = probe
|
||||||
|
&& probe.arn == target.arn
|
||||||
|
{
|
||||||
|
probe.started.add_permits(1);
|
||||||
|
probe
|
||||||
|
.release
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("test probe semaphore should remain open")
|
||||||
|
.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let Some(credentials) = &target.credentials else {
|
let Some(credentials) = &target.credentials else {
|
||||||
return Err(BucketTargetError::BucketRemoteTargetNotFound {
|
return Err(BucketTargetError::BucketRemoteTargetNotFound {
|
||||||
bucket: target.target_bucket.clone(),
|
bucket: target.target_bucket.clone(),
|
||||||
@@ -792,12 +937,25 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_all_targets(&self, bucket: &str, targets: Option<&BucketTargets>) {
|
pub async fn update_all_targets(&self, bucket: &str, targets: Option<&BucketTargets>) {
|
||||||
|
let update_mutex = self.target_update_mutex(bucket).await;
|
||||||
|
let _update_guard = update_mutex.lock().await;
|
||||||
|
|
||||||
|
let mut clients = Vec::new();
|
||||||
|
if let Some(new_targets) = targets {
|
||||||
|
for target in &new_targets.targets {
|
||||||
|
clients.push((target, self.get_remote_target_client_internal(target).await.map(Arc::new)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex.
|
||||||
let mut targets_map = self.targets_map.write().await;
|
let mut targets_map = self.targets_map.write().await;
|
||||||
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
||||||
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
// Remove existing targets
|
// Remove existing targets
|
||||||
if let Some(existing_targets) = targets_map.remove(bucket) {
|
if let Some(existing_targets) = targets_map.remove(bucket) {
|
||||||
for target in existing_targets {
|
for target in existing_targets {
|
||||||
arn_remotes_map.remove(&target.arn);
|
arn_remotes_map.remove(&target.arn);
|
||||||
|
health_map.remove(&target.arn);
|
||||||
self.update_bandwidth_limit(bucket, &target.arn, 0);
|
self.update_bandwidth_limit(bucket, &target.arn, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,16 +964,17 @@ impl BucketTargetSys {
|
|||||||
if let Some(new_targets) = targets
|
if let Some(new_targets) = targets
|
||||||
&& !new_targets.is_empty()
|
&& !new_targets.is_empty()
|
||||||
{
|
{
|
||||||
for target in &new_targets.targets {
|
for (target, client) in clients {
|
||||||
match self.get_remote_target_client_internal(target).await {
|
match client {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
arn_remotes_map.insert(
|
arn_remotes_map.insert(
|
||||||
target.arn.clone(),
|
target.arn.clone(),
|
||||||
ArnTarget {
|
ArnTarget {
|
||||||
client: Some(Arc::new(client)),
|
client: Some(client.clone()),
|
||||||
last_refresh: OffsetDateTime::now_utc(),
|
last_refresh: OffsetDateTime::now_utc(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
health_map.insert(client.arn.clone(), target_health(&client));
|
||||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||||
}
|
}
|
||||||
// The target stays in `targets_map`, so it keeps showing up in
|
// The target stays in `targets_map`, so it keeps showing up in
|
||||||
@@ -845,25 +1004,7 @@ impl BucketTargetSys {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for target in config.targets.iter() {
|
self.update_all_targets(bucket, Some(config)).await;
|
||||||
let cli = match self.get_remote_target_client_internal(target).await {
|
|
||||||
Ok(cli) => cli,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("get_remote_target_client_internal error:{}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let arn_target = ArnTarget::with_client(Arc::new(cli));
|
|
||||||
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
|
||||||
arn_remotes_map.insert(target.arn.clone(), arn_target);
|
|
||||||
}
|
|
||||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut targets_map = self.targets_map.write().await;
|
|
||||||
targets_map.insert(bucket.to_string(), config.targets.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRemoteARN gets existing ARN for an endpoint or generates a new one.
|
// getRemoteARN gets existing ARN for an endpoint or generates a new one.
|
||||||
@@ -1995,7 +2136,50 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use rcgen::generate_simple_self_signed;
|
use rcgen::generate_simple_self_signed;
|
||||||
|
|
||||||
fn spawn_single_request_https_server(cert: &rcgen::CertifiedKey<rcgen::KeyPair>) -> (u16, std::thread::JoinHandle<()>) {
|
#[derive(Clone, Debug)]
|
||||||
|
struct RecordingHttpConnector {
|
||||||
|
request_uris: Arc<std::sync::Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmithyHttpConnector for RecordingHttpConnector {
|
||||||
|
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
|
||||||
|
self.request_uris
|
||||||
|
.lock()
|
||||||
|
.expect("recorded request lock should not be poisoned")
|
||||||
|
.push(request.uri().to_string());
|
||||||
|
HttpConnectorFuture::ready(Ok(HttpResponse::new(
|
||||||
|
aws_smithy_runtime_api::http::StatusCode::try_from(204_u16).expect("204 should be a valid response status"),
|
||||||
|
SdkBody::empty(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recording_target_client() -> (TargetClient, Arc<std::sync::Mutex<Vec<String>>>) {
|
||||||
|
let request_uris = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let connector = SharedHttpConnector::new(RecordingHttpConnector {
|
||||||
|
request_uris: Arc::clone(&request_uris),
|
||||||
|
});
|
||||||
|
let http_client = http_client_fn(move |_settings, _components| connector.clone());
|
||||||
|
let client = s3_client_for_test(443, Some(http_client));
|
||||||
|
(
|
||||||
|
TargetClient {
|
||||||
|
endpoint: "https://localhost:443".to_string(),
|
||||||
|
credentials: None,
|
||||||
|
bucket: "target-bucket".to_string(),
|
||||||
|
storage_class: String::new(),
|
||||||
|
disable_proxy: false,
|
||||||
|
arn: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
|
||||||
|
reset_id: String::new(),
|
||||||
|
secure: true,
|
||||||
|
health_check_duration: Duration::from_secs(5),
|
||||||
|
replicate_sync: false,
|
||||||
|
client: Arc::new(client),
|
||||||
|
},
|
||||||
|
request_uris,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_https_server(cert: &rcgen::CertifiedKey<rcgen::KeyPair>, requests: usize) -> (u16, std::thread::JoinHandle<()>) {
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
ensure_rustls_crypto_provider();
|
ensure_rustls_crypto_provider();
|
||||||
@@ -2014,42 +2198,116 @@ mod tests {
|
|||||||
.expect("test TLS server config should build");
|
.expect("test TLS server config should build");
|
||||||
|
|
||||||
let handle = std::thread::spawn(move || {
|
let handle = std::thread::spawn(move || {
|
||||||
let (stream, _) = listener.accept().expect("test TLS client should connect");
|
let server_config = Arc::new(server_config);
|
||||||
stream
|
for _ in 0..requests {
|
||||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
let (stream, _) = listener.accept().expect("test TLS client should connect");
|
||||||
.expect("test TLS read timeout should configure");
|
stream
|
||||||
stream
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
.set_write_timeout(Some(Duration::from_secs(10)))
|
.expect("test TLS read timeout should configure");
|
||||||
.expect("test TLS write timeout should configure");
|
stream
|
||||||
let connection = rustls::ServerConnection::new(Arc::new(server_config)).expect("test TLS connection should build");
|
.set_write_timeout(Some(Duration::from_secs(10)))
|
||||||
let mut stream = rustls::StreamOwned::new(connection, stream);
|
.expect("test TLS write timeout should configure");
|
||||||
let mut request = [0_u8; 8192];
|
let connection = rustls::ServerConnection::new(server_config.clone()).expect("test TLS connection should build");
|
||||||
let _ = stream.read(&mut request).expect("test TLS request should be readable");
|
let mut stream = rustls::StreamOwned::new(connection, stream);
|
||||||
stream
|
let mut request = [0_u8; 8192];
|
||||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
if stream.read(&mut request).is_err() {
|
||||||
.expect("test TLS response should be written");
|
continue;
|
||||||
stream.flush().expect("test TLS response should flush");
|
}
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||||
|
.expect("test TLS response should be written");
|
||||||
|
stream.flush().expect("test TLS response should flush");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(port, handle)
|
(port, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn s3_client_with_http_client(port: u16, http_client: SharedHttpClient) -> S3Client {
|
fn spawn_http_status_server(status: u16) -> (u16, std::thread::JoinHandle<()>) {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("test HTTP listener should bind");
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("test HTTP listener should have an address")
|
||||||
|
.port();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("test HTTP client should connect");
|
||||||
|
let mut request = [0_u8; 8192];
|
||||||
|
let bytes_read = stream.read(&mut request).expect("test HTTP request should be read");
|
||||||
|
assert!(bytes_read > 0, "test HTTP request should not be empty");
|
||||||
|
write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||||
|
.expect("test HTTP response should be written");
|
||||||
|
});
|
||||||
|
(port, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_delayed_http_server() -> (
|
||||||
|
u16,
|
||||||
|
tokio::sync::oneshot::Receiver<()>,
|
||||||
|
std::sync::mpsc::Sender<()>,
|
||||||
|
std::thread::JoinHandle<()>,
|
||||||
|
) {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("test HTTP listener should bind");
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("test HTTP listener should have an address")
|
||||||
|
.port();
|
||||||
|
let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("test HTTP client should connect");
|
||||||
|
let mut request = [0_u8; 8192];
|
||||||
|
let bytes_read = stream.read(&mut request).expect("test HTTP request should be read");
|
||||||
|
assert!(bytes_read > 0, "test HTTP request should not be empty");
|
||||||
|
accepted_tx.send(()).expect("test should wait for request");
|
||||||
|
release_rx.recv().expect("test should release response");
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 500 Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||||
|
.expect("test HTTP response should be written");
|
||||||
|
});
|
||||||
|
(port, accepted_rx, release_tx, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s3_client_for_test(port: u16, http_client: Option<SharedHttpClient>) -> S3Client {
|
||||||
|
s3_client_for_endpoint_test(format!("https://localhost:{port}"), http_client)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s3_client_for_endpoint_test(endpoint: String, http_client: Option<SharedHttpClient>) -> S3Client {
|
||||||
let credentials = SdkCredentials::builder()
|
let credentials = SdkCredentials::builder()
|
||||||
.access_key_id("test-access")
|
.access_key_id("test-access")
|
||||||
.secret_access_key("test-secret")
|
.secret_access_key("test-secret")
|
||||||
.provider_name("bucket_target_tls_test")
|
.provider_name("bucket_target_tls_test")
|
||||||
.build();
|
.build();
|
||||||
let config = S3Config::builder()
|
let mut config = S3Config::builder()
|
||||||
.endpoint_url(format!("https://localhost:{port}"))
|
.endpoint_url(endpoint)
|
||||||
.credentials_provider(SharedCredentialsProvider::new(credentials))
|
.credentials_provider(SharedCredentialsProvider::new(credentials))
|
||||||
.region(SdkRegion::new("us-east-1"))
|
.region(SdkRegion::new("us-east-1"))
|
||||||
.force_path_style(true)
|
.force_path_style(true)
|
||||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest());
|
||||||
.http_client(http_client)
|
if let Some(http_client) = http_client {
|
||||||
.build();
|
config = config.http_client(http_client);
|
||||||
|
}
|
||||||
|
|
||||||
S3Client::from_conf(config)
|
S3Client::from_conf(config.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_client_for_test(arn: &str, endpoint: String, client: S3Client) -> Arc<TargetClient> {
|
||||||
|
Arc::new(TargetClient {
|
||||||
|
endpoint,
|
||||||
|
credentials: None,
|
||||||
|
bucket: "target-bucket".to_string(),
|
||||||
|
storage_class: String::new(),
|
||||||
|
disable_proxy: false,
|
||||||
|
arn: arn.to_string(),
|
||||||
|
reset_id: String::new(),
|
||||||
|
secure: true,
|
||||||
|
health_check_duration: Duration::from_secs(5),
|
||||||
|
replicate_sync: false,
|
||||||
|
client: Arc::new(client),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2169,17 +2427,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_targets_applies_health_stats_for_endpoint_with_port() {
|
async fn list_targets_applies_health_stats_by_arn_and_preserves_endpoint_port() {
|
||||||
let sys = BucketTargetSys::default();
|
let sys = BucketTargetSys::default();
|
||||||
let url = Url::parse("https://remote.example:9443").expect("url should parse");
|
let arn = "arn:rustfs:replication:us-east-1:bucket:id";
|
||||||
sys.init_hc(&url).await;
|
let endpoint = "https://remote.example:9443".to_string();
|
||||||
sys.mark_offline(&url).await;
|
let client = target_client_for_test(
|
||||||
|
arn,
|
||||||
|
endpoint.clone(),
|
||||||
|
S3Client::from_conf(
|
||||||
|
S3Config::builder()
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(arn.to_string(), ArnTarget::with_client(client.clone()));
|
||||||
|
sys.init_target_health(&client).await;
|
||||||
|
sys.mark_target_offline(&client).await;
|
||||||
|
|
||||||
sys.targets_map.write().await.insert(
|
sys.targets_map.write().await.insert(
|
||||||
"bucket".to_string(),
|
"bucket".to_string(),
|
||||||
vec![BucketTarget {
|
vec![BucketTarget {
|
||||||
endpoint: "remote.example:9443".to_string(),
|
endpoint: "remote.example:9443".to_string(),
|
||||||
arn: "arn:rustfs:replication:us-east-1:bucket:id".to_string(),
|
arn: arn.to_string(),
|
||||||
target_type: BucketTargetType::ReplicationService,
|
target_type: BucketTargetType::ReplicationService,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
@@ -2190,6 +2463,97 @@ mod tests {
|
|||||||
assert_eq!(targets.len(), 1);
|
assert_eq!(targets.len(), 1);
|
||||||
assert!(!targets[0].online);
|
assert!(!targets[0].online);
|
||||||
assert_eq!(targets[0].offline_count, 1);
|
assert_eq!(targets[0].offline_count, 1);
|
||||||
|
assert_eq!(sys.target_health_stats().await[arn].endpoint, "remote.example:9443");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_health_is_isolated_by_arn_for_shared_endpoint() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let endpoint = "https://shared.example:9443".to_string();
|
||||||
|
let config = || {
|
||||||
|
S3Config::builder()
|
||||||
|
.endpoint_url(endpoint.clone())
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
let first = target_client_for_test("arn:first", endpoint.clone(), S3Client::from_conf(config()));
|
||||||
|
let second = target_client_for_test("arn:second", endpoint.clone(), S3Client::from_conf(config()));
|
||||||
|
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(first.arn.clone(), ArnTarget::with_client(first.clone()));
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(second.arn.clone(), ArnTarget::with_client(second.clone()));
|
||||||
|
sys.init_target_health(&first).await;
|
||||||
|
sys.init_target_health(&second).await;
|
||||||
|
sys.mark_target_offline(&first).await;
|
||||||
|
|
||||||
|
assert!(sys.is_target_offline(&first).await);
|
||||||
|
assert!(!sys.is_target_offline(&second).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_client_cannot_change_replacement_health_for_same_arn() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let arn = "arn:replacement";
|
||||||
|
let config = |endpoint: &str| {
|
||||||
|
S3Config::builder()
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
let stale = target_client_for_test(
|
||||||
|
arn,
|
||||||
|
"https://stale.example:9443".to_string(),
|
||||||
|
S3Client::from_conf(config("https://stale.example:9443")),
|
||||||
|
);
|
||||||
|
let current = target_client_for_test(
|
||||||
|
arn,
|
||||||
|
"https://current.example:9443".to_string(),
|
||||||
|
S3Client::from_conf(config("https://current.example:9443")),
|
||||||
|
);
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(arn.to_string(), ArnTarget::with_client(current.clone()));
|
||||||
|
sys.init_target_health(¤t).await;
|
||||||
|
|
||||||
|
sys.mark_target_offline(&stale).await;
|
||||||
|
|
||||||
|
assert!(sys.is_target_offline(&stale).await);
|
||||||
|
assert!(!sys.is_target_offline(¤t).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_target_health_by_arn() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let arn = "arn:delete";
|
||||||
|
let endpoint = "https://delete.example:9443".to_string();
|
||||||
|
let client = target_client_for_test(
|
||||||
|
arn,
|
||||||
|
endpoint.clone(),
|
||||||
|
S3Client::from_conf(
|
||||||
|
S3Config::builder()
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sys.targets_map.write().await.insert(
|
||||||
|
"bucket".to_string(),
|
||||||
|
vec![BucketTarget {
|
||||||
|
arn: arn.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
sys.init_target_health(&client).await;
|
||||||
|
|
||||||
|
sys.delete("bucket").await;
|
||||||
|
|
||||||
|
assert!(!sys.target_health_stats().await.contains_key(arn));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2285,6 +2649,32 @@ mod tests {
|
|||||||
assert_eq!(got.as_deref(), Some(vid.as_str()));
|
assert_eq!(got.as_deref(), Some(vid.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_object_writes_null_purge_and_omits_marker_creation_version_queries() {
|
||||||
|
let (client, request_uris) = recording_target_client();
|
||||||
|
client
|
||||||
|
.remove_object("target-bucket", "object", Some("null".to_string()), remove_opts(true, false))
|
||||||
|
.await
|
||||||
|
.expect("explicit null version purge should reach the target client");
|
||||||
|
client
|
||||||
|
.remove_object("target-bucket", "object", Some(Uuid::new_v4().to_string()), remove_opts(true, true))
|
||||||
|
.await
|
||||||
|
.expect("delete marker creation should reach the target client");
|
||||||
|
|
||||||
|
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
|
||||||
|
assert_eq!(request_uris.len(), 2);
|
||||||
|
assert!(
|
||||||
|
request_uris[0].contains("versionId=null"),
|
||||||
|
"an explicit null purge must be emitted as a target versionId query: {}",
|
||||||
|
request_uris[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!request_uris[1].contains("versionId="),
|
||||||
|
"delete marker creation must omit the target versionId query: {}",
|
||||||
|
request_uris[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn put_object_headers_include_non_empty_source_etag_only() {
|
fn put_object_headers_include_non_empty_source_etag_only() {
|
||||||
let mut opts = PutObjectOptions::default();
|
let mut opts = PutObjectOptions::default();
|
||||||
@@ -2375,6 +2765,216 @@ mod tests {
|
|||||||
assert_eq!(client.endpoint, "https://192.168.1.10:9000");
|
assert_eq!(client.endpoint, "https://192.168.1.10:9000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_health_check_rejects_untrusted_self_signed_certificate() {
|
||||||
|
let cert = generate_simple_self_signed(vec!["localhost".to_string()]).expect("certificate should generate");
|
||||||
|
let (port, server) = spawn_https_server(&cert, 1);
|
||||||
|
let target =
|
||||||
|
target_client_for_test("arn:default-tls", format!("https://localhost:{port}"), s3_client_for_test(port, None));
|
||||||
|
|
||||||
|
assert!(!BucketTargetSys::check_endpoint_health(&target).await);
|
||||||
|
server.join().expect("test TLS server should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_health_check_honors_skip_tls_verify_client() {
|
||||||
|
let cert = generate_simple_self_signed(vec!["localhost".to_string()]).expect("certificate should generate");
|
||||||
|
let (port, server) = spawn_https_server(&cert, 1);
|
||||||
|
let target = target_client_for_test(
|
||||||
|
"arn:skip-tls",
|
||||||
|
format!("https://localhost:{port}"),
|
||||||
|
s3_client_for_test(port, Some(build_insecure_aws_s3_http_client())),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(BucketTargetSys::check_endpoint_health(&target).await);
|
||||||
|
server.join().expect("test TLS server should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_health_check_honors_custom_ca_client() {
|
||||||
|
let cert = generate_simple_self_signed(vec!["localhost".to_string()]).expect("certificate should generate");
|
||||||
|
let http_client = build_aws_s3_http_client_from_target_ca_pem(&cert.cert.pem())
|
||||||
|
.await
|
||||||
|
.expect("custom CA client should build");
|
||||||
|
let (port, server) = spawn_https_server(&cert, 1);
|
||||||
|
let target = target_client_for_test(
|
||||||
|
"arn:custom-ca",
|
||||||
|
format!("https://localhost:{port}"),
|
||||||
|
s3_client_for_test(port, Some(http_client)),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(BucketTargetSys::check_endpoint_health(&target).await);
|
||||||
|
server.join().expect("test TLS server should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_health_check_treats_client_errors_as_online_and_server_errors_as_offline() {
|
||||||
|
for (status, expected_online) in [(403, true), (500, false)] {
|
||||||
|
let (port, server) = spawn_http_status_server(status);
|
||||||
|
let endpoint = format!("http://127.0.0.1:{port}");
|
||||||
|
let target = target_client_for_test(
|
||||||
|
&format!("arn:http-{status}"),
|
||||||
|
endpoint.clone(),
|
||||||
|
s3_client_for_endpoint_test(endpoint, None),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(BucketTargetSys::check_endpoint_health(&target).await, expected_online);
|
||||||
|
server.join().expect("test HTTP server should stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeat_keeps_tls_health_isolated_by_arn_for_shared_endpoint() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let cert = generate_simple_self_signed(vec!["localhost".to_string()]).expect("certificate should generate");
|
||||||
|
let (port, server) = spawn_https_server(&cert, 2);
|
||||||
|
let endpoint = format!("https://localhost:{port}");
|
||||||
|
let strict = target_client_for_test("arn:strict", endpoint.clone(), s3_client_for_test(port, None));
|
||||||
|
let insecure = target_client_for_test(
|
||||||
|
"arn:insecure",
|
||||||
|
endpoint,
|
||||||
|
s3_client_for_test(port, Some(build_insecure_aws_s3_http_client())),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut remotes = sys.arn_remotes_map.write().await;
|
||||||
|
remotes.insert(strict.arn.clone(), ArnTarget::with_client(strict.clone()));
|
||||||
|
remotes.insert(insecure.arn.clone(), ArnTarget::with_client(insecure.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.heartbeat_once().await;
|
||||||
|
|
||||||
|
assert!(sys.is_target_offline(&strict).await);
|
||||||
|
assert!(!sys.is_target_offline(&insecure).await);
|
||||||
|
server.join().expect("test TLS server should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeat_discards_result_from_replaced_client_with_same_arn() {
|
||||||
|
let sys = Arc::new(BucketTargetSys::default());
|
||||||
|
let (port, accepted, release, server) = spawn_delayed_http_server();
|
||||||
|
let endpoint = format!("http://127.0.0.1:{port}");
|
||||||
|
let stale = target_client_for_test("arn:replacement", endpoint.clone(), s3_client_for_endpoint_test(endpoint, None));
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(stale.arn.clone(), ArnTarget::with_client(stale));
|
||||||
|
let heartbeat_sys = sys.clone();
|
||||||
|
let heartbeat = tokio::spawn(async move { heartbeat_sys.heartbeat_once().await });
|
||||||
|
accepted.await.expect("heartbeat request should reach test server");
|
||||||
|
|
||||||
|
let replacement_endpoint = "https://replacement.example:9443".to_string();
|
||||||
|
let replacement = target_client_for_test(
|
||||||
|
"arn:replacement",
|
||||||
|
replacement_endpoint.clone(),
|
||||||
|
S3Client::from_conf(
|
||||||
|
S3Config::builder()
|
||||||
|
.endpoint_url(replacement_endpoint)
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sys.arn_remotes_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(replacement.arn.clone(), ArnTarget::with_client(replacement.clone()));
|
||||||
|
sys.init_target_health(&replacement).await;
|
||||||
|
release.send(()).expect("stale heartbeat response should be released");
|
||||||
|
heartbeat.await.expect("heartbeat should finish");
|
||||||
|
|
||||||
|
assert!(!sys.is_target_offline(&replacement).await);
|
||||||
|
server.join().expect("test HTTP server should stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_update_mutex_reuses_live_lock_and_reclaims_dead_entries() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let first = sys.target_update_mutex("first").await;
|
||||||
|
let same = sys.target_update_mutex("first").await;
|
||||||
|
assert!(Arc::ptr_eq(&first, &same));
|
||||||
|
drop(first);
|
||||||
|
drop(same);
|
||||||
|
|
||||||
|
let _second = sys.target_update_mutex("second").await;
|
||||||
|
let mutexes = sys.target_update_mutexes.lock().await;
|
||||||
|
assert!(!mutexes.contains_key("first"));
|
||||||
|
assert!(mutexes.contains_key("second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn target_updates_serialize_client_build_through_publication_per_bucket() {
|
||||||
|
let sys = Arc::new(BucketTargetSys::default());
|
||||||
|
let started = Arc::new(tokio::sync::Semaphore::new(0));
|
||||||
|
let release = Arc::new(tokio::sync::Semaphore::new(0));
|
||||||
|
*sys.target_client_build_probe.lock().await = Some(TargetClientBuildProbe {
|
||||||
|
arn: "arn:first".to_string(),
|
||||||
|
started: started.clone(),
|
||||||
|
release: release.clone(),
|
||||||
|
});
|
||||||
|
let target = |arn: &str| BucketTarget {
|
||||||
|
arn: arn.to_string(),
|
||||||
|
endpoint: "192.168.1.10:9000".to_string(),
|
||||||
|
target_bucket: "target-bucket".to_string(),
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
credentials: Some(Credentials {
|
||||||
|
access_key: "access".to_string(),
|
||||||
|
secret_key: "secret".to_string(),
|
||||||
|
session_token: None,
|
||||||
|
expiration: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let first_targets = BucketTargets {
|
||||||
|
targets: vec![target("arn:first")],
|
||||||
|
};
|
||||||
|
let second_targets = BucketTargets {
|
||||||
|
targets: vec![target("arn:second")],
|
||||||
|
};
|
||||||
|
let first_sys = sys.clone();
|
||||||
|
let first = tokio::spawn(async move {
|
||||||
|
first_sys.update_all_targets("bucket", Some(&first_targets)).await;
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), started.acquire())
|
||||||
|
.await
|
||||||
|
.expect("first client build should start")
|
||||||
|
.expect("first started semaphore should remain open")
|
||||||
|
.forget();
|
||||||
|
let second_started = Arc::new(tokio::sync::Semaphore::new(0));
|
||||||
|
let second_release = Arc::new(tokio::sync::Semaphore::new(0));
|
||||||
|
*sys.target_client_build_probe.lock().await = Some(TargetClientBuildProbe {
|
||||||
|
arn: "arn:second".to_string(),
|
||||||
|
started: second_started.clone(),
|
||||||
|
release: second_release.clone(),
|
||||||
|
});
|
||||||
|
let second_sys = sys.clone();
|
||||||
|
let second = tokio::spawn(async move {
|
||||||
|
second_sys.update_all_targets("bucket", Some(&second_targets)).await;
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(50), second_started.acquire())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(!sys.targets_map.read().await.contains_key("bucket"));
|
||||||
|
|
||||||
|
release.add_permits(1);
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), first)
|
||||||
|
.await
|
||||||
|
.expect("first target update should not stall")
|
||||||
|
.expect("first target update should finish");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), second_started.acquire())
|
||||||
|
.await
|
||||||
|
.expect("second client build should start after first update publishes")
|
||||||
|
.expect("second started semaphore should remain open")
|
||||||
|
.forget();
|
||||||
|
second_release.add_permits(1);
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), second)
|
||||||
|
.await
|
||||||
|
.expect("second target update should not stall")
|
||||||
|
.expect("second target update should finish");
|
||||||
|
let targets = sys.targets_map.read().await;
|
||||||
|
assert_eq!(targets["bucket"][0].arn, "arn:second");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn replication_trust_store_composes_system_global_and_target_roots_for_real_tls() {
|
async fn replication_trust_store_composes_system_global_and_target_roots_for_real_tls() {
|
||||||
let tls_dir = tempfile::tempdir().expect("temporary TLS directory should be created");
|
let tls_dir = tempfile::tempdir().expect("temporary TLS directory should be created");
|
||||||
@@ -2396,8 +2996,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let http_client = build_aws_s3_http_client_with_trust_store(trust_store).expect("composed TLS client should build");
|
let http_client = build_aws_s3_http_client_with_trust_store(trust_store).expect("composed TLS client should build");
|
||||||
|
|
||||||
let (global_port, global_server) = spawn_single_request_https_server(&global_ca);
|
let (global_port, global_server) = spawn_https_server(&global_ca, 1);
|
||||||
s3_client_with_http_client(global_port, http_client.clone())
|
s3_client_for_test(global_port, Some(http_client.clone()))
|
||||||
.head_bucket()
|
.head_bucket()
|
||||||
.bucket("test-bucket")
|
.bucket("test-bucket")
|
||||||
.send()
|
.send()
|
||||||
@@ -2405,8 +3005,8 @@ mod tests {
|
|||||||
.expect("global RUSTFS_TLS_PATH CA should authenticate its TLS server");
|
.expect("global RUSTFS_TLS_PATH CA should authenticate its TLS server");
|
||||||
global_server.join().expect("global CA TLS server should finish");
|
global_server.join().expect("global CA TLS server should finish");
|
||||||
|
|
||||||
let (target_port, target_server) = spawn_single_request_https_server(&target_ca);
|
let (target_port, target_server) = spawn_https_server(&target_ca, 1);
|
||||||
s3_client_with_http_client(target_port, http_client)
|
s3_client_for_test(target_port, Some(http_client))
|
||||||
.head_bucket()
|
.head_bucket()
|
||||||
.bucket("test-bucket")
|
.bucket("test-bucket")
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ use crate::bucket::lifecycle::manual_transition_job::{
|
|||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::replication_sink;
|
use crate::bucket::lifecycle::replication_sink;
|
||||||
use crate::bucket::lifecycle::replication_sink::{
|
use crate::bucket::lifecycle::replication_sink::{
|
||||||
ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, replication_state_to_filemeta,
|
DeleteReplicationConfigSnapshot, ReplicationObjectBridge, ReplicationStatusType, replication_state_to_filemeta,
|
||||||
replication_statuses_map, version_purge_statuses_map,
|
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::tier_delete_journal::{process_tier_delete_journal_entry, run_tier_delete_journal_recovery_loop};
|
use crate::bucket::lifecycle::tier_delete_journal::{process_tier_delete_journal_entry, run_tier_delete_journal_recovery_loop};
|
||||||
use crate::bucket::lifecycle::tier_free_version_recovery::{
|
use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||||
@@ -43,6 +42,7 @@ use crate::bucket::lifecycle::tier_free_version_recovery::{
|
|||||||
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
|
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
|
||||||
use crate::bucket::lifecycle::transition_transaction::run_transition_transaction_recovery_loop;
|
use crate::bucket::lifecycle::transition_transaction::run_transition_transaction_recovery_loop;
|
||||||
|
use crate::bucket::versioning::VersioningApi as _;
|
||||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||||
use crate::client::object_api_utils::new_getobjectreader;
|
use crate::client::object_api_utils::new_getobjectreader;
|
||||||
use crate::disk::error::DiskError;
|
use crate::disk::error::DiskError;
|
||||||
@@ -1764,10 +1764,7 @@ impl TransitionState {
|
|||||||
|
|
||||||
pub fn add_lastday_stats(&self, tier: &str, ts: TierStats) {
|
pub fn add_lastday_stats(&self, tier: &str, ts: TierStats) {
|
||||||
let mut tier_stats = self.lock_last_day_stats();
|
let mut tier_stats = self.lock_last_day_stats();
|
||||||
tier_stats
|
tier_stats.entry(tier.to_string()).or_default().add_stats(ts);
|
||||||
.entry(tier.to_string())
|
|
||||||
.and_modify(|e| e.add_stats(ts))
|
|
||||||
.or_default();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_daily_all_tier_stats(&self) -> DailyAllTierStats {
|
pub fn get_daily_all_tier_stats(&self) -> DailyAllTierStats {
|
||||||
@@ -4082,12 +4079,12 @@ pub async fn expire_transitioned_object(
|
|||||||
lc_event: &lifecycle::Event,
|
lc_event: &lifecycle::Event,
|
||||||
_src: &LcEventSrc,
|
_src: &LcEventSrc,
|
||||||
) -> Result<ObjectInfo, std::io::Error> {
|
) -> Result<ObjectInfo, std::io::Error> {
|
||||||
let opts = transitioned_object_delete_opts(
|
let snapshot = lifecycle_delete_config_snapshot(&api, oi)
|
||||||
oi,
|
.await
|
||||||
lc_event.action,
|
.map_err(std::io::Error::other)?;
|
||||||
BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
|
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
|
||||||
BucketVersioningSys::prefix_suspended(&oi.bucket, &oi.name).await,
|
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended);
|
||||||
);
|
opts.delete_replication_config_snapshot = Some(Arc::new(snapshot));
|
||||||
//let tags = LcAuditEvent::new(src, lcEvent).Tags();
|
//let tags = LcAuditEvent::new(src, lcEvent).Tags();
|
||||||
if lc_event.action.delete_restored() {
|
if lc_event.action.delete_restored() {
|
||||||
return match api.delete_object(&oi.bucket, &oi.name, opts).await {
|
return match api.delete_object(&oi.bucket, &oi.name, opts).await {
|
||||||
@@ -4695,8 +4692,28 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
|||||||
lc_event: &lifecycle::Event,
|
lc_event: &lifecycle::Event,
|
||||||
_src: &LcEventSrc,
|
_src: &LcEventSrc,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
let snapshot = match lifecycle_delete_config_snapshot(&api, oi).await {
|
||||||
|
Ok(snapshot) => snapshot,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
event = EVENT_LIFECYCLE_DELETE_FAILED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
|
bucket = %oi.bucket,
|
||||||
|
object = %oi.name,
|
||||||
|
operation = "load_delete_config_snapshot",
|
||||||
|
error = ?err,
|
||||||
|
"Lifecycle delete admission failed"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
|
||||||
let mut opts = ObjectOptions {
|
let mut opts = ObjectOptions {
|
||||||
|
versioned,
|
||||||
|
version_suspended,
|
||||||
expiration: ExpirationOptions { expire: true },
|
expiration: ExpirationOptions { expire: true },
|
||||||
|
delete_replication_config_snapshot: Some(Arc::new(snapshot)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4704,9 +4721,6 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
|||||||
opts.version_id = oi.version_id.map(|v| v.to_string());
|
opts.version_id = oi.version_id.map(|v| v.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.versioned = BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await;
|
|
||||||
opts.version_suspended = BucketVersioningSys::prefix_suspended(&oi.bucket, &oi.name).await;
|
|
||||||
|
|
||||||
if lc_event.action.delete_all() {
|
if lc_event.action.delete_all() {
|
||||||
opts.delete_prefix = true;
|
opts.delete_prefix = true;
|
||||||
opts.delete_prefix_object = true;
|
opts.delete_prefix_object = true;
|
||||||
@@ -4768,12 +4782,17 @@ pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject {
|
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject {
|
||||||
|
let replication_state = dobj.replication_state();
|
||||||
|
let replication_state = (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty())
|
||||||
|
.then(|| replication_state_to_filemeta(&replication_state));
|
||||||
|
|
||||||
if dobj.delete_marker {
|
if dobj.delete_marker {
|
||||||
return DeletedObject {
|
return DeletedObject {
|
||||||
object_name: oi.name.clone(),
|
object_name: oi.name.clone(),
|
||||||
delete_marker: true,
|
delete_marker: true,
|
||||||
delete_marker_version_id: dobj.version_id,
|
delete_marker_version_id: dobj.version_id,
|
||||||
delete_marker_mtime: dobj.mod_time.or(oi.mod_time),
|
delete_marker_mtime: dobj.mod_time.or(oi.mod_time),
|
||||||
|
replication_state,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4784,6 +4803,7 @@ fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject
|
|||||||
delete_marker: false,
|
delete_marker: false,
|
||||||
delete_marker_version_id: oi.version_id,
|
delete_marker_version_id: oi.version_id,
|
||||||
delete_marker_mtime: oi.mod_time,
|
delete_marker_mtime: oi.mod_time,
|
||||||
|
replication_state,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4793,106 +4813,21 @@ fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject
|
|||||||
delete_marker: false,
|
delete_marker: false,
|
||||||
version_id: oi.version_id,
|
version_id: oi.version_id,
|
||||||
delete_marker_mtime: oi.mod_time,
|
delete_marker_mtime: oi.mod_time,
|
||||||
|
replication_state,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo, dobj: &ObjectInfo) {
|
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo, dobj: &ObjectInfo) {
|
||||||
let mut delete_object = lifecycle_deleted_object(oi, dobj);
|
let delete_object = lifecycle_deleted_object(oi, dobj);
|
||||||
let version_id = if delete_object.delete_marker {
|
if delete_object.replication_state.is_none() {
|
||||||
None
|
|
||||||
} else if delete_object.delete_marker_version_id.is_some() {
|
|
||||||
delete_object.delete_marker_version_id
|
|
||||||
} else {
|
|
||||||
delete_object.version_id
|
|
||||||
};
|
|
||||||
|
|
||||||
let replication_state = lifecycle_delete_replication_state(oi, version_id).await;
|
|
||||||
if replication_state.is_none() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete_object.replication_state = replication_state.as_ref().map(replication_state_to_filemeta);
|
|
||||||
|
|
||||||
replication_sink::schedule_delete(oi.bucket.clone(), delete_object).await;
|
replication_sink::schedule_delete(oi.bucket.clone(), delete_object).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_reuse_lifecycle_delete_replication_state(oi: &ObjectInfo, version_delete: bool) -> bool {
|
async fn lifecycle_delete_config_snapshot(api: &ECStore, oi: &ObjectInfo) -> Result<DeleteReplicationConfigSnapshot, Error> {
|
||||||
let state = oi.replication_state();
|
ReplicationObjectBridge::delete_request_config(api, &oi.bucket).await
|
||||||
if version_delete {
|
|
||||||
oi.version_purge_status == VersionPurgeStatusType::Pending && !state.purge_targets.is_empty()
|
|
||||||
} else {
|
|
||||||
oi.replication_status == ReplicationStatusType::Pending && !state.targets.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lifecycle_version_purge_state_from_completed_targets(oi: &ObjectInfo) -> Option<ReplicationState> {
|
|
||||||
if oi.replication_status != ReplicationStatusType::Completed {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targets = oi.replication_state().targets;
|
|
||||||
if targets.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pending_status = targets.keys().map(|arn| format!("{arn}=PENDING;")).collect::<String>();
|
|
||||||
|
|
||||||
Some(ReplicationState {
|
|
||||||
replicate_decision_str: oi.replication_decision.clone(),
|
|
||||||
version_purge_status_internal: Some(pending_status.clone()),
|
|
||||||
purge_targets: version_purge_statuses_map(&pending_status),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn lifecycle_delete_replication_state(oi: &ObjectInfo, version_id: Option<Uuid>) -> Option<ReplicationState> {
|
|
||||||
if should_reuse_lifecycle_delete_replication_state(oi, version_id.is_some()) {
|
|
||||||
return Some(oi.replication_state());
|
|
||||||
}
|
|
||||||
|
|
||||||
if version_id.is_some()
|
|
||||||
&& let Some(state) = lifecycle_version_purge_state_from_completed_targets(oi)
|
|
||||||
{
|
|
||||||
return Some(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dsc = replication_sink::check_delete_replication(
|
|
||||||
&oi.bucket,
|
|
||||||
ObjectToDelete {
|
|
||||||
object_name: oi.name.clone(),
|
|
||||||
version_id,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
oi,
|
|
||||||
&ObjectOptions {
|
|
||||||
version_id: version_id.map(|v| v.to_string()),
|
|
||||||
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if !dsc.replicate_any() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(replication_state_for_delete(dsc, version_id.is_some()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replication_state_for_delete(dsc: ReplicateDecision, version_delete: bool) -> ReplicationState {
|
|
||||||
let pending_status = dsc.pending_status();
|
|
||||||
let mut state = ReplicationState {
|
|
||||||
replicate_decision_str: dsc.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
if version_delete {
|
|
||||||
state.version_purge_status_internal = pending_status.clone();
|
|
||||||
state.purge_targets = version_purge_statuses_map(pending_status.as_deref().unwrap_or_default());
|
|
||||||
} else {
|
|
||||||
state.replication_status_internal = pending_status.clone();
|
|
||||||
state.targets = replication_statuses_map(pending_status.as_deref().unwrap_or_default());
|
|
||||||
}
|
|
||||||
state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||||
@@ -4928,16 +4863,14 @@ mod tests {
|
|||||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||||
jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
||||||
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
||||||
lifecycle_rule_has_date_expiration, lifecycle_version_purge_state_from_completed_targets,
|
lifecycle_rule_has_date_expiration, manual_transition_duration_elapsed, manual_transition_has_more_after_limit,
|
||||||
manual_transition_duration_elapsed, manual_transition_has_more_after_limit, manual_transition_recovery_progress_sink,
|
manual_transition_recovery_progress_sink, manual_transition_version_marker, manual_transition_worker_failure_reason,
|
||||||
manual_transition_version_marker, manual_transition_worker_failure_reason,
|
|
||||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
||||||
persist_manual_transition_job_progress, persist_manual_transition_page_checkpoint, recover_manual_transition_job,
|
persist_manual_transition_job_progress, persist_manual_transition_page_checkpoint, recover_manual_transition_job,
|
||||||
recover_manual_transition_jobs, replication_state_for_delete, resolve_tier_free_version_recovery_enabled,
|
recover_manual_transition_jobs, resolve_tier_free_version_recovery_enabled, resolve_transition_queue_capacity,
|
||||||
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
resolve_transition_queue_send_timeout, resolve_transition_worker_count, resolve_transition_workers_absolute_max,
|
||||||
resolve_transition_workers_absolute_max, run_tier_free_version_recovery_loop, select_restore_s3_location,
|
run_tier_free_version_recovery_loop, select_restore_s3_location, set_lifecycle_observability_observer,
|
||||||
set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
|
set_recovered_free_version_enqueue_observer, should_defer_date_expiry_for_recent_config_update,
|
||||||
should_defer_date_expiry_for_recent_config_update, should_reuse_lifecycle_delete_replication_state,
|
|
||||||
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
|
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
@@ -4961,9 +4894,7 @@ mod tests {
|
|||||||
save_manual_transition_scope_admission_if_absent, save_manual_transition_scope_admission_if_current,
|
save_manual_transition_scope_admission_if_absent, save_manual_transition_scope_admission_if_current,
|
||||||
save_manual_transition_task_if_absent, save_manual_transition_worker_result_if_absent,
|
save_manual_transition_task_if_absent, save_manual_transition_worker_result_if_absent,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::replication_sink::{
|
use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType};
|
||||||
ReplicateDecision, ReplicateTargetDecision, ReplicationStatusType, VersionPurgeStatusType,
|
|
||||||
};
|
|
||||||
use crate::bucket::lifecycle::runtime_boundary as runtime_sources;
|
use crate::bucket::lifecycle::runtime_boundary as runtime_sources;
|
||||||
use crate::bucket::lifecycle::tier_free_version_recovery::{
|
use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||||
FreeVersionRecoveryStats, RecoveryWalkTestAction, list_tier_free_versions, recover_tier_free_versions_with_cancel,
|
FreeVersionRecoveryStats, RecoveryWalkTestAction, list_tier_free_versions, recover_tier_free_versions_with_cancel,
|
||||||
@@ -5997,6 +5928,8 @@ mod tests {
|
|||||||
backend_identity: Some([1; 32]),
|
backend_identity: Some([1; 32]),
|
||||||
version_id_exact: true,
|
version_id_exact: true,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = state
|
let err = state
|
||||||
@@ -6109,6 +6042,8 @@ mod tests {
|
|||||||
backend_identity: Some([1; 32]),
|
backend_identity: Some([1; 32]),
|
||||||
version_id_exact: true,
|
version_id_exact: true,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -7277,6 +7212,48 @@ mod tests {
|
|||||||
assert!(stats.contains_key("fresh"), "statistics must accept new samples after recovery");
|
assert!(stats.contains_key("fresh"), "statistics must accept new samples after recovery");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_tier_sample_is_not_dropped() {
|
||||||
|
// The first completed transition to a tier both creates the map entry
|
||||||
|
// and carries a sample. Creating the entry must not discard that
|
||||||
|
// sample, or the admin GetTierInfo response undercounts every tier by
|
||||||
|
// its first transition while reporting later ones correctly.
|
||||||
|
let state = TransitionState::new_with_capacity(1);
|
||||||
|
|
||||||
|
state.add_lastday_stats(
|
||||||
|
"warm",
|
||||||
|
TierStats {
|
||||||
|
total_size: 10,
|
||||||
|
num_versions: 1,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = state.get_daily_all_tier_stats();
|
||||||
|
let total = stats.get("warm").expect("first sample must create the tier entry").total();
|
||||||
|
assert_eq!(total.total_size, 10, "first sample size must be counted");
|
||||||
|
assert_eq!(total.num_versions, 1, "first sample version must be counted");
|
||||||
|
assert_eq!(total.num_objects, 1, "first sample object must be counted");
|
||||||
|
|
||||||
|
// Later samples must accumulate onto the existing entry rather than
|
||||||
|
// replace it; `LastDayTierStats::add_stats` coverage alone does not
|
||||||
|
// reach this path, because it never goes through the tier map.
|
||||||
|
state.add_lastday_stats(
|
||||||
|
"warm",
|
||||||
|
TierStats {
|
||||||
|
total_size: 20,
|
||||||
|
num_versions: 2,
|
||||||
|
num_objects: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = state.get_daily_all_tier_stats();
|
||||||
|
let total = stats.get("warm").expect("tier entry must survive later samples").total();
|
||||||
|
assert_eq!(total.total_size, 30, "later sample size must accumulate");
|
||||||
|
assert_eq!(total.num_versions, 3, "later sample versions must accumulate");
|
||||||
|
assert_eq!(total.num_objects, 2, "later sample objects must accumulate");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn scanner_transition_state_reports_compensation_pending_buckets() {
|
async fn scanner_transition_state_reports_compensation_pending_buckets() {
|
||||||
let state = TransitionState::new_with_capacity(1);
|
let state = TransitionState::new_with_capacity(1);
|
||||||
@@ -7647,6 +7624,46 @@ mod tests {
|
|||||||
assert_eq!(deleted.object_name, "key");
|
assert_eq!(deleted.object_name, "key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_deleted_object_hands_off_only_persisted_delete_admission_state() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let marker_result = ObjectInfo {
|
||||||
|
delete_marker: true,
|
||||||
|
version_id: Some(Uuid::new_v4()),
|
||||||
|
replication_status_internal: Some("arn:target=PENDING;".to_string()),
|
||||||
|
replication_decision: "arn:target=true".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let marker_delete = lifecycle_deleted_object(&source, &marker_result);
|
||||||
|
let marker_state = marker_delete
|
||||||
|
.replication_state
|
||||||
|
.expect("persisted marker admission must be handed off");
|
||||||
|
assert_eq!(marker_state.replication_status_internal.as_deref(), Some("arn:target=PENDING;"));
|
||||||
|
assert!(marker_state.version_purge_status_internal.is_none());
|
||||||
|
|
||||||
|
let version_result = ObjectInfo {
|
||||||
|
version_purge_status_internal: Some("arn:target=PENDING;".to_string()),
|
||||||
|
replication_decision: "arn:target=true".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let version_delete = lifecycle_deleted_object(
|
||||||
|
&ObjectInfo {
|
||||||
|
version_id: Some(Uuid::new_v4()),
|
||||||
|
..source
|
||||||
|
},
|
||||||
|
&version_result,
|
||||||
|
);
|
||||||
|
let version_state = version_delete
|
||||||
|
.replication_state
|
||||||
|
.expect("persisted version purge admission must be handed off");
|
||||||
|
assert!(version_state.replication_status_internal.is_none());
|
||||||
|
assert_eq!(version_state.version_purge_status_internal.as_deref(), Some("arn:target=PENDING;"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifecycle_deleted_object_uses_version_id_for_noncurrent_version_purge() {
|
fn lifecycle_deleted_object_uses_version_id_for_noncurrent_version_purge() {
|
||||||
let version_id = Uuid::new_v4();
|
let version_id = Uuid::new_v4();
|
||||||
@@ -7682,77 +7699,6 @@ mod tests {
|
|||||||
assert_eq!(deleted.version_id, None);
|
assert_eq!(deleted.version_id, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replication_state_for_delete_uses_replication_targets_for_current_delete() {
|
|
||||||
let arn = "arn:aws:s3:::target-bucket";
|
|
||||||
let mut dsc = ReplicateDecision::default();
|
|
||||||
dsc.set(ReplicateTargetDecision::new(arn.to_string(), true, false));
|
|
||||||
|
|
||||||
let state = replication_state_for_delete(dsc, false);
|
|
||||||
|
|
||||||
assert_eq!(state.replication_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
|
|
||||||
assert!(state.version_purge_status_internal.is_none());
|
|
||||||
assert!(state.targets.contains_key(arn));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replication_state_for_delete_uses_purge_targets_for_version_delete() {
|
|
||||||
let arn = "arn:aws:s3:::target-bucket";
|
|
||||||
let mut dsc = ReplicateDecision::default();
|
|
||||||
dsc.set(ReplicateTargetDecision::new(arn.to_string(), true, false));
|
|
||||||
|
|
||||||
let state = replication_state_for_delete(dsc, true);
|
|
||||||
|
|
||||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
|
|
||||||
assert!(state.replication_status_internal.is_none());
|
|
||||||
assert!(state.purge_targets.contains_key(arn));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lifecycle_delete_replication_state_reuses_only_pending_version_purge_state() {
|
|
||||||
let oi = ObjectInfo {
|
|
||||||
version_purge_status: VersionPurgeStatusType::Pending,
|
|
||||||
version_purge_status_internal: Some("arn:aws:s3:::target=PENDING;".to_string()),
|
|
||||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(should_reuse_lifecycle_delete_replication_state(&oi, true));
|
|
||||||
assert!(!should_reuse_lifecycle_delete_replication_state(&oi, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lifecycle_delete_replication_state_does_not_reuse_put_replication_for_version_delete() {
|
|
||||||
let oi = ObjectInfo {
|
|
||||||
replication_status: ReplicationStatusType::Completed,
|
|
||||||
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
|
|
||||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!should_reuse_lifecycle_delete_replication_state(&oi, true),
|
|
||||||
"version purges must not reuse plain object replication state from prior PUT/delete-marker replication"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lifecycle_version_purge_state_from_completed_targets_derives_pending_purge_targets() {
|
|
||||||
let oi = ObjectInfo {
|
|
||||||
replication_status: ReplicationStatusType::Completed,
|
|
||||||
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
|
|
||||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = lifecycle_version_purge_state_from_completed_targets(&oi)
|
|
||||||
.expect("completed replication targets should be convertible into version-purge targets");
|
|
||||||
|
|
||||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:aws:s3:::target=PENDING;"));
|
|
||||||
assert!(state.purge_targets.contains_key("arn:aws:s3:::target"));
|
|
||||||
assert_eq!(state.replicate_decision_str, oi.replication_decision);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expired_delete_marker_lifecycle() -> BucketLifecycleConfiguration {
|
fn expired_delete_marker_lifecycle() -> BucketLifecycleConfiguration {
|
||||||
BucketLifecycleConfiguration {
|
BucketLifecycleConfiguration {
|
||||||
expiry_updated_at: None,
|
expiry_updated_at: None,
|
||||||
@@ -10204,6 +10150,8 @@ mod tests {
|
|||||||
backend_identity: Some(identity),
|
backend_identity: Some(identity),
|
||||||
version_id_exact: false,
|
version_id_exact: false,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Unknown,
|
version_state: rustfs_filemeta::TransitionVersionState::Unknown,
|
||||||
|
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
|
let err = crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
|
||||||
@@ -10243,6 +10191,8 @@ mod tests {
|
|||||||
backend_identity: Some(identity),
|
backend_identity: Some(identity),
|
||||||
version_id_exact: true,
|
version_id_exact: true,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
crate::set_disk::cleanup_rejected_transition_upload_durably(
|
crate::set_disk::cleanup_rejected_transition_upload_durably(
|
||||||
|
|||||||
@@ -15,15 +15,14 @@
|
|||||||
use rustfs_common::metrics::IlmAction;
|
use rustfs_common::metrics::IlmAction;
|
||||||
|
|
||||||
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
||||||
|
pub(crate) use crate::bucket::replication::ReplicationStatusType;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::bucket::replication::ReplicateTargetDecision;
|
pub(crate) use crate::bucket::replication::VersionPurgeStatusType;
|
||||||
pub(crate) use crate::bucket::replication::{
|
pub(crate) use crate::bucket::replication::{
|
||||||
ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, replication_state_to_filemeta,
|
DeleteReplicationConfigSnapshot, ReplicationObjectBridge, replication_state_to_filemeta,
|
||||||
replication_statuses_map, version_purge_statuses_map,
|
|
||||||
};
|
};
|
||||||
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationLifecycleConfig};
|
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationLifecycleConfig};
|
||||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
use crate::storage_api_contracts::object::DeletedObject;
|
||||||
use crate::storage_api_contracts::object::{DeletedObject, ObjectToDelete};
|
|
||||||
|
|
||||||
pub(crate) type LifecycleReplicationConfig = ReplicationLifecycleConfig;
|
pub(crate) type LifecycleReplicationConfig = ReplicationLifecycleConfig;
|
||||||
|
|
||||||
@@ -57,15 +56,6 @@ pub(crate) fn lifecycle_action_waits_for_replication(action: IlmAction) -> bool
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn check_delete_replication(
|
|
||||||
bucket: &str,
|
|
||||||
object: ObjectToDelete,
|
|
||||||
source: &ObjectInfo,
|
|
||||||
opts: &ObjectOptions,
|
|
||||||
) -> ReplicateDecision {
|
|
||||||
ReplicationLifecycleBridge::check_delete_replication(bucket, &object, source, opts).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn schedule_delete(bucket: String, delete_object: DeletedObject) {
|
pub(crate) async fn schedule_delete(bucket: String, delete_object: DeletedObject) {
|
||||||
ReplicationLifecycleBridge::schedule_delete(bucket, delete_object).await;
|
ReplicationLifecycleBridge::schedule_delete(bucket, delete_object).await;
|
||||||
}
|
}
|
||||||
@@ -74,7 +64,16 @@ pub(crate) async fn schedule_delete(bucket: String, delete_object: DeletedObject
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||||
|
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||||
|
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||||
use rustfs_common::metrics::IlmAction;
|
use rustfs_common::metrics::IlmAction;
|
||||||
|
use s3s::dto::{
|
||||||
|
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||||
|
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||||
|
VersioningConfiguration,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -139,4 +138,97 @@ mod tests {
|
|||||||
assert!(lifecycle_action_waits_for_replication(IlmAction::TransitionVersionAction));
|
assert!(lifecycle_action_waits_for_replication(IlmAction::TransitionVersionAction));
|
||||||
assert!(!lifecycle_action_waits_for_replication(IlmAction::NoneAction));
|
assert!(!lifecycle_action_waits_for_replication(IlmAction::NoneAction));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_delete_admission_uses_marker_and_version_switches_for_all_purges() {
|
||||||
|
for marker_enabled in [false, true] {
|
||||||
|
for purge_enabled in [false, true] {
|
||||||
|
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||||
|
VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![ReplicationRule {
|
||||||
|
delete_marker_replication: Some(DeleteMarkerReplication {
|
||||||
|
status: Some(if marker_enabled {
|
||||||
|
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)
|
||||||
|
} else {
|
||||||
|
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete_replication: Some(DeleteReplication {
|
||||||
|
status: if purge_enabled {
|
||||||
|
DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED)
|
||||||
|
} else {
|
||||||
|
DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
destination: Destination {
|
||||||
|
bucket: "arn:rustfs:replication:target".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
existing_object_replication: None,
|
||||||
|
filter: None,
|
||||||
|
id: Some("lifecycle-delete-switches".to_string()),
|
||||||
|
prefix: Some(String::new()),
|
||||||
|
priority: Some(1),
|
||||||
|
source_selection_criteria: None,
|
||||||
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let source = ObjectInfo {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
name: "logs/object".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let marker = ObjectToDelete {
|
||||||
|
object_name: source.name.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let marker_opts = ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ReplicationObjectBridge::check_delete_with_snapshot(&marker, &source, &marker_opts, false, &snapshot)
|
||||||
|
.replicate_any(),
|
||||||
|
marker_enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
for delete_marker in [false, true] {
|
||||||
|
for version_id in [Uuid::new_v4(), Uuid::nil()] {
|
||||||
|
let purge = ObjectToDelete {
|
||||||
|
object_name: source.name.clone(),
|
||||||
|
version_id: Some(version_id),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let purge_source = ObjectInfo {
|
||||||
|
delete_marker,
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
let purge_opts = ObjectOptions {
|
||||||
|
version_id: Some(version_id.to_string()),
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ReplicationObjectBridge::check_delete_with_snapshot(
|
||||||
|
&purge,
|
||||||
|
&purge_source,
|
||||||
|
&purge_opts,
|
||||||
|
false,
|
||||||
|
&snapshot,
|
||||||
|
)
|
||||||
|
.replicate_any(),
|
||||||
|
purge_enabled,
|
||||||
|
"delete marker={delete_marker}, version_id={version_id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::bucket::lifecycle::config_boundary;
|
use crate::bucket::lifecycle::config_boundary;
|
||||||
|
use crate::bucket::lifecycle::runtime_boundary;
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
Jentry, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
Jentry, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
|
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||||
};
|
};
|
||||||
use crate::disk::RUSTFS_META_BUCKET;
|
use crate::disk::RUSTFS_META_BUCKET;
|
||||||
@@ -30,7 +32,7 @@ use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader
|
|||||||
use crate::services::tier::tier::tier_destination_id_from_metadata;
|
use crate::services::tier::tier::tier_destination_id_from_metadata;
|
||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
list::ListOperations as _,
|
list::ListOperations as _,
|
||||||
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
|
object::{DeletedObject, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||||
range::HTTPRangeSpec,
|
range::HTTPRangeSpec,
|
||||||
};
|
};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
@@ -46,6 +48,7 @@ const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
|||||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
||||||
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
||||||
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
||||||
|
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
|
||||||
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
|
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -61,13 +64,22 @@ struct PersistedTierDeleteJournalEntry {
|
|||||||
version_id_exact: Option<bool>,
|
version_id_exact: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
version_state: Option<rustfs_filemeta::TransitionVersionState>,
|
version_state: Option<rustfs_filemeta::TransitionVersionState>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
state: Option<TierDeleteJournalState>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
source: Option<TierDeleteSourceIdentity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PersistedTierDeleteJournalEntry {
|
impl PersistedTierDeleteJournalEntry {
|
||||||
fn from_jentry(je: &Jentry) -> Result<Self> {
|
fn from_jentry(je: &Jentry) -> Result<Self> {
|
||||||
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
||||||
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
||||||
let version = if legacy_unknown {
|
let version = if je.source.is_some() || je.state == TierDeleteJournalState::Prepared {
|
||||||
|
if je.backend_identity.is_none() {
|
||||||
|
return Err(Error::other("tier delete transaction is missing its backend identity"));
|
||||||
|
}
|
||||||
|
TIER_DELETE_JOURNAL_TRANSACTION_VERSION
|
||||||
|
} else if legacy_unknown {
|
||||||
if je.backend_identity.is_some() {
|
if je.backend_identity.is_some() {
|
||||||
TIER_DELETE_JOURNAL_VERSION
|
TIER_DELETE_JOURNAL_VERSION
|
||||||
} else {
|
} else {
|
||||||
@@ -87,6 +99,10 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
backend_identity: je.backend_identity,
|
backend_identity: je.backend_identity,
|
||||||
version_id_exact: je.version_id_exact.then_some(true),
|
version_id_exact: je.version_id_exact.then_some(true),
|
||||||
version_state: (!legacy_unknown).then_some(je.version_state),
|
version_state: (!legacy_unknown).then_some(je.version_state),
|
||||||
|
state: (version == TIER_DELETE_JOURNAL_TRANSACTION_VERSION).then_some(je.state),
|
||||||
|
source: (version == TIER_DELETE_JOURNAL_TRANSACTION_VERSION)
|
||||||
|
.then(|| je.source.clone())
|
||||||
|
.flatten(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +117,21 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
}
|
}
|
||||||
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION
|
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION
|
||||||
&& self.version != TIER_DELETE_JOURNAL_STATE_VERSION
|
&& self.version != TIER_DELETE_JOURNAL_STATE_VERSION
|
||||||
|
&& self.version != TIER_DELETE_JOURNAL_TRANSACTION_VERSION
|
||||||
&& self.version_id_exact.unwrap_or(false)
|
&& self.version_id_exact.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return Err(Error::other(
|
return Err(Error::other(
|
||||||
"legacy tier delete journal entry has an unsupported exact version constraint",
|
"legacy tier delete journal entry has an unsupported exact version constraint",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let (backend_identity, version_id_exact, version_state) = match self.version {
|
let (backend_identity, version_id_exact, version_state, state, source) = match self.version {
|
||||||
1 => (None, false, rustfs_filemeta::TransitionVersionState::Unknown),
|
1 => (
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
rustfs_filemeta::TransitionVersionState::Unknown,
|
||||||
|
TierDeleteJournalState::Committed,
|
||||||
|
None,
|
||||||
|
),
|
||||||
TIER_DELETE_JOURNAL_VERSION => (
|
TIER_DELETE_JOURNAL_VERSION => (
|
||||||
Some(
|
Some(
|
||||||
self.backend_identity
|
self.backend_identity
|
||||||
@@ -116,6 +139,8 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
rustfs_filemeta::TransitionVersionState::Unknown,
|
rustfs_filemeta::TransitionVersionState::Unknown,
|
||||||
|
TierDeleteJournalState::Committed,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
TIER_DELETE_JOURNAL_EXACT_VERSION => {
|
TIER_DELETE_JOURNAL_EXACT_VERSION => {
|
||||||
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
|
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
|
||||||
@@ -128,6 +153,8 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
rustfs_filemeta::TransitionVersionState::Exact,
|
rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
TierDeleteJournalState::Committed,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TIER_DELETE_JOURNAL_STATE_VERSION => {
|
TIER_DELETE_JOURNAL_STATE_VERSION => {
|
||||||
@@ -143,6 +170,31 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
),
|
),
|
||||||
exact,
|
exact,
|
||||||
state,
|
state,
|
||||||
|
TierDeleteJournalState::Committed,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TIER_DELETE_JOURNAL_TRANSACTION_VERSION => {
|
||||||
|
let state = self
|
||||||
|
.state
|
||||||
|
.ok_or_else(|| Error::other("tier delete journal v5 entry is missing its state"))?;
|
||||||
|
let source = self
|
||||||
|
.source
|
||||||
|
.ok_or_else(|| Error::other("tier delete journal v5 entry is missing its source identity"))?;
|
||||||
|
let exact = self.version_id_exact.unwrap_or(false);
|
||||||
|
let version_state = self
|
||||||
|
.version_state
|
||||||
|
.ok_or_else(|| Error::other("tier delete journal v5 entry is missing its version state"))?;
|
||||||
|
validate_version_state(version_state, &self.version_id, exact)?;
|
||||||
|
(
|
||||||
|
Some(
|
||||||
|
self.backend_identity
|
||||||
|
.ok_or_else(|| Error::other("tier delete journal v5 entry is missing its backend identity"))?,
|
||||||
|
),
|
||||||
|
exact,
|
||||||
|
version_state,
|
||||||
|
state,
|
||||||
|
Some(source),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
|
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
|
||||||
@@ -154,6 +206,8 @@ impl PersistedTierDeleteJournalEntry {
|
|||||||
backend_identity,
|
backend_identity,
|
||||||
version_id_exact,
|
version_id_exact,
|
||||||
version_state,
|
version_state,
|
||||||
|
state,
|
||||||
|
source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +255,20 @@ pub(crate) fn tier_delete_journal_object_name(je: &Jentry) -> String {
|
|||||||
hasher.update([0]);
|
hasher.update([0]);
|
||||||
hasher.update(b"exact-version-id");
|
hasher.update(b"exact-version-id");
|
||||||
}
|
}
|
||||||
|
if let Some(source) = &je.source {
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.bucket.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.object.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.version_id.as_deref().unwrap_or_default().as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.data_dir.as_deref().unwrap_or_default().as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.etag.as_deref().unwrap_or_default().as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source.mod_time.as_deref().unwrap_or_default().as_bytes());
|
||||||
|
}
|
||||||
format!(
|
format!(
|
||||||
"{TIER_DELETE_JOURNAL_PREFIX}{}.json",
|
"{TIER_DELETE_JOURNAL_PREFIX}{}.json",
|
||||||
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
|
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
|
||||||
@@ -246,6 +314,66 @@ where
|
|||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn commit_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
|
||||||
|
where
|
||||||
|
S: ObjectIO<
|
||||||
|
Error = Error,
|
||||||
|
RangeSpec = HTTPRangeSpec,
|
||||||
|
HeaderMap = http::HeaderMap,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
GetObjectReader = GetObjectReader,
|
||||||
|
PutObjectReader = PutObjReader,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
let mut committed = je.clone();
|
||||||
|
committed.state = TierDeleteJournalState::Committed;
|
||||||
|
persist_tier_delete_journal_entry(api, &committed).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn abort_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
|
||||||
|
where
|
||||||
|
S: ObjectOperations<
|
||||||
|
Error = Error,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
FileInfo = FileInfo,
|
||||||
|
ObjectToDelete = ObjectToDelete,
|
||||||
|
DeletedObject = DeletedObject,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
remove_tier_delete_journal_entry(api, je).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn abort_prepared_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||||
|
let name = tier_delete_journal_object_name(je);
|
||||||
|
let (data, metadata) = match config_boundary::read_config_with_metadata(api.clone(), &name, &ObjectOptions::default()).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(Error::ConfigNotFound) | Err(Error::FileNotFound) => return Ok(()),
|
||||||
|
Err(err) => return Err(std::io::Error::other(err)),
|
||||||
|
};
|
||||||
|
let current = decode_tier_delete_journal_entry(&data).map_err(std::io::Error::other)?;
|
||||||
|
if current.state != TierDeleteJournalState::Prepared {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let etag = metadata
|
||||||
|
.etag
|
||||||
|
.ok_or_else(|| std::io::Error::other("prepared tier delete journal has no entity tag"))?;
|
||||||
|
match config_boundary::delete_config_if_match(api, &name, &etag).await {
|
||||||
|
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
|
||||||
|
Err(Error::PreconditionFailed) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"prepared tier delete journal changed before abort",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(std::io::Error::other(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn enqueue_committed_tier_delete_journal_entry(je: &Jentry) -> std::io::Result<()> {
|
||||||
|
let expiry_state = runtime_boundary::expiry_state_handle();
|
||||||
|
expiry_state.write().await.enqueue_tier_journal_entry(je)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
|
pub async fn remove_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
|
||||||
where
|
where
|
||||||
S: ObjectOperations<
|
S: ObjectOperations<
|
||||||
@@ -264,6 +392,13 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||||
|
if je.state == TierDeleteJournalState::Prepared {
|
||||||
|
return reconcile_prepared_tier_delete_journal_entry(api, je).await;
|
||||||
|
}
|
||||||
|
process_committed_tier_delete_journal_entry(api, je).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_committed_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||||
if je.version_state == rustfs_filemeta::TransitionVersionState::Unknown {
|
if je.version_state == rustfs_filemeta::TransitionVersionState::Unknown {
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
std::io::ErrorKind::InvalidData,
|
||||||
@@ -296,6 +431,87 @@ pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -
|
|||||||
remove_tier_delete_journal_entry(api, je).await
|
remove_tier_delete_journal_entry(api, je).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reconcile_prepared_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||||
|
let (data, metadata) =
|
||||||
|
config_boundary::read_config_with_metadata(api.clone(), &tier_delete_journal_object_name(je), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
let current = decode_tier_delete_journal_entry(&data).map_err(std::io::Error::other)?;
|
||||||
|
if current.state != TierDeleteJournalState::Prepared {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"prepared tier delete journal changed before reconciliation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(etag) = metadata.etag else {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"prepared tier delete journal has no entity tag",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let source = je
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "prepared tier delete journal has no source"))?;
|
||||||
|
match api
|
||||||
|
.get_object_info(&source.bucket, &source.object, &source.lookup_options())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) if source.matches(&info) => {
|
||||||
|
match config_boundary::delete_config_if_match(api, &tier_delete_journal_object_name(¤t), &etag).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(Error::PreconditionFailed) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"prepared tier delete journal changed before abort",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(std::io::Error::other(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_info) if source.has_stable_identity() => {
|
||||||
|
commit_prepared_tier_delete_journal_entry_if_current(api, current, etag).await
|
||||||
|
}
|
||||||
|
Ok(_) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"prepared tier delete journal source identity is not sufficient to confirm deletion",
|
||||||
|
)),
|
||||||
|
Err(Error::ObjectNotFound(_, _)) | Err(Error::FileNotFound) | Err(Error::FileVersionNotFound) => {
|
||||||
|
commit_prepared_tier_delete_journal_entry_if_current(api, current, etag).await
|
||||||
|
}
|
||||||
|
Err(err) => Err(std::io::Error::other(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_prepared_tier_delete_journal_entry_if_current(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
mut committed: Jentry,
|
||||||
|
etag: String,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
committed.state = TierDeleteJournalState::Committed;
|
||||||
|
let data = encode_tier_delete_journal_entry(&committed).map_err(std::io::Error::other)?;
|
||||||
|
match config_boundary::save_config_with_opts(
|
||||||
|
api.clone(),
|
||||||
|
&tier_delete_journal_object_name(&committed),
|
||||||
|
data,
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_match: Some(etag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => process_committed_tier_delete_journal_entry(api, &committed).await,
|
||||||
|
Err(Error::PreconditionFailed) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"prepared tier delete journal changed before commit",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(std::io::Error::other(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn recover_tier_delete_journal_entries(
|
pub async fn recover_tier_delete_journal_entries(
|
||||||
api: Arc<ECStore>,
|
api: Arc<ECStore>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
@@ -482,10 +698,13 @@ mod tests {
|
|||||||
decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
||||||
tier_delete_journal_object_name,
|
tier_delete_journal_object_name,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
use crate::bucket::lifecycle::tier_sweeper::{Jentry, TierDeleteJournalState, TierDeleteSourceIdentity};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::object_api::ObjectInfo;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn journal_entry() -> Jentry {
|
fn journal_entry() -> Jentry {
|
||||||
Jentry {
|
Jentry {
|
||||||
@@ -495,6 +714,8 @@ mod tests {
|
|||||||
backend_identity: Some([7; 32]),
|
backend_identity: Some([7; 32]),
|
||||||
version_id_exact: true,
|
version_id_exact: true,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
state: TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +734,55 @@ mod tests {
|
|||||||
assert_eq!(decoded.version_state, je.version_state);
|
assert_eq!(decoded.version_state, je.version_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_delete_transaction_roundtrips_prepared_source_identity() {
|
||||||
|
let mut je = journal_entry();
|
||||||
|
je.state = TierDeleteJournalState::Prepared;
|
||||||
|
je.source = Some(TierDeleteSourceIdentity {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
object: "object".to_string(),
|
||||||
|
version_id: Some("version".to_string()),
|
||||||
|
versioned: true,
|
||||||
|
version_suspended: false,
|
||||||
|
data_dir: Some("data-dir".to_string()),
|
||||||
|
etag: Some("etag".to_string()),
|
||||||
|
mod_time: Some("mod-time".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let encoded = encode_tier_delete_journal_entry(&je).expect("prepared transaction should encode");
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&encoded).expect("transaction should be JSON");
|
||||||
|
assert_eq!(value["version"], serde_json::json!(5));
|
||||||
|
assert_eq!(value["state"], serde_json::json!("Prepared"));
|
||||||
|
assert!(value["source"].is_object());
|
||||||
|
|
||||||
|
let decoded = decode_tier_delete_journal_entry(&encoded).expect("prepared transaction should decode");
|
||||||
|
assert_eq!(decoded.state, TierDeleteJournalState::Prepared);
|
||||||
|
assert_eq!(decoded.source, je.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_delete_source_identity_rejects_recreated_object() {
|
||||||
|
let version_id = Uuid::from_u128(1);
|
||||||
|
let data_dir = Uuid::from_u128(2);
|
||||||
|
let mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1);
|
||||||
|
let info = ObjectInfo {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
version_id: Some(version_id),
|
||||||
|
data_dir: Some(data_dir),
|
||||||
|
mod_time: Some(mod_time),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let source = TierDeleteSourceIdentity::from_object_info("bucket", "object", &info, true, false);
|
||||||
|
assert!(source.matches(&info));
|
||||||
|
|
||||||
|
let recreated = ObjectInfo {
|
||||||
|
data_dir: Some(Uuid::from_u128(3)),
|
||||||
|
..info
|
||||||
|
};
|
||||||
|
assert!(!source.matches(&recreated));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tier_delete_journal_roundtrips_exact_put_response_constraint() {
|
fn tier_delete_journal_roundtrips_exact_put_response_constraint() {
|
||||||
let mut exact = journal_entry();
|
let mut exact = journal_entry();
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
|||||||
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
||||||
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
||||||
use crate::client::signer_error::error_chain_contains_signer_header_marker;
|
use crate::client::signer_error::error_chain_contains_signer_header_marker;
|
||||||
|
use crate::object_api::ObjectInfo;
|
||||||
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
||||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
use rustfs_utils::get_env_usize;
|
use rustfs_utils::get_env_usize;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -257,6 +259,8 @@ impl ObjSweeper {
|
|||||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||||
),
|
),
|
||||||
version_state: self.transition_version_state,
|
version_state: self.transition_version_state,
|
||||||
|
state: TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -285,6 +289,76 @@ impl ObjSweeper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TierDeleteJournalState {
|
||||||
|
Prepared,
|
||||||
|
Committed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct TierDeleteSourceIdentity {
|
||||||
|
pub(crate) bucket: String,
|
||||||
|
pub(crate) object: String,
|
||||||
|
pub(crate) version_id: Option<String>,
|
||||||
|
pub(crate) versioned: bool,
|
||||||
|
pub(crate) version_suspended: bool,
|
||||||
|
pub(crate) data_dir: Option<String>,
|
||||||
|
pub(crate) etag: Option<String>,
|
||||||
|
pub(crate) mod_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TierDeleteSourceIdentity {
|
||||||
|
pub(crate) fn from_object_info(
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
info: &ObjectInfo,
|
||||||
|
versioned: bool,
|
||||||
|
version_suspended: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
object: object.to_string(),
|
||||||
|
version_id: info.version_id.map(|id| id.to_string()),
|
||||||
|
versioned,
|
||||||
|
version_suspended,
|
||||||
|
data_dir: info.data_dir.map(|id| id.to_string()),
|
||||||
|
etag: info.etag.clone(),
|
||||||
|
mod_time: info.mod_time.map(|time| time.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lookup_options(&self) -> crate::object_api::ObjectOptions {
|
||||||
|
crate::object_api::ObjectOptions {
|
||||||
|
version_id: self.version_id.clone(),
|
||||||
|
versioned: self.versioned,
|
||||||
|
version_suspended: self.version_suspended,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matches(&self, info: &ObjectInfo) -> bool {
|
||||||
|
if self.bucket != info.bucket {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(version_id) = &self.version_id {
|
||||||
|
return info.version_id.map(|id| id.to_string()).as_deref() == Some(version_id.as_str())
|
||||||
|
&& self.data_dir == info.data_dir.map(|id| id.to_string());
|
||||||
|
}
|
||||||
|
if self.data_dir.is_some() {
|
||||||
|
return self.data_dir == info.data_dir.map(|id| id.to_string());
|
||||||
|
}
|
||||||
|
self.etag.is_some()
|
||||||
|
&& self.etag == info.etag
|
||||||
|
&& self.mod_time.is_some()
|
||||||
|
&& self.mod_time == info.mod_time.map(|time| time.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_stable_identity(&self) -> bool {
|
||||||
|
self.version_id.is_some() || self.data_dir.is_some() || (self.etag.is_some() && self.mod_time.is_some())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(unused_assignments)]
|
#[allow(unused_assignments)]
|
||||||
pub struct Jentry {
|
pub struct Jentry {
|
||||||
@@ -294,6 +368,8 @@ pub struct Jentry {
|
|||||||
pub(crate) backend_identity: Option<TierDestinationId>,
|
pub(crate) backend_identity: Option<TierDestinationId>,
|
||||||
pub(crate) version_id_exact: bool,
|
pub(crate) version_id_exact: bool,
|
||||||
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
|
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
|
||||||
|
pub(crate) state: TierDeleteJournalState,
|
||||||
|
pub(crate) source: Option<TierDeleteSourceIdentity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExpiryOp for Jentry {
|
impl ExpiryOp for Jentry {
|
||||||
@@ -554,9 +630,48 @@ pub fn transitioned_force_delete_journal_entry(
|
|||||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||||
),
|
),
|
||||||
version_state: transition_version_state,
|
version_state: transition_version_state,
|
||||||
|
state: TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_tier_delete_source(
|
||||||
|
je: &mut Jentry,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
info: &ObjectInfo,
|
||||||
|
versioned: bool,
|
||||||
|
version_suspended: bool,
|
||||||
|
) {
|
||||||
|
je.state = TierDeleteJournalState::Prepared;
|
||||||
|
je.source = Some(TierDeleteSourceIdentity::from_object_info(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
info,
|
||||||
|
versioned,
|
||||||
|
version_suspended,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transitioned_delete_journal_entry_for_source(
|
||||||
|
version_id: Option<Uuid>,
|
||||||
|
versioned: bool,
|
||||||
|
suspended: bool,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
source: &ObjectInfo,
|
||||||
|
) -> Option<Jentry> {
|
||||||
|
let mut je = transitioned_delete_journal_entry(
|
||||||
|
version_id,
|
||||||
|
versioned,
|
||||||
|
suspended,
|
||||||
|
&source.transitioned_object,
|
||||||
|
source.transition_version_state,
|
||||||
|
)?;
|
||||||
|
attach_tier_delete_source(&mut je, bucket, object, source, versioned, suspended);
|
||||||
|
Some(je)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use crate::client::signer_error::invalid_utf8_header_error;
|
use crate::client::signer_error::invalid_utf8_header_error;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
|
|||||||
use super::object_lock::ObjectLockApi;
|
use super::object_lock::ObjectLockApi;
|
||||||
use super::versioning::VersioningApi;
|
use super::versioning::VersioningApi;
|
||||||
use super::{quota::BucketQuota, target::BucketTargets};
|
use super::{quota::BucketQuota, target::BucketTargets};
|
||||||
|
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||||
use crate::bucket::utils::deserialize;
|
use crate::bucket::utils::deserialize;
|
||||||
use crate::config::com::{read_config, save_config};
|
use crate::config::com::{read_config, save_config};
|
||||||
use crate::disk::BUCKET_META_PREFIX;
|
use crate::disk::BUCKET_META_PREFIX;
|
||||||
@@ -25,9 +26,9 @@ use crate::store::ECStore;
|
|||||||
use byteorder::{BigEndian, ByteOrder, LittleEndian};
|
use byteorder::{BigEndian, ByteOrder, LittleEndian};
|
||||||
use rustfs_policy::policy::BucketPolicy;
|
use rustfs_policy::policy::BucketPolicy;
|
||||||
use s3s::dto::{
|
use s3s::dto::{
|
||||||
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, CORSConfiguration, NotificationConfiguration,
|
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, BucketVersioningStatus, CORSConfiguration,
|
||||||
ObjectLockConfiguration, PublicAccessBlockConfiguration, ReplicationConfiguration, RequestPaymentConfiguration,
|
NotificationConfiguration, ObjectLockConfiguration, PublicAccessBlockConfiguration, ReplicationConfiguration,
|
||||||
ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
|
RequestPaymentConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
|
||||||
};
|
};
|
||||||
use serde::Serializer;
|
use serde::Serializer;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -751,11 +752,33 @@ impl BucketMetadata {
|
|||||||
self.object_lock_config_updated_at = updated;
|
self.object_lock_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
BUCKET_VERSIONING_CONFIG => {
|
BUCKET_VERSIONING_CONFIG => {
|
||||||
|
let config = if data.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let config = deserialize::<VersioningConfiguration>(&data)?;
|
||||||
|
if config.status.as_ref().is_some_and(|status| {
|
||||||
|
!matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED)
|
||||||
|
}) {
|
||||||
|
return Err(Error::other("bucket versioning configuration has an invalid status"));
|
||||||
|
}
|
||||||
|
Some(config)
|
||||||
|
};
|
||||||
self.versioning_config_xml = data;
|
self.versioning_config_xml = data;
|
||||||
|
self.versioning_config = config;
|
||||||
self.versioning_config_updated_at = updated;
|
self.versioning_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
BUCKET_REPLICATION_CONFIG => {
|
BUCKET_REPLICATION_CONFIG => {
|
||||||
|
let config = if data.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let config = deserialize::<ReplicationConfiguration>(&data)?;
|
||||||
|
if let Some(field) = invalid_replication_config_status_field(&config) {
|
||||||
|
return Err(Error::other(format!("replication field {field} has an invalid status")));
|
||||||
|
}
|
||||||
|
Some(config)
|
||||||
|
};
|
||||||
self.replication_config_xml = data;
|
self.replication_config_xml = data;
|
||||||
|
self.replication_config = config;
|
||||||
self.replication_config_updated_at = updated;
|
self.replication_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
BUCKET_TARGETS_FILE => {
|
BUCKET_TARGETS_FILE => {
|
||||||
@@ -825,7 +848,6 @@ impl BucketMetadata {
|
|||||||
/// ambient (first) one. [`BucketMetadata::save`] keeps the ambient default.
|
/// ambient (first) one. [`BucketMetadata::save`] keeps the ambient default.
|
||||||
pub async fn save_with_store(&mut self, store: std::sync::Arc<crate::store::ECStore>) -> Result<()> {
|
pub async fn save_with_store(&mut self, store: std::sync::Arc<crate::store::ECStore>) -> Result<()> {
|
||||||
self.parse_all_configs()?;
|
self.parse_all_configs()?;
|
||||||
|
|
||||||
let mut buf: Vec<u8> = vec![0; 4];
|
let mut buf: Vec<u8> = vec![0; 4];
|
||||||
|
|
||||||
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
|
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
|
||||||
@@ -906,6 +928,7 @@ impl BucketMetadata {
|
|||||||
"Failed to parse bucket metadata config"
|
"Failed to parse bucket metadata config"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
self.versioning_config = None;
|
||||||
if !self.versioning_config_xml.is_empty()
|
if !self.versioning_config_xml.is_empty()
|
||||||
&& let Err(e) =
|
&& let Err(e) =
|
||||||
deserialize::<VersioningConfiguration>(&self.versioning_config_xml).map(|c| self.versioning_config = Some(c))
|
deserialize::<VersioningConfiguration>(&self.versioning_config_xml).map(|c| self.versioning_config = Some(c))
|
||||||
@@ -960,6 +983,7 @@ impl BucketMetadata {
|
|||||||
"Failed to parse bucket metadata config"
|
"Failed to parse bucket metadata config"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
self.replication_config = None;
|
||||||
if !self.replication_config_xml.is_empty()
|
if !self.replication_config_xml.is_empty()
|
||||||
&& let Err(e) =
|
&& let Err(e) =
|
||||||
deserialize::<ReplicationConfiguration>(&self.replication_config_xml).map(|c| self.replication_config = Some(c))
|
deserialize::<ReplicationConfiguration>(&self.replication_config_xml).map(|c| self.replication_config = Some(c))
|
||||||
@@ -1345,6 +1369,66 @@ mod test {
|
|||||||
assert!(bm.tagging_config.is_none());
|
assert!(bm.tagging_config.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_admission_configs_update_parsed_state_atomically() {
|
||||||
|
let mut bm = BucketMetadata::new("test-bucket");
|
||||||
|
let versioning_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>";
|
||||||
|
let replication_xml = b"<ReplicationConfiguration><Role>arn:aws:s3:::target-bucket</Role><Rule><ID>rule1</ID><Status>Enabled</Status><Prefix></Prefix><Destination><Bucket>arn:aws:s3:::target-bucket</Bucket></Destination></Rule></ReplicationConfiguration>";
|
||||||
|
|
||||||
|
bm.update_config(BUCKET_VERSIONING_CONFIG, versioning_xml.to_vec())
|
||||||
|
.expect("valid versioning config should update parsed state");
|
||||||
|
bm.update_config(BUCKET_REPLICATION_CONFIG, replication_xml.to_vec())
|
||||||
|
.expect("valid replication config should update parsed state");
|
||||||
|
|
||||||
|
assert!(bm.versioning_config.as_ref().is_some_and(VersioningConfiguration::enabled));
|
||||||
|
assert_eq!(
|
||||||
|
bm.replication_config.as_ref().map(|config| config.role.as_str()),
|
||||||
|
Some("arn:aws:s3:::target-bucket")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bm.update_config(BUCKET_VERSIONING_CONFIG, b"<VersioningConfiguration>".to_vec())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bm.update_config(BUCKET_REPLICATION_CONFIG, b"<ReplicationConfiguration>".to_vec())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(bm.versioning_config_xml, versioning_xml);
|
||||||
|
assert_eq!(bm.replication_config_xml, replication_xml);
|
||||||
|
assert!(bm.versioning_config.as_ref().is_some_and(VersioningConfiguration::enabled));
|
||||||
|
assert_eq!(
|
||||||
|
bm.replication_config.as_ref().map(|config| config.role.as_str()),
|
||||||
|
Some("arn:aws:s3:::target-bucket")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bm.update_config(
|
||||||
|
BUCKET_VERSIONING_CONFIG,
|
||||||
|
b"<VersioningConfiguration><Status>Enabld</Status></VersioningConfiguration>".to_vec(),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bm.update_config(
|
||||||
|
BUCKET_REPLICATION_CONFIG,
|
||||||
|
b"<ReplicationConfiguration><Role>arn:aws:s3:::target-bucket</Role><Rule><ID>rule1</ID><Status>Enabld</Status><Prefix></Prefix><Destination><Bucket>arn:aws:s3:::target-bucket</Bucket></Destination></Rule></ReplicationConfiguration>".to_vec(),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(bm.versioning_config_xml, versioning_xml);
|
||||||
|
assert_eq!(bm.replication_config_xml, replication_xml);
|
||||||
|
|
||||||
|
bm.versioning_config_xml = b"<VersioningConfiguration>".to_vec();
|
||||||
|
bm.replication_config_xml = b"<ReplicationConfiguration>".to_vec();
|
||||||
|
bm.parse_all_configs()
|
||||||
|
.expect("bulk config parsing reports malformed fields through cleared typed state");
|
||||||
|
|
||||||
|
assert!(bm.versioning_config.is_none());
|
||||||
|
assert!(bm.replication_config.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn marshal_msg_complete_example() {
|
async fn marshal_msg_complete_example() {
|
||||||
// Create a complete BucketMetadata with various configurations
|
// Create a complete BucketMetadata with various configurations
|
||||||
|
|||||||
@@ -1027,7 +1027,6 @@ impl BucketMetadataSys {
|
|||||||
/// server's metadata never leaks into the ambient (first) instance.
|
/// server's metadata never leaks into the ambient (first) instance.
|
||||||
pub(crate) async fn persist_and_set(&self, bm: BucketMetadata) -> Result<()> {
|
pub(crate) async fn persist_and_set(&self, bm: BucketMetadata) -> Result<()> {
|
||||||
let mut bm = bm;
|
let mut bm = bm;
|
||||||
|
|
||||||
bm.save_with_store(self.api.clone()).await?;
|
bm.save_with_store(self.api.clone()).await?;
|
||||||
|
|
||||||
self.set(bm.name.clone(), Arc::new(bm)).await;
|
self.set(bm.name.clone(), Arc::new(bm)).await;
|
||||||
@@ -1221,7 +1220,9 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(config) = &bm.versioning_config {
|
if !bm.versioning_config_xml.is_empty() && bm.versioning_config.is_none() {
|
||||||
|
Err(Error::other("persisted bucket versioning configuration is invalid"))
|
||||||
|
} else if let Some(config) = &bm.versioning_config {
|
||||||
Ok((config.clone(), bm.versioning_config_updated_at))
|
Ok((config.clone(), bm.versioning_config_updated_at))
|
||||||
} else {
|
} else {
|
||||||
Ok((VersioningConfiguration::default(), bm.versioning_config_updated_at))
|
Ok((VersioningConfiguration::default(), bm.versioning_config_updated_at))
|
||||||
@@ -1407,7 +1408,9 @@ impl BucketMetadataSys {
|
|||||||
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
let (bm, _) = self.get_config(bucket).await?;
|
||||||
|
|
||||||
if let Some(config) = &bm.replication_config {
|
if !bm.replication_config_xml.is_empty() && bm.replication_config.is_none() {
|
||||||
|
Err(Error::other("persisted bucket replication configuration is invalid"))
|
||||||
|
} else if let Some(config) = &bm.replication_config {
|
||||||
Ok((config.clone(), bm.replication_config_updated_at))
|
Ok((config.clone(), bm.replication_config_updated_at))
|
||||||
} else {
|
} else {
|
||||||
Err(Error::ConfigNotFound)
|
Err(Error::ConfigNotFound)
|
||||||
@@ -1481,6 +1484,28 @@ mod tests {
|
|||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn malformed_delete_configs_are_not_treated_as_absent() {
|
||||||
|
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||||
|
let sys = BucketMetadataSys::new(ecstore);
|
||||||
|
let bucket = "malformed-delete-config";
|
||||||
|
let mut metadata = BucketMetadata::new(bucket);
|
||||||
|
metadata.versioning_config_xml = b"<VersioningConfiguration>".to_vec();
|
||||||
|
metadata.versioning_config = None;
|
||||||
|
metadata.replication_config_xml = b"<ReplicationConfiguration>".to_vec();
|
||||||
|
metadata.replication_config = None;
|
||||||
|
sys.set(bucket.to_string(), Arc::new(metadata)).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
sys.get_versioning_config(bucket).await.is_err(),
|
||||||
|
"malformed versioning metadata must block destructive requests"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sys.get_replication_config(bucket).await.is_err(),
|
||||||
|
"malformed replication metadata must not be reported as ConfigNotFound"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Concurrent cache misses for one bucket must collapse into a single disk
|
/// Concurrent cache misses for one bucket must collapse into a single disk
|
||||||
/// load.
|
/// load.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ mod runtime_boundary;
|
|||||||
|
|
||||||
pub use datatypes::ResyncStatusType;
|
pub use datatypes::ResyncStatusType;
|
||||||
pub use replication_config_boundary::{
|
pub use replication_config_boundary::{
|
||||||
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns,
|
ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
|
||||||
should_remove_replication_target, validate_replication_config_target_arns,
|
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
|
||||||
|
ReplicationConfigurationExt, ReplicationTargetValidationError, invalid_replication_config_status_field,
|
||||||
|
replication_target_arns, should_remove_replication_target, unsupported_replication_config_field,
|
||||||
|
validate_replication_config_target_arns,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use replication_filemeta_boundary::ReplicateTargetDecision;
|
|
||||||
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
||||||
pub use replication_filemeta_boundary::{
|
pub use replication_filemeta_boundary::{
|
||||||
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
|
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
|
||||||
@@ -62,23 +63,24 @@ pub(crate) use replication_filemeta_boundary::{
|
|||||||
pub(crate) use replication_lifecycle_bridge::{ReplicationLifecycleBridge, ReplicationLifecycleConfig};
|
pub(crate) use replication_lifecycle_bridge::{ReplicationLifecycleBridge, ReplicationLifecycleConfig};
|
||||||
pub(crate) use replication_migration_bridge::ReplicationMigrationBridge;
|
pub(crate) use replication_migration_bridge::ReplicationMigrationBridge;
|
||||||
pub use replication_object_bridge::ReplicationObjectBridge;
|
pub use replication_object_bridge::ReplicationObjectBridge;
|
||||||
pub use replication_object_config::ReplicationConfig;
|
pub use replication_object_config::{DeleteReplicationConfigSnapshot, ReplicationConfig};
|
||||||
pub use replication_object_decision_boundary::{
|
pub use replication_object_decision_boundary::{
|
||||||
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
||||||
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||||
should_use_existing_delete_replication_source,
|
should_use_existing_delete_replication_source,
|
||||||
};
|
};
|
||||||
pub use replication_pool::{
|
pub use replication_pool::{
|
||||||
DurableMrfBacklog, DynReplicationPool, ReplicationPoolTrait, get_global_replication_pool, get_global_replication_stats,
|
DurableMrfBacklog, DynReplicationPool, ReplicationPoolTrait, commit_force_delete_intent, complete_force_delete_intent,
|
||||||
init_background_replication, read_durable_mrf_backlog, resync_start_conflict_id,
|
get_global_replication_pool, get_global_replication_stats, init_background_replication, persist_force_delete_intent,
|
||||||
|
read_durable_mrf_backlog, resync_start_conflict_id,
|
||||||
};
|
};
|
||||||
pub use replication_queue_boundary::{
|
pub use replication_queue_boundary::{
|
||||||
DeletedObjectReplicationInfo, ReplicationHealQueueResult, ReplicationOperation, ReplicationPriority,
|
DeletedObjectReplicationInfo, ReplicationBatchAdmission, ReplicationHealQueueResult, ReplicationOperation,
|
||||||
ReplicationQueueAdmission,
|
ReplicationPriority, ReplicationQueueAdmission,
|
||||||
};
|
};
|
||||||
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
|
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
|
||||||
pub use replication_scanner_bridge::ReplicationScannerBridge;
|
pub use replication_scanner_bridge::ReplicationScannerBridge;
|
||||||
pub use replication_state::ReplicationStats;
|
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
|
||||||
pub use replication_stats_boundary::BucketStats;
|
pub use replication_stats_boundary::{BucketReplicationStats, BucketStats};
|
||||||
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
||||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
pub use rustfs_replication::{
|
pub use rustfs_replication::{
|
||||||
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns,
|
ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
|
||||||
should_remove_replication_target, validate_replication_config_target_arns,
|
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
|
||||||
|
ReplicationConfigurationExt, ReplicationRuleExt, ReplicationTargetValidationError, invalid_replication_config_status_field,
|
||||||
|
replication_target_arns, should_remove_replication_target, unsupported_replication_config_field,
|
||||||
|
validate_replication_config_target_arns,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,10 +30,24 @@ impl ReplicationConfigStore {
|
|||||||
com::read_config(api, file).await
|
com::read_config(api, file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
S: ReplicationObjectIO,
|
||||||
|
{
|
||||||
|
com::read_config_no_lock(api, file).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn save<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
pub(crate) async fn save<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||||
where
|
where
|
||||||
S: ReplicationObjectIO,
|
S: ReplicationObjectIO,
|
||||||
{
|
{
|
||||||
com::save_config(api, file, data).await
|
com::save_config(api, file, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn save_no_lock<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||||
|
where
|
||||||
|
S: ReplicationObjectIO,
|
||||||
|
{
|
||||||
|
com::save_config_no_lock(api, file, data).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub(crate) use rustfs_filemeta::NULL_VERSION_ID;
|
||||||
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos,
|
REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos,
|
||||||
ReplicatedTargetInfo, ReplicationAction, ReplicationWorkerOperation, ResyncDecision, get_replication_state,
|
ReplicatedTargetInfo, ReplicationAction, ReplicationWorkerOperation, ResyncDecision, get_replication_state,
|
||||||
parse_replicate_decision, target_reset_header, version_purge_statuses_map,
|
parse_replicate_decision, replicate_decision_for_admitted_targets, target_reset_header, version_purge_statuses_map,
|
||||||
};
|
};
|
||||||
pub use rustfs_replication::{
|
pub use rustfs_replication::{
|
||||||
REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState, ReplicationStatusType, ReplicationType,
|
REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState, ReplicationStatusType, ReplicationType,
|
||||||
|
|||||||
@@ -12,14 +12,19 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::bucket::metadata_sys;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::bucket::{metadata::BucketMetadata, metadata_sys};
|
||||||
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||||
|
use crate::runtime::instance::InstanceContext;
|
||||||
use rustfs_utils::path::path_join_buf;
|
use rustfs_utils::path::path_join_buf;
|
||||||
use s3s::dto::ReplicationConfiguration;
|
use s3s::dto::ReplicationConfiguration;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::replication_error_boundary::{Error, Result};
|
use super::replication_error_boundary::{Error, Result};
|
||||||
|
|
||||||
|
pub(crate) type ReplicationInstanceContext = InstanceContext;
|
||||||
|
|
||||||
const REPLICATION_DIR: &str = ".replication";
|
const REPLICATION_DIR: &str = ".replication";
|
||||||
const RESYNC_FILE_NAME: &str = "resync.bin";
|
const RESYNC_FILE_NAME: &str = "resync.bin";
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ pub(crate) struct ReplicationMetadataStore;
|
|||||||
|
|
||||||
impl ReplicationMetadataStore {
|
impl ReplicationMetadataStore {
|
||||||
pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin";
|
pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin";
|
||||||
|
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
|
||||||
|
|
||||||
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||||
metadata_sys::get_replication_config(bucket).await
|
metadata_sys::get_replication_config(bucket).await
|
||||||
@@ -45,6 +51,20 @@ impl ReplicationMetadataStore {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_metadata(bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||||
|
let sys = metadata_sys::get_bucket_metadata_sys()?;
|
||||||
|
let sys = sys.read().await;
|
||||||
|
Ok(sys.get_config(bucket).await?.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_metadata_in(ctx: &ReplicationInstanceContext, bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||||
|
let sys = ctx
|
||||||
|
.bucket_metadata_sys()
|
||||||
|
.ok_or_else(|| Error::other("request instance bucket metadata system is not initialized"))?;
|
||||||
|
let sys = sys.read().await;
|
||||||
|
Ok(sys.get_config(bucket).await?.0)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn rustfs_meta_bucket() -> &'static str {
|
pub(crate) fn rustfs_meta_bucket() -> &'static str {
|
||||||
RUSTFS_META_BUCKET
|
RUSTFS_META_BUCKET
|
||||||
}
|
}
|
||||||
@@ -90,5 +110,9 @@ mod tests {
|
|||||||
"buckets/bucket-a/.replication/resync.bin"
|
"buckets/bucket-a/.replication/resync.bin"
|
||||||
);
|
);
|
||||||
assert_eq!(ReplicationMetadataStore::MRF_REPLICATION_FILE, "config/replication/mrf.bin");
|
assert_eq!(ReplicationMetadataStore::MRF_REPLICATION_FILE, "config/replication/mrf.bin");
|
||||||
|
assert_eq!(
|
||||||
|
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE,
|
||||||
|
"config/replication/force-delete.bin"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,19 @@
|
|||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use super::replication_filemeta_boundary::{ReplicateDecision, ReplicationStatusType, ReplicationType};
|
use super::replication_error_boundary::Result;
|
||||||
use super::replication_object_config::{check_replicate_delete, get_must_replicate_options, must_replicate};
|
use super::replication_filemeta_boundary::{ReplicateDecision, ReplicatedTargetInfo, ReplicationStatusType, ReplicationType};
|
||||||
|
use super::replication_metadata_boundary::ReplicationInstanceContext;
|
||||||
|
use super::replication_object_config::{
|
||||||
|
DeleteReplicationConfigSnapshot, check_replicate_delete, check_replicate_delete_strict, check_replicate_delete_with_snapshot,
|
||||||
|
get_must_replicate_options, load_delete_replication_config_in, load_delete_request_config_in, must_replicate,
|
||||||
|
};
|
||||||
use super::replication_object_decision_boundary::MustReplicateOptions;
|
use super::replication_object_decision_boundary::MustReplicateOptions;
|
||||||
use super::replication_pool::{schedule_replication, schedule_replication_delete};
|
use super::replication_pool::{schedule_replication, schedule_replication_delete};
|
||||||
use super::replication_queue_boundary::DeletedObjectReplicationInfo;
|
use super::replication_queue_boundary::DeletedObjectReplicationInfo;
|
||||||
use super::replication_storage_boundary::{
|
use super::replication_storage_boundary::{
|
||||||
DeletedObject, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationStorage, deleted_object_for_replication,
|
DeletedObject, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationObjectStore, ReplicationStorage,
|
||||||
|
deleted_object_for_replication,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ReplicationObjectBridge;
|
pub struct ReplicationObjectBridge;
|
||||||
@@ -50,6 +56,56 @@ impl ReplicationObjectBridge {
|
|||||||
check_replicate_delete(bucket, object, source, opts, get_error).await
|
check_replicate_delete(bucket, object, source, opts, get_error).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn check_delete_strict(
|
||||||
|
bucket: &str,
|
||||||
|
object: &ObjectToDelete,
|
||||||
|
source: &ObjectInfo,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
get_error: Option<String>,
|
||||||
|
) -> Result<ReplicateDecision> {
|
||||||
|
check_replicate_delete_strict(bucket, object, source, opts, get_error).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_request_config(api: &ReplicationObjectStore, bucket: &str) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
load_delete_request_config_in(&api.ctx, bucket).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_request_config_in(
|
||||||
|
ctx: &ReplicationInstanceContext,
|
||||||
|
bucket: &str,
|
||||||
|
) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
load_delete_request_config_in(ctx, bucket).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_config_snapshot_in(
|
||||||
|
ctx: &ReplicationInstanceContext,
|
||||||
|
bucket: &str,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
load_delete_replication_config_in(ctx, bucket, opts).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_active_delete_rule(snapshot: &DeleteReplicationConfigSnapshot, object: &str) -> bool {
|
||||||
|
snapshot.has_active_rule(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn force_delete_target_set(
|
||||||
|
snapshot: &DeleteReplicationConfigSnapshot,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Option<(Vec<String>, time::OffsetDateTime)> {
|
||||||
|
snapshot.force_delete_target_set(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_delete_with_snapshot(
|
||||||
|
object: &ObjectToDelete,
|
||||||
|
source: &ObjectInfo,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
source_error: bool,
|
||||||
|
snapshot: &DeleteReplicationConfigSnapshot,
|
||||||
|
) -> ReplicateDecision {
|
||||||
|
check_replicate_delete_with_snapshot(object, source, opts, source_error, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn schedule_object<S: ReplicationStorage>(
|
pub async fn schedule_object<S: ReplicationStorage>(
|
||||||
object: ObjectInfo,
|
object: ObjectInfo,
|
||||||
storage: Arc<S>,
|
storage: Arc<S>,
|
||||||
@@ -63,6 +119,31 @@ impl ReplicationObjectBridge {
|
|||||||
schedule_replication_delete(delete_object).await;
|
schedule_replication_delete(delete_object).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn schedule_deletes(delete_objects: &[DeletedObjectReplicationInfo]) {
|
||||||
|
if let Some(pool) = super::runtime_boundary::replication_pool() {
|
||||||
|
let _ = pool.queue_replica_delete_batch(delete_objects).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stats) = super::runtime_boundary::replication_stats() {
|
||||||
|
for delete_object in delete_objects {
|
||||||
|
if let Some(rs) = &delete_object.delete_object.replication_state {
|
||||||
|
for k in rs.targets.keys() {
|
||||||
|
let ri = ReplicatedTargetInfo {
|
||||||
|
arn: k.clone(),
|
||||||
|
size: 0,
|
||||||
|
duration: std::time::Duration::default(),
|
||||||
|
op_type: ReplicationType::Delete,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
stats
|
||||||
|
.update(&delete_object.bucket, &ri, ReplicationStatusType::Pending, ReplicationStatusType::Empty)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn schedule_storage_delete(delete_object: DeletedObject, bucket: String, event_type: String) {
|
pub async fn schedule_storage_delete(delete_object: DeletedObject, bucket: String, event_type: String) {
|
||||||
Self::schedule_delete(DeletedObjectReplicationInfo {
|
Self::schedule_delete(DeletedObjectReplicationInfo {
|
||||||
delete_object: deleted_object_for_replication(delete_object),
|
delete_object: deleted_object_for_replication(delete_object),
|
||||||
@@ -72,6 +153,19 @@ impl ReplicationObjectBridge {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn schedule_storage_deletes(delete_objects: Vec<DeletedObject>, bucket: String, event_type: String) {
|
||||||
|
let delete_objects = delete_objects
|
||||||
|
.into_iter()
|
||||||
|
.map(|delete_object| DeletedObjectReplicationInfo {
|
||||||
|
delete_object: deleted_object_for_replication(delete_object),
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
event_type: event_type.clone(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Self::schedule_deletes(&delete_objects).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,23 +12,27 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, fmt, sync::Arc};
|
||||||
|
|
||||||
|
use crate::bucket::metadata::BucketMetadata;
|
||||||
use rustfs_utils::http::AMZ_BUCKET_REPLICATION_STATUS;
|
use rustfs_utils::http::AMZ_BUCKET_REPLICATION_STATUS;
|
||||||
use s3s::dto::ReplicationConfiguration;
|
use s3s::dto::{BucketVersioningStatus, ReplicationConfiguration, ReplicationRuleStatus, VersioningConfiguration};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt as _};
|
use super::replication_config_boundary::{
|
||||||
|
ObjectOpts, ReplicationConfigurationExt as _, ReplicationRuleExt as _, invalid_replication_config_status_field,
|
||||||
|
};
|
||||||
use super::replication_error_boundary::Result;
|
use super::replication_error_boundary::Result;
|
||||||
use super::replication_filemeta_boundary::{
|
use super::replication_filemeta_boundary::{
|
||||||
ReplicateDecision, ReplicateTargetDecision, ReplicationStatusType, ReplicationType, ResyncDecision,
|
ReplicateDecision, ReplicateTargetDecision, ReplicationStatusType, ReplicationType, ResyncDecision,
|
||||||
};
|
};
|
||||||
use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION_RESYNC};
|
use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION_RESYNC};
|
||||||
use super::replication_metadata_boundary::ReplicationMetadataStore;
|
use super::replication_metadata_boundary::{ReplicationInstanceContext, ReplicationMetadataStore};
|
||||||
use super::replication_object_decision_boundary::{
|
use super::replication_object_decision_boundary::{
|
||||||
MustReplicateOptions, ReplicationDeleteSource, ReplicationResyncTargetObject, delete_replication_missing_source_decision,
|
MustReplicateOptions, ReplicationDeleteSource, ReplicationResyncTargetObject, delete_replication_missing_source_decision,
|
||||||
delete_replication_object_opts, resync_target_for_object,
|
delete_replication_object_opts, heal_uses_delete_replication_path, resync_target_for_object,
|
||||||
};
|
};
|
||||||
use super::replication_storage_boundary::{ObjectInfo, ObjectOptions, ObjectToDelete, object_to_delete_for_replication};
|
use super::replication_storage_boundary::{ObjectInfo, ObjectOptions, ObjectToDelete, object_to_delete_for_replication};
|
||||||
use super::replication_target_boundary::{BucketTargets, ReplicationTargetStore};
|
use super::replication_target_boundary::{BucketTargets, ReplicationTargetStore};
|
||||||
@@ -36,7 +40,206 @@ use super::replication_versioning_boundary::ReplicationVersioningStore;
|
|||||||
use super::runtime_boundary as runtime_sources;
|
use super::runtime_boundary as runtime_sources;
|
||||||
|
|
||||||
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
|
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
|
||||||
ReplicationMetadataStore::optional_replication_config(bucket).await
|
let config = ReplicationMetadataStore::optional_replication_config(bucket).await?;
|
||||||
|
validate_delete_replication_config(&VersioningConfiguration::default(), config.as_ref())?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DeleteReplicationConfigSnapshot {
|
||||||
|
metadata: Option<Arc<BucketMetadata>>,
|
||||||
|
versioning: VersioningConfiguration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for DeleteReplicationConfigSnapshot {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("DeleteReplicationConfigSnapshot")
|
||||||
|
.field("has_replication_config", &self.replication_config().is_some())
|
||||||
|
.field("versioning_status", &self.versioning.status)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteReplicationConfigSnapshot {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_configs_for_test(
|
||||||
|
versioning: VersioningConfiguration,
|
||||||
|
replication: Option<ReplicationConfiguration>,
|
||||||
|
) -> Self {
|
||||||
|
let metadata = replication.map(|config| {
|
||||||
|
let mut metadata = BucketMetadata::new("test-bucket");
|
||||||
|
metadata.replication_config = Some(config);
|
||||||
|
Arc::new(metadata)
|
||||||
|
});
|
||||||
|
Self { metadata, versioning }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn versioning_config(&self) -> &VersioningConfiguration {
|
||||||
|
&self.versioning
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replication_config(&self) -> Option<&ReplicationConfiguration> {
|
||||||
|
self.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| metadata.replication_config.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn force_delete_target_set(&self, prefix: &str) -> Option<(Vec<String>, OffsetDateTime)> {
|
||||||
|
self.metadata.as_ref().and_then(|metadata| {
|
||||||
|
metadata
|
||||||
|
.replication_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| (config.filter_force_delete_target_arns(prefix), metadata.replication_config_updated_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_active_rule(&self, object: &str) -> bool {
|
||||||
|
self.replication_config()
|
||||||
|
.is_some_and(|config| config.has_active_rules(object, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_delete_marker_rules_require_tags(&self, object: &str) -> bool {
|
||||||
|
self.replication_config().is_some_and(|config| {
|
||||||
|
config.rules.iter().any(|rule| {
|
||||||
|
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !object.starts_with(rule.prefix()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rule.filter.as_ref().is_some_and(|filter| {
|
||||||
|
filter.tag.is_some()
|
||||||
|
|| filter
|
||||||
|
.and
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|and| and.tags.as_ref())
|
||||||
|
.is_some_and(|tags| !tags.is_empty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_delete_replication_config(
|
||||||
|
versioning: &VersioningConfiguration,
|
||||||
|
config: Option<&ReplicationConfiguration>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if versioning
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|status| !matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED))
|
||||||
|
{
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"bucket versioning configuration has an invalid status",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(config) = config {
|
||||||
|
if let Some(field) = invalid_replication_config_status_field(config) {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(format!(
|
||||||
|
"replication field {field} has an invalid status"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = config.role.trim();
|
||||||
|
let mut role_destination = None;
|
||||||
|
for rule in &config.rules {
|
||||||
|
if rule.status.as_str() == ReplicationRuleStatus::ENABLED {
|
||||||
|
let destination = rule.destination.bucket.trim();
|
||||||
|
if role.is_empty() && destination.is_empty() {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"enabled replication rule has no destination ARN",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !role.is_empty() && !destination.is_empty() {
|
||||||
|
match role_destination {
|
||||||
|
Some(existing) if existing != destination => {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"replication role cannot address multiple active destinations",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => role_destination = Some(destination),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replication_config_from_metadata(metadata: &BucketMetadata) -> Result<Option<&ReplicationConfiguration>> {
|
||||||
|
if !metadata.replication_config_xml.is_empty() && metadata.replication_config.is_none() {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"persisted bucket replication configuration is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(metadata.replication_config.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_request_snapshot_from_metadata(metadata: Arc<BucketMetadata>) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
if !metadata.versioning_config_xml.is_empty() && metadata.versioning_config.is_none() {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"persisted bucket versioning configuration is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let versioning = metadata.versioning_config.clone().unwrap_or_default();
|
||||||
|
let has_config = {
|
||||||
|
let config = replication_config_from_metadata(&metadata)?;
|
||||||
|
if versioning.status.is_none() && config.is_some() {
|
||||||
|
return Err(super::replication_error_boundary::Error::other(
|
||||||
|
"bucket replication configuration requires versioning",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_delete_replication_config(&versioning, config)?;
|
||||||
|
config.is_some()
|
||||||
|
};
|
||||||
|
Ok(DeleteReplicationConfigSnapshot {
|
||||||
|
metadata: has_config.then_some(metadata),
|
||||||
|
versioning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_snapshot_from_metadata(metadata: Arc<BucketMetadata>) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
let has_config = {
|
||||||
|
let config = replication_config_from_metadata(&metadata)?;
|
||||||
|
validate_delete_replication_config(&VersioningConfiguration::default(), config)?;
|
||||||
|
config.is_some()
|
||||||
|
};
|
||||||
|
Ok(DeleteReplicationConfigSnapshot {
|
||||||
|
metadata: has_config.then_some(metadata),
|
||||||
|
versioning: VersioningConfiguration::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_delete_request_config_in(
|
||||||
|
ctx: &ReplicationInstanceContext,
|
||||||
|
bucket: &str,
|
||||||
|
) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
delete_request_snapshot_from_metadata(ReplicationMetadataStore::delete_metadata_in(ctx, bucket).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_delete_replication_config(
|
||||||
|
bucket: &str,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
if opts.replication_request || (!opts.versioned && !opts.version_suspended) {
|
||||||
|
return Ok(DeleteReplicationConfigSnapshot::default());
|
||||||
|
}
|
||||||
|
delete_snapshot_from_metadata(ReplicationMetadataStore::delete_metadata(bucket).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_delete_replication_config_in(
|
||||||
|
ctx: &ReplicationInstanceContext,
|
||||||
|
bucket: &str,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
) -> Result<DeleteReplicationConfigSnapshot> {
|
||||||
|
if opts.replication_request || (!opts.versioned && !opts.version_suspended) {
|
||||||
|
return Ok(DeleteReplicationConfigSnapshot::default());
|
||||||
|
}
|
||||||
|
delete_snapshot_from_metadata(ReplicationMetadataStore::delete_metadata_in(ctx, bucket).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -54,10 +257,31 @@ impl ReplicationConfig {
|
|||||||
self.config.is_none()
|
self.config.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate(&self) -> Result<()> {
|
||||||
|
validate_delete_replication_config(&VersioningConfiguration::default(), self.config.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn replicate(&self, obj: &ObjectOpts) -> bool {
|
pub fn replicate(&self, obj: &ObjectOpts) -> bool {
|
||||||
self.config.as_ref().is_some_and(|config| config.replicate(obj))
|
self.config.as_ref().is_some_and(|config| config.replicate(obj))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_delete_for_heal(
|
||||||
|
&self,
|
||||||
|
object: &ObjectToDelete,
|
||||||
|
source: &ObjectInfo,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
) -> ReplicateDecision {
|
||||||
|
check_replicate_delete_with_config(
|
||||||
|
object,
|
||||||
|
source,
|
||||||
|
opts,
|
||||||
|
false,
|
||||||
|
self.config.as_ref(),
|
||||||
|
source.delete_marker && source.version_purge_status.is_empty(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn resync(
|
pub async fn resync(
|
||||||
&self,
|
&self,
|
||||||
oi: ObjectInfo,
|
oi: ObjectInfo,
|
||||||
@@ -70,30 +294,34 @@ impl ReplicationConfig {
|
|||||||
|
|
||||||
let mut dsc = dsc;
|
let mut dsc = dsc;
|
||||||
|
|
||||||
if oi.delete_marker {
|
if heal_uses_delete_replication_path(oi.delete_marker, &oi.version_purge_status) {
|
||||||
|
if !dsc.targets_map.is_empty() {
|
||||||
|
return self.resync_internal(oi, dsc, status);
|
||||||
|
}
|
||||||
let opts = ObjectOpts {
|
let opts = ObjectOpts {
|
||||||
name: oi.name.clone(),
|
name: oi.name.clone(),
|
||||||
version_id: oi.version_id,
|
version_id: if oi.version_purge_status.is_empty() {
|
||||||
delete_marker: true,
|
None
|
||||||
|
} else {
|
||||||
|
oi.version_id
|
||||||
|
},
|
||||||
|
delete_marker: oi.delete_marker,
|
||||||
op_type: ReplicationType::Delete,
|
op_type: ReplicationType::Delete,
|
||||||
existing_object: true,
|
existing_object: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let arns = self
|
let targets = self
|
||||||
.config
|
.config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|config| config.filter_target_arns(&opts))
|
.map(|config| config.filter_target_replication_decisions(&opts))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if arns.is_empty() {
|
if targets.is_empty() {
|
||||||
return ResyncDecision::default();
|
return ResyncDecision::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
for arn in arns {
|
for (arn, replicate) in targets {
|
||||||
let mut opts = opts.clone();
|
dsc.set(ReplicateTargetDecision::new(arn, replicate, false));
|
||||||
opts.target_arn = arn;
|
|
||||||
|
|
||||||
dsc.set(ReplicateTargetDecision::new(opts.target_arn.clone(), self.replicate(&opts), false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.resync_internal(oi, dsc, status);
|
return self.resync_internal(oi, dsc, status);
|
||||||
@@ -169,9 +397,10 @@ pub(crate) async fn check_replicate_delete(
|
|||||||
del_opts: &ObjectOptions,
|
del_opts: &ObjectOptions,
|
||||||
gerr: Option<String>,
|
gerr: Option<String>,
|
||||||
) -> ReplicateDecision {
|
) -> ReplicateDecision {
|
||||||
let rcfg = match get_replication_config(bucket).await {
|
match load_delete_replication_config(bucket, del_opts).await {
|
||||||
Ok(Some(config)) => config,
|
Ok(snapshot) => {
|
||||||
Ok(None) => return ReplicateDecision::default(),
|
check_replicate_delete_with_config(dobj, oi, del_opts, gerr.is_some(), snapshot.replication_config(), false, false)
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(
|
error!(
|
||||||
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
||||||
@@ -182,61 +411,110 @@ pub(crate) async fn check_replicate_delete(
|
|||||||
error = %err,
|
error = %err,
|
||||||
"Failed to look up replication config for delete replication"
|
"Failed to look up replication config for delete replication"
|
||||||
);
|
);
|
||||||
return ReplicateDecision::default();
|
ReplicateDecision::default()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn check_replicate_delete_strict(
|
||||||
|
bucket: &str,
|
||||||
|
dobj: &ObjectToDelete,
|
||||||
|
oi: &ObjectInfo,
|
||||||
|
del_opts: &ObjectOptions,
|
||||||
|
gerr: Option<String>,
|
||||||
|
) -> Result<ReplicateDecision> {
|
||||||
|
let Some(config) = get_replication_config(bucket).await? else {
|
||||||
|
return Ok(ReplicateDecision::default());
|
||||||
|
};
|
||||||
|
let mut decision = check_replicate_delete_with_config(dobj, oi, del_opts, gerr.is_some(), Some(&config), false, false);
|
||||||
|
if gerr.is_some() {
|
||||||
|
return Ok(decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in decision.targets_map.values_mut() {
|
||||||
|
if let Some(client) = ReplicationTargetStore::remote_target_client(bucket, &target.arn).await {
|
||||||
|
target.synchronous = client.replicate_sync;
|
||||||
|
} else {
|
||||||
|
target.replicate = false;
|
||||||
|
target.synchronous = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_replicate_delete_with_snapshot(
|
||||||
|
dobj: &ObjectToDelete,
|
||||||
|
oi: &ObjectInfo,
|
||||||
|
del_opts: &ObjectOptions,
|
||||||
|
source_error: bool,
|
||||||
|
snapshot: &DeleteReplicationConfigSnapshot,
|
||||||
|
) -> ReplicateDecision {
|
||||||
|
check_replicate_delete_with_config(dobj, oi, del_opts, source_error, snapshot.replication_config(), false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_replicate_delete_with_config(
|
||||||
|
dobj: &ObjectToDelete,
|
||||||
|
oi: &ObjectInfo,
|
||||||
|
del_opts: &ObjectOptions,
|
||||||
|
source_error: bool,
|
||||||
|
config: Option<&ReplicationConfiguration>,
|
||||||
|
existing_delete_marker: bool,
|
||||||
|
trust_persisted_replica_status: bool,
|
||||||
|
) -> ReplicateDecision {
|
||||||
if del_opts.replication_request {
|
if del_opts.replication_request {
|
||||||
return ReplicateDecision::default();
|
return ReplicateDecision::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !del_opts.versioned {
|
if !del_opts.versioned && !del_opts.version_suspended {
|
||||||
return ReplicateDecision::default();
|
return ReplicateDecision::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(rcfg) = config else {
|
||||||
|
return ReplicateDecision::default();
|
||||||
|
};
|
||||||
|
|
||||||
let replication_delete = object_to_delete_for_replication(dobj);
|
let replication_delete = object_to_delete_for_replication(dobj);
|
||||||
let opts = delete_replication_object_opts(
|
let missing_source_marker = source_error && dobj.version_id.is_none();
|
||||||
|
let mut opts = delete_replication_object_opts(
|
||||||
&replication_delete,
|
&replication_delete,
|
||||||
&ReplicationDeleteSource {
|
&ReplicationDeleteSource {
|
||||||
user_defined: oi.user_defined.as_ref(),
|
user_defined: oi.user_defined.as_ref(),
|
||||||
user_tags: oi.user_tags.as_str(),
|
user_tags: oi.user_tags.as_str(),
|
||||||
delete_marker: oi.delete_marker,
|
delete_marker: oi.delete_marker || missing_source_marker,
|
||||||
replication_status: oi.replication_status.clone(),
|
replication_status: if trust_persisted_replica_status {
|
||||||
|
oi.replication_status.clone()
|
||||||
|
} else {
|
||||||
|
ReplicationStatusType::Empty
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if existing_delete_marker {
|
||||||
|
opts.version_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
let tgt_arns = rcfg.filter_target_arns(&opts);
|
let target_decisions = rcfg.filter_target_replication_decisions(&opts);
|
||||||
let mut dsc = ReplicateDecision::new();
|
let mut dsc = ReplicateDecision::new();
|
||||||
|
|
||||||
if tgt_arns.is_empty() {
|
if target_decisions.is_empty() {
|
||||||
return dsc;
|
return dsc;
|
||||||
}
|
}
|
||||||
|
|
||||||
for tgt_arn in tgt_arns {
|
for (tgt_arn, replicate) in target_decisions {
|
||||||
let mut opts = opts.clone();
|
let effective_replicate = if source_error {
|
||||||
opts.target_arn = tgt_arn.clone();
|
delete_replication_missing_source_decision(
|
||||||
let replicate = rcfg.replicate(&opts);
|
oi.delete_marker || missing_source_marker,
|
||||||
let sync = false;
|
|
||||||
|
|
||||||
if gerr.is_some() {
|
|
||||||
if let Some(replicate) = delete_replication_missing_source_decision(
|
|
||||||
oi.delete_marker,
|
|
||||||
oi.target_replication_status(&tgt_arn),
|
oi.target_replication_status(&tgt_arn),
|
||||||
replicate,
|
replicate,
|
||||||
&oi.version_purge_status,
|
&oi.version_purge_status,
|
||||||
) {
|
)
|
||||||
dsc.set(ReplicateTargetDecision::new(tgt_arn, replicate, sync));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tgt = ReplicationTargetStore::remote_target_client(bucket, &tgt_arn).await;
|
|
||||||
let tgt_dsc = if let Some(tgt) = tgt {
|
|
||||||
ReplicateTargetDecision::new(tgt_arn, replicate, tgt.replicate_sync)
|
|
||||||
} else {
|
} else {
|
||||||
ReplicateTargetDecision::new(tgt_arn, false, false)
|
Some(replicate)
|
||||||
};
|
};
|
||||||
dsc.set(tgt_dsc);
|
let Some(effective_replicate) = effective_replicate else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
dsc.set(ReplicateTargetDecision::new(tgt_arn, effective_replicate, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
dsc
|
dsc
|
||||||
@@ -299,8 +577,13 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use s3s::dto::{Destination, ReplicationRule, ReplicationRuleStatus};
|
use s3s::dto::{
|
||||||
|
DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
|
||||||
|
ReplicaModifications, ReplicationRule, ReplicationRuleFilter, ReplicationRuleStatus, SourceSelectionCriteria, Tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::super::replication_filemeta_boundary::VersionPurgeStatusType;
|
||||||
|
use super::super::replication_target_boundary::BucketTarget;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn replication_rule() -> ReplicationRule {
|
fn replication_rule() -> ReplicationRule {
|
||||||
@@ -360,4 +643,379 @@ mod tests {
|
|||||||
assert!(options.is_replication_request());
|
assert!(options.is_replication_request());
|
||||||
assert_eq!(options.user_tags(), "env=prod");
|
assert_eq!(options.user_tags(), "env=prod");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_snapshot_rejects_enabled_rules_without_a_destination() {
|
||||||
|
let mut rule = replication_rule();
|
||||||
|
rule.destination.bucket.clear();
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![rule],
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config))
|
||||||
|
.expect_err("an enabled rule without a destination must fail closed");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("destination ARN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_snapshot_rejects_role_with_multiple_destinations() {
|
||||||
|
let first = replication_rule();
|
||||||
|
let mut second = replication_rule();
|
||||||
|
second.destination.bucket = "arn:aws:s3:::other-target".to_string();
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: "arn:aws:s3:::role-target".to_string(),
|
||||||
|
rules: vec![first, second],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_snapshot_rejects_unknown_status_values() {
|
||||||
|
let invalid_versioning = VersioningConfiguration {
|
||||||
|
status: Some("Enabld".to_string().into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(validate_delete_replication_config(&invalid_versioning, None).is_err());
|
||||||
|
|
||||||
|
let mut invalid_rule = replication_rule();
|
||||||
|
invalid_rule.status = "Enabld".to_string().into();
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![invalid_rule],
|
||||||
|
};
|
||||||
|
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||||
|
|
||||||
|
let mut invalid_delete = replication_rule();
|
||||||
|
invalid_delete.delete_replication = Some(DeleteReplication {
|
||||||
|
status: "Enabld".to_string().into(),
|
||||||
|
});
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![invalid_delete],
|
||||||
|
};
|
||||||
|
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||||
|
|
||||||
|
let mut invalid_delete_marker = replication_rule();
|
||||||
|
invalid_delete_marker.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some("Enabld".to_string().into()),
|
||||||
|
});
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![invalid_delete_marker],
|
||||||
|
};
|
||||||
|
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||||
|
|
||||||
|
let mut invalid_replica_modifications = replication_rule();
|
||||||
|
invalid_replica_modifications.source_selection_criteria = Some(SourceSelectionCriteria {
|
||||||
|
replica_modifications: Some(ReplicaModifications {
|
||||||
|
status: "Enabld".to_string().into(),
|
||||||
|
}),
|
||||||
|
sse_kms_encrypted_objects: None,
|
||||||
|
});
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![invalid_replica_modifications],
|
||||||
|
};
|
||||||
|
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_snapshot_borrows_cached_replication_config() {
|
||||||
|
let mut metadata = BucketMetadata::new("bucket");
|
||||||
|
metadata.versioning_config_xml = b"configured".to_vec();
|
||||||
|
metadata.versioning_config = Some(VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
metadata.replication_config_xml = b"configured".to_vec();
|
||||||
|
metadata.replication_config = Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![replication_rule()],
|
||||||
|
});
|
||||||
|
let metadata = Arc::new(metadata);
|
||||||
|
let cached_config = metadata.replication_config.as_ref().expect("cached config") as *const _;
|
||||||
|
|
||||||
|
let snapshot = delete_request_snapshot_from_metadata(Arc::clone(&metadata)).expect("valid snapshot");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.replication_config().expect("snapshot config") as *const _, cached_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_snapshot_debug_redacts_bucket_target_credentials() {
|
||||||
|
let secret = "snapshot-secret-must-not-be-formatted";
|
||||||
|
let mut metadata = BucketMetadata::new("bucket");
|
||||||
|
metadata.bucket_targets_config_json = format!(r#"{{"secretKey":"{secret}"}}"#).into_bytes();
|
||||||
|
let opts = ObjectOptions {
|
||||||
|
delete_replication_config_snapshot: Some(Arc::new(DeleteReplicationConfigSnapshot {
|
||||||
|
metadata: Some(Arc::new(metadata)),
|
||||||
|
versioning: VersioningConfiguration::default(),
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug = format!("{opts:?}");
|
||||||
|
assert!(!debug.contains(secret), "delete tracing must not expose replication target credentials");
|
||||||
|
assert!(debug.contains("has_replication_config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_snapshot_rejects_replication_without_versioning_status() {
|
||||||
|
let mut malformed = BucketMetadata::new("bucket");
|
||||||
|
malformed.replication_config_xml = b"<ReplicationConfiguration>".to_vec();
|
||||||
|
assert!(
|
||||||
|
delete_request_snapshot_from_metadata(Arc::new(malformed)).is_err(),
|
||||||
|
"malformed replication metadata must fail closed even when versioning has no status"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut inconsistent = BucketMetadata::new("bucket");
|
||||||
|
inconsistent.replication_config = Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![replication_rule()],
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
delete_request_snapshot_from_metadata(Arc::new(inconsistent)).is_err(),
|
||||||
|
"replication metadata without an enabled or suspended versioning state must fail closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_source_marker_creation_is_still_admitted() {
|
||||||
|
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||||
|
let mut rule = replication_rule();
|
||||||
|
rule.destination.bucket = arn.to_string();
|
||||||
|
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||||
|
});
|
||||||
|
let mut metadata = BucketMetadata::new("bucket");
|
||||||
|
metadata.replication_config = Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![rule],
|
||||||
|
});
|
||||||
|
let snapshot = DeleteReplicationConfigSnapshot {
|
||||||
|
metadata: Some(Arc::new(metadata)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = check_replicate_delete_with_snapshot(
|
||||||
|
&ObjectToDelete {
|
||||||
|
object_name: "object".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&ObjectInfo::default(),
|
||||||
|
&ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
&snapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(decision.replicate_any());
|
||||||
|
assert!(decision.targets_map.get(arn).is_some_and(|target| target.replicate));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_marker_source_read_is_required_only_for_tag_filtered_rules() {
|
||||||
|
let mut prefix_rule = replication_rule();
|
||||||
|
prefix_rule.prefix = Some("logs/".to_string());
|
||||||
|
prefix_rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let prefix_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||||
|
VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![prefix_rule],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tag_rule = replication_rule();
|
||||||
|
tag_rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||||
|
});
|
||||||
|
tag_rule.filter = Some(ReplicationRuleFilter {
|
||||||
|
tag: Some(Tag {
|
||||||
|
key: Some("class".to_string()),
|
||||||
|
value: Some("audit".to_string()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let tag_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||||
|
VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![tag_rule],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!prefix_snapshot.active_delete_marker_rules_require_tags("logs/2026/app.log"));
|
||||||
|
assert!(tag_snapshot.active_delete_marker_rules_require_tags("logs/2026/app.log"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heal_uses_delete_switch_for_pending_purges_and_marker_switch_for_stored_markers() {
|
||||||
|
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||||
|
let mut rule = replication_rule();
|
||||||
|
rule.destination.bucket = arn.to_string();
|
||||||
|
rule.delete_replication = Some(DeleteReplication {
|
||||||
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||||
|
});
|
||||||
|
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
|
||||||
|
});
|
||||||
|
let config = ReplicationConfig::new(
|
||||||
|
Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![rule],
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let object = ObjectToDelete {
|
||||||
|
object_name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let purge = ObjectInfo {
|
||||||
|
version_id: object.version_id,
|
||||||
|
version_purge_status: VersionPurgeStatusType::Pending,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(config.check_delete_for_heal(&object, &purge, &opts).replicate_any());
|
||||||
|
|
||||||
|
let marker = ObjectInfo {
|
||||||
|
delete_marker: true,
|
||||||
|
version_id: object.version_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!config.check_delete_for_heal(&object, &marker, &opts).replicate_any());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_delete_does_not_trust_persisted_replica_status() {
|
||||||
|
let mut rule = replication_rule();
|
||||||
|
rule.delete_replication = Some(DeleteReplication {
|
||||||
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||||
|
});
|
||||||
|
rule.source_selection_criteria = Some(SourceSelectionCriteria {
|
||||||
|
replica_modifications: Some(ReplicaModifications {
|
||||||
|
status: s3s::dto::ReplicaModificationsStatus::from_static(s3s::dto::ReplicaModificationsStatus::DISABLED),
|
||||||
|
}),
|
||||||
|
sse_kms_encrypted_objects: None,
|
||||||
|
});
|
||||||
|
let replication = ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![rule],
|
||||||
|
};
|
||||||
|
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||||
|
VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some(replication.clone()),
|
||||||
|
);
|
||||||
|
let object = ObjectToDelete {
|
||||||
|
object_name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let source = ObjectInfo {
|
||||||
|
replication_status: ReplicationStatusType::Replica,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
check_replicate_delete_with_snapshot(&object, &source, &opts, false, &snapshot).replicate_any(),
|
||||||
|
"an ordinary authenticated delete must not inherit replica identity from object metadata"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ReplicationConfig::new(Some(replication), None)
|
||||||
|
.check_delete_for_heal(&object, &source, &opts)
|
||||||
|
.replicate_any(),
|
||||||
|
"heal must still honor the persisted replica identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resync_keeps_marker_version_purges_separate_from_marker_creation() {
|
||||||
|
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||||
|
let mut rule = replication_rule();
|
||||||
|
rule.destination.bucket = arn.to_string();
|
||||||
|
rule.delete_replication = Some(DeleteReplication {
|
||||||
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||||
|
});
|
||||||
|
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||||
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
|
||||||
|
});
|
||||||
|
let config = ReplicationConfig::new(
|
||||||
|
Some(ReplicationConfiguration {
|
||||||
|
role: String::new(),
|
||||||
|
rules: vec![rule],
|
||||||
|
}),
|
||||||
|
Some(BucketTargets {
|
||||||
|
targets: vec![BucketTarget {
|
||||||
|
arn: arn.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let marker = ObjectInfo {
|
||||||
|
name: "object".to_string(),
|
||||||
|
delete_marker: true,
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let purge = config
|
||||||
|
.resync(
|
||||||
|
ObjectInfo {
|
||||||
|
version_purge_status: VersionPurgeStatusType::Pending,
|
||||||
|
..marker.clone()
|
||||||
|
},
|
||||||
|
ReplicateDecision::default(),
|
||||||
|
&HashMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(purge.targets.get(arn).is_some_and(|target| target.replicate));
|
||||||
|
|
||||||
|
let mut object_purge_decision = ReplicateDecision::default();
|
||||||
|
object_purge_decision.set(ReplicateTargetDecision::new(arn.to_string(), true, false));
|
||||||
|
let object_purge = config
|
||||||
|
.resync(
|
||||||
|
ObjectInfo {
|
||||||
|
name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
version_purge_status: VersionPurgeStatusType::Pending,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
object_purge_decision,
|
||||||
|
&HashMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
object_purge.targets.get(arn).is_some_and(|target| target.replicate),
|
||||||
|
"non-marker PENDING purges must stay on the delete resync path"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored_marker = config.resync(marker, ReplicateDecision::default(), &HashMap::new()).await;
|
||||||
|
assert!(!stored_marker.targets.contains_key(arn));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,8 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
pub use rustfs_replication::{
|
pub use rustfs_replication::{
|
||||||
DeletedObjectReplicationInfo, ReplicationHealQueueResult, ReplicationOperation, ReplicationPriority,
|
DeletedObjectReplicationInfo, ReplicationBatchAdmission, ReplicationHealQueueResult, ReplicationOperation,
|
||||||
ReplicationQueueAdmission,
|
ReplicationPriority, ReplicationQueueAdmission,
|
||||||
};
|
};
|
||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
LARGE_WORKER_COUNT, ReplicationBackpressureRecommendation, ReplicationBackpressureState, ReplicationHealQueueAction,
|
LARGE_WORKER_COUNT, ReplicationBackpressureRecommendation, ReplicationBackpressureState, ReplicationHealQueueAction,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,9 @@ use super::replication_stats_boundary::{
|
|||||||
QueueCache, ReplicationMetricScope, SRMetricsSummary, XferStats,
|
QueueCache, ReplicationMetricScope, SRMetricsSummary, XferStats,
|
||||||
};
|
};
|
||||||
use super::runtime_boundary as runtime_sources;
|
use super::runtime_boundary as runtime_sources;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, hash_map::Entry};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::{Arc, LazyLock, Mutex as StdMutex, Weak};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
@@ -143,7 +143,7 @@ pub struct ReplicationStats {
|
|||||||
// Active worker statistics
|
// Active worker statistics
|
||||||
pub workers: Arc<Mutex<ActiveWorkerStat>>,
|
pub workers: Arc<Mutex<ActiveWorkerStat>>,
|
||||||
// Queue statistics cache
|
// Queue statistics cache
|
||||||
pub q_cache: Arc<Mutex<QueueCache>>,
|
pub q_cache: Arc<StdMutex<QueueCache>>,
|
||||||
// Proxy statistics cache
|
// Proxy statistics cache
|
||||||
pub p_cache: Arc<Mutex<ProxyStatsCache>>,
|
pub p_cache: Arc<Mutex<ProxyStatsCache>>,
|
||||||
// MRF backlog statistics (simplified)
|
// MRF backlog statistics (simplified)
|
||||||
@@ -153,12 +153,89 @@ pub struct ReplicationStats {
|
|||||||
pub most_recent_stats: Arc<Mutex<HashMap<String, BucketStats>>>,
|
pub most_recent_stats: Arc<Mutex<HashMap<String, BucketStats>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeReplicationTargetBacklog {
|
||||||
|
pub bucket: String,
|
||||||
|
pub target_arn: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetQueueKey = (String, String);
|
||||||
|
type TargetQueueCache = HashMap<TargetQueueKey, InQueueMetric>;
|
||||||
|
|
||||||
|
struct TargetQueueCacheSlot {
|
||||||
|
owner: Weak<StdMutex<QueueCache>>,
|
||||||
|
metrics: TargetQueueCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetQueueCacheSlot {
|
||||||
|
fn new(owner: &Arc<StdMutex<QueueCache>>) -> Self {
|
||||||
|
Self {
|
||||||
|
owner: Arc::downgrade(owner),
|
||||||
|
metrics: TargetQueueCache::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn belongs_to(&self, owner: &Arc<StdMutex<QueueCache>>) -> bool {
|
||||||
|
self.owner.upgrade().is_some_and(|current| Arc::ptr_eq(¤t, owner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep runtime target counters outside ReplicationStats to preserve its public struct shape.
|
||||||
|
static TARGET_QUEUE_CACHES: LazyLock<StdMutex<Vec<TargetQueueCacheSlot>>> = LazyLock::new(|| StdMutex::new(Vec::new()));
|
||||||
|
|
||||||
|
fn i64_to_u64_floor_zero(value: i64) -> u64 {
|
||||||
|
u64::try_from(value.max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_target_arns(target_arns: &[String]) -> Vec<&str> {
|
||||||
|
let mut target_arns = target_arns
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|target_arn| !target_arn.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
target_arns.sort_unstable();
|
||||||
|
target_arns.dedup();
|
||||||
|
target_arns
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_queue_cache_snapshot(cache: &TargetQueueCache) -> Vec<RuntimeReplicationTargetBacklog> {
|
||||||
|
cache
|
||||||
|
.iter()
|
||||||
|
.filter_map(|((bucket, target_arn), metric)| {
|
||||||
|
let count = i64_to_u64_floor_zero(metric.curr.get_current_count());
|
||||||
|
let bytes = i64_to_u64_floor_zero(metric.curr.get_current_bytes());
|
||||||
|
(count > 0 || bytes > 0).then(|| RuntimeReplicationTargetBacklog {
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
target_arn: target_arn.clone(),
|
||||||
|
count,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_stale_target_queue_caches(caches: &mut Vec<TargetQueueCacheSlot>) {
|
||||||
|
caches.retain(|slot| slot.owner.strong_count() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_target_queue_caches<T>(f: impl FnOnce(&mut Vec<TargetQueueCacheSlot>) -> T) -> T {
|
||||||
|
match TARGET_QUEUE_CACHES.lock() {
|
||||||
|
Ok(mut caches) => f(&mut caches),
|
||||||
|
Err(poisoned) => {
|
||||||
|
let mut caches = poisoned.into_inner();
|
||||||
|
f(&mut caches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ReplicationStats {
|
impl ReplicationStats {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sr_stats: Arc::new(SRStats::new()),
|
sr_stats: Arc::new(SRStats::new()),
|
||||||
workers: Arc::new(Mutex::new(ActiveWorkerStat::new())),
|
workers: Arc::new(Mutex::new(ActiveWorkerStat::new())),
|
||||||
q_cache: Arc::new(Mutex::new(QueueCache::new())),
|
q_cache: Arc::new(StdMutex::new(QueueCache::new())),
|
||||||
p_cache: Arc::new(Mutex::new(ProxyStatsCache::new())),
|
p_cache: Arc::new(Mutex::new(ProxyStatsCache::new())),
|
||||||
mrf_stats: HashMap::new(),
|
mrf_stats: HashMap::new(),
|
||||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -198,8 +275,9 @@ impl ReplicationStats {
|
|||||||
let mut interval = interval(Duration::from_secs(2));
|
let mut interval = interval(Duration::from_secs(2));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let mut cache = q_cache_clone.lock().await;
|
if let Ok(mut cache) = q_cache_clone.lock() {
|
||||||
cache.update();
|
cache.update();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -391,12 +469,13 @@ impl ReplicationStats {
|
|||||||
drop(cache);
|
drop(cache);
|
||||||
|
|
||||||
{
|
{
|
||||||
let q_cache = self.q_cache.lock().await;
|
if let Ok(q_cache) = self.q_cache.lock() {
|
||||||
for (bucket, queue_stats) in &q_cache.bucket_stats {
|
for (bucket, queue_stats) in &q_cache.bucket_stats {
|
||||||
let bucket_stats = result.entry(bucket.clone()).or_insert_with(BucketReplicationStats::new);
|
let bucket_stats = result.entry(bucket.clone()).or_insert_with(BucketReplicationStats::new);
|
||||||
bucket_stats.q_stat = queue_stats.snapshot();
|
bucket_stats.q_stat = queue_stats.snapshot();
|
||||||
bucket_stats.mark_node_local_provider_available();
|
bucket_stats.mark_node_local_provider_available();
|
||||||
bucket_stats.queue_scope = ReplicationMetricScope::NodeLocal;
|
bucket_stats.queue_scope = ReplicationMetricScope::NodeLocal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,8 +508,11 @@ impl ReplicationStats {
|
|||||||
let boot_time = SystemTime::UNIX_EPOCH; // simplified implementation
|
let boot_time = SystemTime::UNIX_EPOCH; // simplified implementation
|
||||||
let uptime = SystemTime::now().duration_since(boot_time).unwrap_or_default().as_secs() as i64;
|
let uptime = SystemTime::now().duration_since(boot_time).unwrap_or_default().as_secs() as i64;
|
||||||
|
|
||||||
let q_cache = self.q_cache.lock().await;
|
let queued = self
|
||||||
let queued = q_cache.get_site_stats();
|
.q_cache
|
||||||
|
.lock()
|
||||||
|
.map(|q_cache| q_cache.get_site_stats())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let p_cache = self.p_cache.lock().await;
|
let p_cache = self.p_cache.lock().await;
|
||||||
let proxied = p_cache.get_site_stats();
|
let proxied = p_cache.get_site_stats();
|
||||||
@@ -633,8 +715,9 @@ impl ReplicationStats {
|
|||||||
drop(cache);
|
drop(cache);
|
||||||
|
|
||||||
{
|
{
|
||||||
let q_cache = self.q_cache.lock().await;
|
if let Ok(q_cache) = self.q_cache.lock()
|
||||||
if let Some(queue_stats) = q_cache.bucket_stats.get(bucket) {
|
&& let Some(queue_stats) = q_cache.bucket_stats.get(bucket)
|
||||||
|
{
|
||||||
replication_stats.q_stat = queue_stats.snapshot();
|
replication_stats.q_stat = queue_stats.snapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,31 +748,79 @@ impl ReplicationStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Increase queue statistics
|
/// Increase queue statistics
|
||||||
pub async fn inc_q(&self, bucket: &str, size: i64, _is_delete_repl: bool, _op_type: ReplicationType) {
|
pub fn inc_q(&self, bucket: &str, size: i64, _is_delete_repl: bool, _op_type: ReplicationType) {
|
||||||
let mut q_cache = self.q_cache.lock().await;
|
if let Ok(mut q_cache) = self.q_cache.lock() {
|
||||||
let stats = q_cache
|
q_cache.inc(bucket, size);
|
||||||
.bucket_stats
|
}
|
||||||
.entry(bucket.to_string())
|
|
||||||
.or_insert_with(InQueueMetric::default);
|
|
||||||
stats.curr.now_bytes.fetch_add(size, Ordering::Relaxed);
|
|
||||||
stats.curr.now_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
|
|
||||||
q_cache.sr_queue_stats.curr.now_bytes.fetch_add(size, Ordering::Relaxed);
|
|
||||||
q_cache.sr_queue_stats.curr.now_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrease queue statistics
|
/// Decrease queue statistics
|
||||||
pub async fn dec_q(&self, bucket: &str, size: i64, _is_del_marker: bool, _op_type: ReplicationType) {
|
pub fn dec_q(&self, bucket: &str, size: i64, _is_del_marker: bool, _op_type: ReplicationType) {
|
||||||
let mut q_cache = self.q_cache.lock().await;
|
if let Ok(mut q_cache) = self.q_cache.lock() {
|
||||||
let stats = q_cache
|
q_cache.dec(bucket, size);
|
||||||
.bucket_stats
|
}
|
||||||
.entry(bucket.to_string())
|
}
|
||||||
.or_insert_with(InQueueMetric::default);
|
|
||||||
stats.curr.now_bytes.fetch_sub(size, Ordering::Relaxed);
|
|
||||||
stats.curr.now_count.fetch_sub(1, Ordering::Relaxed);
|
|
||||||
|
|
||||||
q_cache.sr_queue_stats.curr.now_bytes.fetch_sub(size, Ordering::Relaxed);
|
pub(crate) fn inc_target_q(&self, bucket: &str, target_arns: &[String], size: i64) {
|
||||||
q_cache.sr_queue_stats.curr.now_count.fetch_sub(1, Ordering::Relaxed);
|
let target_arns = normalized_target_arns(target_arns);
|
||||||
|
if target_arns.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
with_target_queue_caches(|caches| {
|
||||||
|
prune_stale_target_queue_caches(caches);
|
||||||
|
let slot_index = match caches.iter().position(|slot| slot.belongs_to(&self.q_cache)) {
|
||||||
|
Some(index) => index,
|
||||||
|
None => {
|
||||||
|
caches.push(TargetQueueCacheSlot::new(&self.q_cache));
|
||||||
|
caches.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let slot = &mut caches[slot_index];
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
for target_arn in target_arns {
|
||||||
|
let metric = match slot.metrics.entry((bucket.clone(), target_arn.to_string())) {
|
||||||
|
Entry::Occupied(entry) => entry.into_mut(),
|
||||||
|
Entry::Vacant(entry) => entry.insert(InQueueMetric::default()),
|
||||||
|
};
|
||||||
|
metric.curr.add_current(size, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dec_target_q(&self, bucket: &str, target_arns: &[String], size: i64) {
|
||||||
|
let target_arns = normalized_target_arns(target_arns);
|
||||||
|
if target_arns.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
with_target_queue_caches(|caches| {
|
||||||
|
prune_stale_target_queue_caches(caches);
|
||||||
|
if let Some(slot) = caches.iter_mut().find(|slot| slot.belongs_to(&self.q_cache)) {
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
for target_arn in target_arns {
|
||||||
|
if let Some(metric) = slot.metrics.get_mut(&(bucket.clone(), target_arn.to_string())) {
|
||||||
|
metric.curr.subtract_current(size, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot.metrics
|
||||||
|
.retain(|_, metric| metric.curr.get_current_count() > 0 || metric.curr.get_current_bytes() > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_target_backlog_snapshot(&self) -> Vec<RuntimeReplicationTargetBacklog> {
|
||||||
|
let mut snapshot = with_target_queue_caches(|caches| {
|
||||||
|
caches
|
||||||
|
.iter()
|
||||||
|
.find(|slot| slot.belongs_to(&self.q_cache))
|
||||||
|
.map(|slot| target_queue_cache_snapshot(&slot.metrics))
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
snapshot.sort_by(|left, right| {
|
||||||
|
left.bucket
|
||||||
|
.cmp(&right.bucket)
|
||||||
|
.then_with(|| left.target_arn.cmp(&right.target_arn))
|
||||||
|
});
|
||||||
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Increase proxy metrics
|
/// Increase proxy metrics
|
||||||
@@ -715,6 +846,94 @@ impl Default for ReplicationStats {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_target_backlog_snapshot_tracks_targets() {
|
||||||
|
let stats = ReplicationStats::new();
|
||||||
|
stats.inc_target_q(
|
||||||
|
"photos",
|
||||||
|
&[
|
||||||
|
"arn:rustfs:replication:target-b".to_string(),
|
||||||
|
"arn:rustfs:replication:target-a".to_string(),
|
||||||
|
"arn:rustfs:replication:target-a".to_string(),
|
||||||
|
],
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = stats.runtime_target_backlog_snapshot();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
snapshot,
|
||||||
|
vec![
|
||||||
|
RuntimeReplicationTargetBacklog {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
target_arn: "arn:rustfs:replication:target-a".to_string(),
|
||||||
|
count: 1,
|
||||||
|
bytes: 1024,
|
||||||
|
},
|
||||||
|
RuntimeReplicationTargetBacklog {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
target_arn: "arn:rustfs:replication:target-b".to_string(),
|
||||||
|
count: 1,
|
||||||
|
bytes: 1024,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_target_backlog_ignores_empty_targets() {
|
||||||
|
let stats = ReplicationStats::new();
|
||||||
|
stats.inc_target_q("photos", &["".to_string()], 1024);
|
||||||
|
|
||||||
|
assert!(stats.runtime_target_backlog_snapshot().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_target_backlog_is_scoped_to_stats_instance() {
|
||||||
|
let first = ReplicationStats::new();
|
||||||
|
let second = ReplicationStats::new();
|
||||||
|
first.inc_target_q("photos", &["arn:rustfs:replication:target-a".to_string()], 1024);
|
||||||
|
|
||||||
|
assert!(second.runtime_target_backlog_snapshot().is_empty());
|
||||||
|
assert_eq!(first.runtime_target_backlog_snapshot()[0].count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_target_backlog_decrements_with_saturation() {
|
||||||
|
let stats = ReplicationStats::new();
|
||||||
|
let target_arns = ["arn:rustfs:replication:target-a".to_string()];
|
||||||
|
stats.inc_target_q("photos", &target_arns, 1024);
|
||||||
|
stats.dec_target_q("photos", &target_arns, 2048);
|
||||||
|
|
||||||
|
assert!(stats.runtime_target_backlog_snapshot().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_target_backlog_prunes_stale_sidecar_on_next_access() {
|
||||||
|
{
|
||||||
|
let stats = ReplicationStats::new();
|
||||||
|
stats.inc_target_q("photos", &["arn:rustfs:replication:target-a".to_string()], 1024);
|
||||||
|
assert!(
|
||||||
|
TARGET_QUEUE_CACHES
|
||||||
|
.lock()
|
||||||
|
.expect("target queue cache mutex")
|
||||||
|
.iter()
|
||||||
|
.any(|slot| slot.owner.strong_count() > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = ReplicationStats::new();
|
||||||
|
stats.inc_target_q("photos", &["arn:rustfs:replication:target-b".to_string()], 1024);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
TARGET_QUEUE_CACHES
|
||||||
|
.lock()
|
||||||
|
.expect("target queue cache mutex")
|
||||||
|
.iter()
|
||||||
|
.all(|slot| slot.owner.strong_count() > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_replication_stats_new() {
|
async fn test_replication_stats_new() {
|
||||||
let stats = ReplicationStats::new();
|
let stats = ReplicationStats::new();
|
||||||
@@ -801,14 +1020,14 @@ mod tests {
|
|||||||
async fn latest_stats_include_queue_until_drained() {
|
async fn latest_stats_include_queue_until_drained() {
|
||||||
let stats = ReplicationStats::new();
|
let stats = ReplicationStats::new();
|
||||||
|
|
||||||
stats.inc_q("queued-bucket", 4096, false, ReplicationType::Object).await;
|
stats.inc_q("queued-bucket", 4096, false, ReplicationType::Object);
|
||||||
let queued = stats.get_latest_replication_stats("queued-bucket").await;
|
let queued = stats.get_latest_replication_stats("queued-bucket").await;
|
||||||
assert!(queued.replication_stats.provider_available);
|
assert!(queued.replication_stats.provider_available);
|
||||||
assert_eq!(queued.replication_stats.q_stat.curr.count, 1);
|
assert_eq!(queued.replication_stats.q_stat.curr.count, 1);
|
||||||
assert_eq!(queued.replication_stats.q_stat.curr.bytes, 4096);
|
assert_eq!(queued.replication_stats.q_stat.curr.bytes, 4096);
|
||||||
assert_eq!(queued.replication_stats.queue_scope, ReplicationMetricScope::NodeLocal);
|
assert_eq!(queued.replication_stats.queue_scope, ReplicationMetricScope::NodeLocal);
|
||||||
|
|
||||||
stats.dec_q("queued-bucket", 4096, false, ReplicationType::Object).await;
|
stats.dec_q("queued-bucket", 4096, false, ReplicationType::Object);
|
||||||
let drained = stats.get_latest_replication_stats("queued-bucket").await;
|
let drained = stats.get_latest_replication_stats("queued-bucket").await;
|
||||||
assert_eq!(drained.replication_stats.q_stat.curr.count, 0);
|
assert_eq!(drained.replication_stats.q_stat.curr.count, 0);
|
||||||
assert_eq!(drained.replication_stats.q_stat.curr.bytes, 0);
|
assert_eq!(drained.replication_stats.q_stat.curr.bytes, 0);
|
||||||
@@ -911,7 +1130,7 @@ mod tests {
|
|||||||
for _ in 0..32 {
|
for _ in 0..32 {
|
||||||
let stats = Arc::clone(&stats);
|
let stats = Arc::clone(&stats);
|
||||||
tasks.push(tokio::spawn(async move {
|
tasks.push(tokio::spawn(async move {
|
||||||
stats.inc_q("concurrent-bucket", 7, false, ReplicationType::Object).await;
|
stats.inc_q("concurrent-bucket", 7, false, ReplicationType::Object);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
pub use rustfs_replication::BucketStats;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_replication::FailStats;
|
pub(crate) use rustfs_replication::FailStats;
|
||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
ActiveWorkerStat, BucketReplicationStat, BucketReplicationStats, InQueueMetric, ProxyMetric, ProxyStatsCache, QueueCache,
|
ActiveWorkerStat, BucketReplicationStat, InQueueMetric, ProxyMetric, ProxyStatsCache, QueueCache, ReplicationMetricScope,
|
||||||
ReplicationMetricScope, SRMetricsSummary, XferStats,
|
SRMetricsSummary, XferStats,
|
||||||
};
|
};
|
||||||
|
pub use rustfs_replication::{BucketReplicationStats, BucketStats};
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ pub(crate) fn deleted_object_for_replication(delete_object: DeletedObject) -> Re
|
|||||||
replication_state: delete_object.replication_state.as_ref().map(replication_state_from_filemeta),
|
replication_state: delete_object.replication_state.as_ref().map(replication_state_from_filemeta),
|
||||||
found: delete_object.found,
|
found: delete_object.found,
|
||||||
force_delete: delete_object.force_delete,
|
force_delete: delete_object.force_delete,
|
||||||
|
force_delete_id: delete_object.force_delete_id,
|
||||||
|
force_delete_target_arns: delete_object.force_delete_target_arns,
|
||||||
|
force_delete_generation: delete_object.force_delete_generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ use rustfs_replication::{
|
|||||||
};
|
};
|
||||||
use rustfs_utils::http::{
|
use rustfs_utils::http::{
|
||||||
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
|
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
|
||||||
AMZ_OBJECT_TAGGING, AMZ_SERVER_SIDE_ENCRYPTION, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT,
|
AMZ_OBJECT_TAGGING, AMZ_SERVER_SIDE_ENCRYPTION, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID,
|
||||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, HeaderExt as _,
|
AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE,
|
||||||
SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE,
|
HeaderExt as _, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP,
|
||||||
SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_TAGGING_TIMESTAMP, get_str, insert_header_map, is_internal_key,
|
SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_TAGGING_TIMESTAMP, get_str, insert_header_map,
|
||||||
|
is_internal_key,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
@@ -35,6 +36,8 @@ use time::format_description::well_known::Rfc3339;
|
|||||||
pub(crate) use crate::bucket::bucket_target_sys::{
|
pub(crate) use crate::bucket::bucket_target_sys::{
|
||||||
AdvancedPutOptions, PutObjectOptions, PutObjectPartOptions, RemoveObjectOptions, TargetClient,
|
AdvancedPutOptions, PutObjectOptions, PutObjectPartOptions, RemoveObjectOptions, TargetClient,
|
||||||
};
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use crate::bucket::target::BucketTarget;
|
||||||
pub(crate) use crate::bucket::target::BucketTargets;
|
pub(crate) use crate::bucket::target::BucketTargets;
|
||||||
|
|
||||||
use super::replication_config_store::ReplicationConfigStore;
|
use super::replication_config_store::ReplicationConfigStore;
|
||||||
@@ -77,6 +80,48 @@ static VALID_SSE_REPLICATION_HEADERS: &[(&str, &str)] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED: &str = "managed SSE replication requires target encryption support";
|
const ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED: &str = "managed SSE replication requires target encryption support";
|
||||||
|
const ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED: &str = "replication source contains unsupported encryption metadata";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ReplicationSourceEncryption {
|
||||||
|
Plaintext,
|
||||||
|
SseS3,
|
||||||
|
SseKms,
|
||||||
|
SseC,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_value<'a>(metadata: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
|
||||||
|
metadata
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_replication_source_encryption(metadata: &HashMap<String, String>) -> ReplicationSourceEncryption {
|
||||||
|
let is_ssec = replication_object_is_ssec_encrypted(metadata);
|
||||||
|
let sse = metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION);
|
||||||
|
let kms_key_id = metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID);
|
||||||
|
let kms_context = metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT);
|
||||||
|
|
||||||
|
if is_ssec {
|
||||||
|
return if sse.is_some() || kms_key_id.is_some() || kms_context.is_some() {
|
||||||
|
ReplicationSourceEncryption::Unsupported
|
||||||
|
} else {
|
||||||
|
ReplicationSourceEncryption::SseC
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match sse.map(str::trim) {
|
||||||
|
None if kms_key_id.is_none() && kms_context.is_none() => ReplicationSourceEncryption::Plaintext,
|
||||||
|
Some(value) if value.eq_ignore_ascii_case("AES256") && kms_key_id.is_none() && kms_context.is_none() => {
|
||||||
|
ReplicationSourceEncryption::SseS3
|
||||||
|
}
|
||||||
|
Some(value) if value.eq_ignore_ascii_case("aws:kms") => ReplicationSourceEncryption::SseKms,
|
||||||
|
_ if kms_key_id.is_some() => ReplicationSourceEncryption::SseKms,
|
||||||
|
_ => ReplicationSourceEncryption::Unsupported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
|
pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
|
||||||
rustfs_replication::is_ssec_encrypted(user_defined)
|
rustfs_replication::is_ssec_encrypted(user_defined)
|
||||||
@@ -93,12 +138,20 @@ impl ReplicationTargetStore {
|
|||||||
BucketTargetSys::get().get_remote_target_client(bucket, arn).await
|
BucketTargetSys::get().get_remote_target_client(bucket, arn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn target_is_offline(target_client: &TargetClient) -> bool {
|
pub(crate) async fn target_is_offline(target_client: &Arc<TargetClient>) -> bool {
|
||||||
BucketTargetSys::get().is_offline(&target_client.to_url()).await
|
BucketTargetSys::get().is_target_offline(target_client).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn mark_target_offline(target_client: &TargetClient) {
|
pub(crate) async fn mark_target_offline(target_client: &Arc<TargetClient>) {
|
||||||
BucketTargetSys::get().mark_offline(&target_client.to_url()).await
|
BucketTargetSys::get().mark_target_offline(target_client).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn register_test_target(target_client: &Arc<TargetClient>) {
|
||||||
|
BucketTargetSys::get().arn_remotes_map.write().await.insert(
|
||||||
|
target_client.arn.clone(),
|
||||||
|
crate::bucket::bucket_target_sys::ArnTarget::with_client(target_client.clone()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +160,18 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
|||||||
use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT};
|
use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT};
|
||||||
|
|
||||||
let mut meta = HashMap::new();
|
let mut meta = HashMap::new();
|
||||||
let is_ssec = replication_object_is_ssec_encrypted(&object_info.user_defined);
|
let source_encryption = classify_replication_source_encryption(&object_info.user_defined);
|
||||||
|
let is_ssec = matches!(source_encryption, ReplicationSourceEncryption::SseC);
|
||||||
|
|
||||||
|
match source_encryption {
|
||||||
|
ReplicationSourceEncryption::Plaintext | ReplicationSourceEncryption::SseC => {}
|
||||||
|
ReplicationSourceEncryption::SseS3 | ReplicationSourceEncryption::SseKms => {
|
||||||
|
return Err(Error::other(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
|
||||||
|
}
|
||||||
|
ReplicationSourceEncryption::Unsupported => {
|
||||||
|
return Err(Error::other(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (key, value) in object_info.user_defined.iter() {
|
for (key, value) in object_info.user_defined.iter() {
|
||||||
let has_valid_sse_header = valid_sse_replication_header(key).is_some();
|
let has_valid_sse_header = valid_sse_replication_header(key).is_some();
|
||||||
@@ -233,20 +297,6 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_sse_s3 = object_info
|
|
||||||
.user_defined
|
|
||||||
.get(AMZ_SERVER_SIDE_ENCRYPTION)
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("AES256"));
|
|
||||||
let has_sse_kms = object_info
|
|
||||||
.user_defined
|
|
||||||
.get(AMZ_SERVER_SIDE_ENCRYPTION)
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("aws:kms"))
|
|
||||||
|| object_info.user_defined.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID);
|
|
||||||
|
|
||||||
if has_sse_s3 || has_sse_kms {
|
|
||||||
return Err(Error::other(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((put_options, is_multipart))
|
Ok((put_options, is_multipart))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +634,46 @@ mod tests {
|
|||||||
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_SSEC_CRC).is_some());
|
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_SSEC_CRC).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_source_encryption_classification_is_explicit_and_fail_closed() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::new()),
|
||||||
|
ReplicationSourceEncryption::Plaintext
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::from([(
|
||||||
|
"x-amz-server-side-encryption".to_string(),
|
||||||
|
"AES256".to_string()
|
||||||
|
)])),
|
||||||
|
ReplicationSourceEncryption::SseS3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::from([(
|
||||||
|
"x-amz-server-side-encryption".to_string(),
|
||||||
|
"AWS:KMS".to_string()
|
||||||
|
)])),
|
||||||
|
ReplicationSourceEncryption::SseKms
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())])),
|
||||||
|
ReplicationSourceEncryption::SseC
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::from([(
|
||||||
|
"x-amz-server-side-encryption".to_string(),
|
||||||
|
"unsupported-algorithm".to_string(),
|
||||||
|
)])),
|
||||||
|
ReplicationSourceEncryption::Unsupported
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_replication_source_encryption(&HashMap::from([(
|
||||||
|
AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT.to_string(),
|
||||||
|
"opaque-context".to_string(),
|
||||||
|
)])),
|
||||||
|
ReplicationSourceEncryption::Unsupported
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_put_options_rejects_sse_s3_until_target_encryption_is_supported() {
|
fn replication_put_options_rejects_sse_s3_until_target_encryption_is_supported() {
|
||||||
let object_info = ObjectInfo {
|
let object_info = ObjectInfo {
|
||||||
@@ -617,6 +707,25 @@ mod tests {
|
|||||||
assert!(err.to_string().contains(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
|
assert!(err.to_string().contains(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_put_options_rejects_unknown_encryption_without_echoing_metadata() {
|
||||||
|
let secret_like_value = "opaque-context-that-must-not-be-logged";
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
user_defined: Arc::new(HashMap::from([
|
||||||
|
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "unsupported-algorithm".to_string()),
|
||||||
|
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT.to_string(), secret_like_value.to_string()),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = match replication_put_object_options("", &object_info) {
|
||||||
|
Ok(_) => panic!("unknown encryption must fail closed"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED));
|
||||||
|
assert!(!err.to_string().contains(secret_like_value));
|
||||||
|
}
|
||||||
|
|
||||||
// T3 (#1264): the outbound replication path forwards a stored object checksum into
|
// T3 (#1264): the outbound replication path forwards a stored object checksum into
|
||||||
// user_metadata via decrypt_checksums, which is algorithm-agnostic. This locks that
|
// user_metadata via decrypt_checksums, which is algorithm-agnostic. This locks that
|
||||||
// the AWS 2026-04 additional algorithms (XXHash3/64/128, SHA-512, MD5) are forwarded
|
// the AWS 2026-04 additional algorithms (XXHash3/64/128, SHA-512, MD5) are forwarded
|
||||||
|
|||||||
@@ -12,11 +12,28 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
use super::replication_error_boundary::Result;
|
||||||
|
use crate::bucket::{versioning::VersioningApi as _, versioning_sys::BucketVersioningSys};
|
||||||
|
#[cfg(test)]
|
||||||
|
use s3s::dto::VersioningConfiguration;
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::{collections::HashMap, sync::LazyLock, sync::Mutex};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
static PREFIX_STATE_TEST_CONFIGS: LazyLock<Mutex<HashMap<String, VersioningConfiguration>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
pub(crate) struct ReplicationVersioningStore;
|
pub(crate) struct ReplicationVersioningStore;
|
||||||
|
|
||||||
impl ReplicationVersioningStore {
|
impl ReplicationVersioningStore {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn install_prefix_state_test_config(bucket: &str, config: VersioningConfiguration) {
|
||||||
|
PREFIX_STATE_TEST_CONFIGS
|
||||||
|
.lock()
|
||||||
|
.expect("replication versioning test config lock should not be poisoned")
|
||||||
|
.insert(bucket.to_string(), config);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn prefix_enabled(bucket: &str, prefix: &str) -> bool {
|
pub(crate) async fn prefix_enabled(bucket: &str, prefix: &str) -> bool {
|
||||||
BucketVersioningSys::prefix_enabled(bucket, prefix).await
|
BucketVersioningSys::prefix_enabled(bucket, prefix).await
|
||||||
}
|
}
|
||||||
@@ -24,4 +41,18 @@ impl ReplicationVersioningStore {
|
|||||||
pub(crate) async fn prefix_suspended(bucket: &str, prefix: &str) -> bool {
|
pub(crate) async fn prefix_suspended(bucket: &str, prefix: &str) -> bool {
|
||||||
BucketVersioningSys::prefix_suspended(bucket, prefix).await
|
BucketVersioningSys::prefix_suspended(bucket, prefix).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn prefix_state(bucket: &str, prefix: &str) -> Result<(bool, bool)> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(config) = PREFIX_STATE_TEST_CONFIGS
|
||||||
|
.lock()
|
||||||
|
.expect("replication versioning test config lock should not be poisoned")
|
||||||
|
.remove(bucket)
|
||||||
|
{
|
||||||
|
return Ok((config.prefix_enabled(prefix), config.prefix_suspended(prefix)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = BucketVersioningSys::get(bucket).await?;
|
||||||
|
Ok((config.prefix_enabled(prefix), config.prefix_suspended(prefix)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,6 +530,47 @@ mod tests {
|
|||||||
assert!(redacted_json.contains(r#""session_token":null"#));
|
assert!(redacted_json.contains(r#""session_token":null"#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_bucket_target_options_remain_readable() {
|
||||||
|
let target: BucketTarget = serde_json::from_value(serde_json::json!({
|
||||||
|
"endpoint": "legacy.example:9000",
|
||||||
|
"credentials": {
|
||||||
|
"accessKey": "legacy-access",
|
||||||
|
"secretKey": "legacy-secret",
|
||||||
|
"session_token": "legacy-session-token",
|
||||||
|
"expiration": "2024-12-31T23:59:59Z"
|
||||||
|
},
|
||||||
|
"targetbucket": "legacy-bucket",
|
||||||
|
"api": "s3v2",
|
||||||
|
"healthCheckDuration": 30,
|
||||||
|
"disableProxy": true,
|
||||||
|
"edge": true,
|
||||||
|
"edgeSyncBeforeExpiry": true,
|
||||||
|
"type": "replication"
|
||||||
|
}))
|
||||||
|
.expect("historical remote target should remain readable");
|
||||||
|
|
||||||
|
assert_eq!(target.api, "s3v2");
|
||||||
|
assert_eq!(target.health_check_duration, Duration::from_secs(30));
|
||||||
|
assert!(target.disable_proxy);
|
||||||
|
assert!(target.edge);
|
||||||
|
assert!(target.edge_sync_before_expiry);
|
||||||
|
assert_eq!(
|
||||||
|
target
|
||||||
|
.credentials
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.session_token.as_deref()),
|
||||||
|
Some("legacy-session-token")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
target
|
||||||
|
.credentials
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.expiration)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bucket_target_type_json_deserialize() {
|
fn test_bucket_target_type_json_deserialize() {
|
||||||
// Test BucketTargetType JSON deserialization
|
// Test BucketTargetType JSON deserialization
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub trait VersioningApi {
|
|||||||
fn enabled(&self) -> bool;
|
fn enabled(&self) -> bool;
|
||||||
fn prefix_enabled(&self, prefix: &str) -> bool;
|
fn prefix_enabled(&self, prefix: &str) -> bool;
|
||||||
fn prefix_suspended(&self, prefix: &str) -> bool;
|
fn prefix_suspended(&self, prefix: &str) -> bool;
|
||||||
|
fn delete_state(&self, prefix: &str) -> (bool, bool) {
|
||||||
|
(self.prefix_enabled(prefix), self.suspended())
|
||||||
|
}
|
||||||
fn versioned(&self, prefix: &str) -> bool;
|
fn versioned(&self, prefix: &str) -> bool;
|
||||||
fn suspended(&self) -> bool;
|
fn suspended(&self) -> bool;
|
||||||
}
|
}
|
||||||
@@ -92,3 +95,60 @@ impl VersioningApi for VersioningConfiguration {
|
|||||||
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED))
|
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct LegacyVersioning;
|
||||||
|
|
||||||
|
impl VersioningApi for LegacyVersioning {
|
||||||
|
fn enabled(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_enabled(&self, _prefix: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_suspended(&self, _prefix: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn versioned(&self, _prefix: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suspended(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_state_has_a_backward_compatible_default() {
|
||||||
|
assert_eq!(LegacyVersioning.delete_state("object"), (false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_state_treats_excluded_prefixes_as_unversioned() {
|
||||||
|
let config = VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
|
excluded_prefixes: Some(vec![ExcludedPrefix {
|
||||||
|
prefix: Some("archive/".to_string()),
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.delete_state("archive/object"), (false, false));
|
||||||
|
assert!(config.prefix_suspended("archive/object"));
|
||||||
|
assert_eq!(config.delete_state("live/object"), (true, false));
|
||||||
|
|
||||||
|
let suspended = VersioningConfiguration {
|
||||||
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(suspended.delete_state("archive/object"), (false, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,17 +21,15 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
|
|||||||
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
||||||
|
|
||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationState, replication_state_to_filemeta};
|
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge};
|
||||||
use crate::bucket::versioning::VersioningApi;
|
|
||||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
|
||||||
use crate::object_api::ObjectOptions;
|
use crate::object_api::ObjectOptions;
|
||||||
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
use rustfs_lock::MAX_DELETE_LIST;
|
use rustfs_lock::MAX_DELETE_LIST;
|
||||||
|
|
||||||
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
|
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
|
||||||
let version_suspended = match BucketVersioningSys::get(bucket).await {
|
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||||
Ok(vc) => vc.suspended(),
|
Ok(snapshot) => Arc::new(snapshot),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
||||||
@@ -39,7 +37,7 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
|||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
bucket,
|
bucket,
|
||||||
error = ?err,
|
error = ?err,
|
||||||
reason = "versioning_config_unavailable",
|
reason = "delete_config_snapshot_unavailable",
|
||||||
"Skipped lifecycle noncurrent version cleanup"
|
"Skipped lifecycle noncurrent version cleanup"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -55,45 +53,12 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
|||||||
remaining = &[];
|
remaining = &[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut replication_candidates: Vec<Option<ReplicationState>> = Vec::with_capacity(to_del.len());
|
|
||||||
for object in to_del.iter() {
|
|
||||||
let version_id = object.version_id.map(|vid| vid.to_string());
|
|
||||||
let opts = ObjectOptions {
|
|
||||||
version_id: version_id.clone(),
|
|
||||||
versioned: true,
|
|
||||||
version_suspended,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let candidate = match api.get_object_info(bucket, &object.object_name, &opts).await {
|
|
||||||
Ok(info) => {
|
|
||||||
let dsc = ReplicationLifecycleBridge::check_delete_replication(bucket, object, &info, &opts).await;
|
|
||||||
dsc.replicate_any()
|
|
||||||
.then(|| ReplicationLifecycleBridge::version_delete_replication_state(&dsc))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
||||||
bucket,
|
|
||||||
object = %object.object_name,
|
|
||||||
version_id = ?version_id,
|
|
||||||
error = ?err,
|
|
||||||
reason = "object_info_unavailable",
|
|
||||||
"Skipped lifecycle delete replication scheduling"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
replication_candidates.push(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mut deleted_objs, errors) = api
|
let (mut deleted_objs, errors) = api
|
||||||
.delete_objects(
|
.delete_objects(
|
||||||
bucket,
|
bucket,
|
||||||
to_del.to_vec(),
|
to_del.to_vec(),
|
||||||
ObjectOptions {
|
ObjectOptions {
|
||||||
version_suspended,
|
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -108,10 +73,9 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
|||||||
if let Some(target) = to_del.get(i) {
|
if let Some(target) = to_del.get(i) {
|
||||||
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
|
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
|
||||||
}
|
}
|
||||||
let Some(replication_state) = replication_candidates.get(i).and_then(|c| c.clone()) else {
|
if deleted_obj.replication_state.is_none() {
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
deleted_obj.replication_state = Some(replication_state_to_filemeta(&replication_state));
|
|
||||||
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
|||||||
pub use internode_data_transport::build_internode_data_transport_from_env;
|
pub use internode_data_transport::build_internode_data_transport_from_env;
|
||||||
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
|
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
|
||||||
pub use peer_rest_client::{
|
pub use peer_rest_client::{
|
||||||
PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
|
KMS_SIGNAL_SUBSYSTEM, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity,
|
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity,
|
||||||
};
|
};
|
||||||
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
|
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ use rustfs_protos::proto_gen::node_service::{
|
|||||||
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
|
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
|
||||||
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
|
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
|
||||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
|
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
|
||||||
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, StartDecommissionRequest, StartProfilingRequest,
|
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
|
||||||
StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
|
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||||
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||||
};
|
};
|
||||||
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
|
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
|
||||||
@@ -71,6 +71,12 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1;
|
pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1;
|
||||||
pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
|
pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
|
||||||
|
/// Dynamic config subsystem for the cluster-persisted KMS configuration.
|
||||||
|
///
|
||||||
|
/// KMS configuration lives in its own cluster object rather than in the server
|
||||||
|
/// config document, so it is not a `ServerConfig` subsystem; it only shares the
|
||||||
|
/// reload signal transport.
|
||||||
|
pub const KMS_SIGNAL_SUBSYSTEM: &str = "kms";
|
||||||
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
|
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
|
||||||
const HEAL_CONTROL_FINGERPRINT_MAX_SIZE: usize = 256;
|
const HEAL_CONTROL_FINGERPRINT_MAX_SIZE: usize = 256;
|
||||||
const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
|
const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
|
||||||
@@ -99,8 +105,13 @@ fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_signal_service_protocol(sig: u64, sub_sys: &str, protocol_version: u32) -> Result<()> {
|
fn validate_signal_service_protocol(sig: u64, sub_sys: &str, protocol_version: u32) -> Result<()> {
|
||||||
|
// The version stays pinned to DYNAMIC_CONFIG_PROTOCOL_VERSION rather than
|
||||||
|
// being bumped per subsystem: the comparison is shared, so raising it would
|
||||||
|
// retire peers that already converge scanner and heal config correctly.
|
||||||
|
// Subsystems added after a peer was built are rejected by that peer's own
|
||||||
|
// subsystem allow-list, which surfaces as an explicit failed signal.
|
||||||
if sig == SERVICE_SIGNAL_RELOAD_DYNAMIC
|
if sig == SERVICE_SIGNAL_RELOAD_DYNAMIC
|
||||||
&& matches!(sub_sys, SCANNER_SUB_SYS | HEAL_SUB_SYS)
|
&& matches!(sub_sys, SCANNER_SUB_SYS | HEAL_SUB_SYS | KMS_SIGNAL_SUBSYSTEM)
|
||||||
&& protocol_version < rustfs_protos::DYNAMIC_CONFIG_PROTOCOL_VERSION
|
&& protocol_version < rustfs_protos::DYNAMIC_CONFIG_PROTOCOL_VERSION
|
||||||
{
|
{
|
||||||
return Err(Error::other(format!("peer does not support dynamic {sub_sys} config convergence")));
|
return Err(Error::other(format!("peer does not support dynamic {sub_sys} config convergence")));
|
||||||
@@ -1500,6 +1511,22 @@ impl PeerRestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn signal_service(&self, sig: u64, sub_sys: &str, dry_run: bool, _exec_at: SystemTime) -> Result<()> {
|
pub async fn signal_service(&self, sig: u64, sub_sys: &str, dry_run: bool, _exec_at: SystemTime) -> Result<()> {
|
||||||
|
self.signal_service_checked(sig, sub_sys, dry_run).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the KMS configuration fingerprint the peer is currently running.
|
||||||
|
///
|
||||||
|
/// Sent as a dry-run reload signal so the peer answers without swapping its
|
||||||
|
/// own configuration. `None` means the peer has no KMS configuration. The
|
||||||
|
/// fingerprint is advisory and feeds cluster status reporting only, so the
|
||||||
|
/// response is not proof-signed.
|
||||||
|
pub async fn kms_config_fingerprint(&self) -> Result<Option<String>> {
|
||||||
|
self.signal_service_checked(SERVICE_SIGNAL_RELOAD_DYNAMIC, KMS_SIGNAL_SUBSYSTEM, true)
|
||||||
|
.await
|
||||||
|
.map(|response| response.config_fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signal_service_checked(&self, sig: u64, sub_sys: &str, dry_run: bool) -> Result<SignalServiceResponse> {
|
||||||
self.finalize_result(
|
self.finalize_result(
|
||||||
async {
|
async {
|
||||||
let mut client = self.get_client().await?;
|
let mut client = self.get_client().await?;
|
||||||
@@ -1520,7 +1547,7 @@ impl PeerRestClient {
|
|||||||
return Err(Error::other(""));
|
return Err(Error::other(""));
|
||||||
}
|
}
|
||||||
validate_signal_service_protocol(sig, sub_sys, response.protocol_version)?;
|
validate_signal_service_protocol(sig, sub_sys, response.protocol_version)?;
|
||||||
Ok(())
|
Ok(response)
|
||||||
}
|
}
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
@@ -2347,6 +2374,19 @@ mod tests {
|
|||||||
.expect("full refresh compatibility is guarded by its scanner preflight");
|
.expect("full refresh compatibility is guarded by its scanner preflight");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dynamic_kms_config_requires_versioned_peer_acknowledgement() {
|
||||||
|
let err = validate_signal_service_protocol(SERVICE_SIGNAL_RELOAD_DYNAMIC, KMS_SIGNAL_SUBSYSTEM, 0)
|
||||||
|
.expect_err("an unversioned peer must not claim KMS config convergence");
|
||||||
|
assert!(err.to_string().contains("does not support dynamic"));
|
||||||
|
validate_signal_service_protocol(
|
||||||
|
SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||||
|
KMS_SIGNAL_SUBSYSTEM,
|
||||||
|
rustfs_protos::DYNAMIC_CONFIG_PROTOCOL_VERSION,
|
||||||
|
)
|
||||||
|
.expect("a current peer should support dynamic KMS config");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_rest_client_marks_network_like_errors() {
|
fn peer_rest_client_marks_network_like_errors() {
|
||||||
assert!(PeerRestClient::is_network_like_error(&Error::other("transport error")));
|
assert!(PeerRestClient::is_network_like_error(&Error::other("transport error")));
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ use std::sync::LazyLock;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tokio::sync::{OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
|
use tokio::sync::{OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
|
||||||
use tracing::{debug, error, info, instrument, warn};
|
use tracing::{debug, error, info, instrument, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub const CONFIG_PREFIX: &str = "config";
|
pub const CONFIG_PREFIX: &str = "config";
|
||||||
const SERVER_CONFIG_OBJECT: &str = "config/config.json";
|
const SERVER_CONFIG_OBJECT: &str = "config/config.json";
|
||||||
|
const CONFIG_TRANSACTION_LOCK_SUFFIX: &str = ".transaction.lock";
|
||||||
|
|
||||||
// Server-config lock order: SERVER_CONFIG_LOCK -> distributed namespace lock
|
// Server-config lock order: SERVER_CONFIG_LOCK -> transaction lock ->
|
||||||
// for SERVER_CONFIG_OBJECT. Readers and writers must never reverse this order.
|
// SERVER_CONFIG_OBJECT. Readers and writers must never reverse this order.
|
||||||
static SERVER_CONFIG_LOCK: LazyLock<Arc<AsyncRwLock<()>>> = LazyLock::new(|| Arc::new(AsyncRwLock::new(())));
|
static SERVER_CONFIG_LOCK: LazyLock<Arc<AsyncRwLock<()>>> = LazyLock::new(|| Arc::new(AsyncRwLock::new(())));
|
||||||
|
|
||||||
fn config_task_join_error(operation: &'static str, error: tokio::task::JoinError) -> Error {
|
fn config_task_join_error(operation: &'static str, error: tokio::task::JoinError) -> Error {
|
||||||
@@ -76,8 +78,11 @@ where
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Lock order: SERVER_CONFIG_LOCK -> namespace write lock.
|
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
|
||||||
let _local_guard = SERVER_CONFIG_LOCK.write().await;
|
let _local_guard = SERVER_CONFIG_LOCK.write().await;
|
||||||
|
let transaction_lock = server_config_transaction_lock_path();
|
||||||
|
let transaction_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||||
|
let _transaction_guard = transaction_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||||
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
||||||
let _write_guard = namespace_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
let _write_guard = namespace_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||||
Ok(operation().await)
|
Ok(operation().await)
|
||||||
@@ -96,8 +101,11 @@ where
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Lock order: SERVER_CONFIG_LOCK -> namespace read lock.
|
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
|
||||||
let _local_guard = SERVER_CONFIG_LOCK.read().await;
|
let _local_guard = SERVER_CONFIG_LOCK.read().await;
|
||||||
|
let transaction_lock = server_config_transaction_lock_path();
|
||||||
|
let transaction_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||||
|
let _transaction_guard = transaction_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
||||||
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
||||||
let _read_guard = namespace_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
let _read_guard = namespace_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
||||||
Ok(operation().await)
|
Ok(operation().await)
|
||||||
@@ -567,6 +575,21 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_config_with_opts<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
pub async fn save_config_with_opts<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||||
|
where
|
||||||
|
S: ObjectIO<
|
||||||
|
Error = Error,
|
||||||
|
RangeSpec = HTTPRangeSpec,
|
||||||
|
HeaderMap = HeaderMap,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
GetObjectReader = GetObjectReader,
|
||||||
|
PutObjectReader = PutObjReader,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
save_config_with_opts_and_metadata(api, file, data, opts).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_config_with_opts_and_metadata<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<ObjectInfo>
|
||||||
where
|
where
|
||||||
S: ObjectIO<
|
S: ObjectIO<
|
||||||
Error = Error,
|
Error = Error,
|
||||||
@@ -579,11 +602,13 @@ where
|
|||||||
>,
|
>,
|
||||||
{
|
{
|
||||||
let mut put_data = PutObjReader::from_vec(data);
|
let mut put_data = PutObjReader::from_vec(data);
|
||||||
if let Err(err) = api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
Ok(object_info) => Ok(object_info),
|
||||||
return Err(err);
|
Err(err) => {
|
||||||
|
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_server_config() -> Config {
|
fn new_server_config() -> Config {
|
||||||
@@ -594,8 +619,12 @@ async fn new_and_save_server_config<S>(api: Arc<S>) -> Result<Config>
|
|||||||
where
|
where
|
||||||
S: EcstoreObjectIO + StorageAdminApi + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
S: EcstoreObjectIO + StorageAdminApi + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||||
{
|
{
|
||||||
|
let snapshot = read_server_config_snapshot(api.clone()).await?;
|
||||||
|
if snapshot.object_exists() {
|
||||||
|
return Ok(snapshot.config.clone());
|
||||||
|
}
|
||||||
let cfg = new_server_config();
|
let cfg = new_server_config();
|
||||||
save_server_config(api, &cfg).await?;
|
save_server_config_snapshot(api, &cfg, &snapshot).await?;
|
||||||
|
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
@@ -617,6 +646,10 @@ pub fn server_config_path() -> String {
|
|||||||
SERVER_CONFIG_OBJECT.to_string()
|
SERVER_CONFIG_OBJECT.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn server_config_transaction_lock_path() -> String {
|
||||||
|
format!("{}{CONFIG_TRANSACTION_LOCK_SUFFIX}", server_config_path())
|
||||||
|
}
|
||||||
|
|
||||||
fn storage_class_kvs_mut(cfg: &mut Config) -> &mut KVS {
|
fn storage_class_kvs_mut(cfg: &mut Config) -> &mut KVS {
|
||||||
let sub_cfg = cfg.0.entry(STORAGE_CLASS_SUB_SYS.to_string()).or_insert_with(|| {
|
let sub_cfg = cfg.0.entry(STORAGE_CLASS_SUB_SYS.to_string()).or_insert_with(|| {
|
||||||
let mut section = HashMap::new();
|
let mut section = HashMap::new();
|
||||||
@@ -819,6 +852,9 @@ fn apply_external_scalar_config_map(
|
|||||||
let Some(config_value) = root.get(descriptor.subsystem_key) else {
|
let Some(config_value) = root.get(descriptor.subsystem_key) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
if descriptor.subsystem_key == HEAL_SUB_SYS && config_value.is_null() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let overrides = decode_scalar_config_value(config_value, descriptor)?;
|
let overrides = decode_scalar_config_value(config_value, descriptor)?;
|
||||||
|
|
||||||
if overrides.is_empty() {
|
if overrides.is_empty() {
|
||||||
@@ -1463,21 +1499,142 @@ fn build_audit_object(cfg: &Config) -> Map<String, Value> {
|
|||||||
build_target_object(cfg, &audit_target_descriptors())
|
build_target_object(cfg, &audit_target_descriptors())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_rendered_target_instance(existing: Value, rendered: Option<&Value>, valid_keys: &[&str]) -> Option<Value> {
|
||||||
|
match existing {
|
||||||
|
Value::Object(mut instance) => {
|
||||||
|
for key in valid_keys {
|
||||||
|
instance.remove(*key);
|
||||||
|
}
|
||||||
|
if let Some(Value::Object(rendered)) = rendered {
|
||||||
|
instance.extend(rendered.clone());
|
||||||
|
}
|
||||||
|
(!instance.is_empty()).then_some(Value::Object(instance))
|
||||||
|
}
|
||||||
|
Value::Array(entries) => {
|
||||||
|
let mut pending = rendered
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.map(|rendered| {
|
||||||
|
rendered
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(key, value)| parse_target_scalar_value(key, value).map(|value| (key.clone(), value)))
|
||||||
|
.collect::<HashMap<_, _>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut updated = Vec::with_capacity(entries.len().saturating_add(pending.len()));
|
||||||
|
for entry in entries {
|
||||||
|
let Some(entry_obj) = entry.as_object() else {
|
||||||
|
updated.push(entry);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(key) = entry_obj.get("key").and_then(Value::as_str) else {
|
||||||
|
updated.push(entry);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !valid_keys.contains(&key) {
|
||||||
|
updated.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(value) = pending.remove(key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut entry_obj = entry_obj.clone();
|
||||||
|
entry_obj.insert("value".to_string(), Value::String(value));
|
||||||
|
updated.push(Value::Object(entry_obj));
|
||||||
|
}
|
||||||
|
updated.extend(rendered_scalar_config_kvs_entries(&pending));
|
||||||
|
(!updated.is_empty()).then_some(Value::Array(updated))
|
||||||
|
}
|
||||||
|
value if rendered.is_none() => Some(value),
|
||||||
|
_ => rendered.cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_rendered_target_object(
|
fn sync_rendered_target_object(
|
||||||
target_obj: &mut Map<String, Value>,
|
target_obj: &mut Map<String, Value>,
|
||||||
rendered_target: &Map<String, Value>,
|
rendered_target: &Map<String, Value>,
|
||||||
descriptors: &[TargetConfigDescriptor],
|
descriptors: &[TargetConfigDescriptor],
|
||||||
) {
|
) {
|
||||||
for descriptor in descriptors {
|
for descriptor in descriptors {
|
||||||
match rendered_target.get(descriptor.external_key) {
|
let existing = target_obj.remove(descriptor.external_key);
|
||||||
Some(Value::Object(v)) => {
|
let alias = target_obj.remove(descriptor.subsystem_key);
|
||||||
target_obj.insert(descriptor.external_key.to_string(), Value::Object(v.clone()));
|
let mut section = existing
|
||||||
target_obj.remove(descriptor.subsystem_key);
|
.or(alias)
|
||||||
|
.and_then(|value| value.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rendered = rendered_target.get(descriptor.external_key).and_then(Value::as_object);
|
||||||
|
|
||||||
|
if is_target_instance_shorthand(§ion, descriptor.valid_keys) {
|
||||||
|
let has_named_instances = rendered.is_some_and(|instances| instances.keys().any(|name| name != "default"));
|
||||||
|
if !has_named_instances {
|
||||||
|
if let Some(section) = sync_rendered_target_instance(
|
||||||
|
Value::Object(section),
|
||||||
|
rendered.and_then(|instances| instances.get("default")),
|
||||||
|
descriptor.valid_keys,
|
||||||
|
) {
|
||||||
|
target_obj.insert(descriptor.external_key.to_string(), section);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
target_obj.remove(descriptor.external_key);
|
let mut nested = Map::new();
|
||||||
target_obj.remove(descriptor.subsystem_key);
|
if let Some(default) = sync_rendered_target_instance(
|
||||||
|
Value::Object(section),
|
||||||
|
rendered.and_then(|instances| instances.get("default")),
|
||||||
|
descriptor.valid_keys,
|
||||||
|
) {
|
||||||
|
nested.insert("default".to_string(), default);
|
||||||
}
|
}
|
||||||
|
if let Some(rendered) = rendered {
|
||||||
|
for (instance_name, instance) in rendered {
|
||||||
|
if instance_name != "default" {
|
||||||
|
nested.insert(instance_name.clone(), instance.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nested.is_empty() {
|
||||||
|
target_obj.insert(descriptor.external_key.to_string(), Value::Object(nested));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(default_alias) = section.remove(DEFAULT_DELIMITER) {
|
||||||
|
if let Some(default) = section.get_mut("default") {
|
||||||
|
if let Some(alias) = sync_rendered_target_instance(default_alias, None, descriptor.valid_keys) {
|
||||||
|
match (default, alias) {
|
||||||
|
(Value::Object(default), Value::Object(alias)) => {
|
||||||
|
for (key, value) in alias {
|
||||||
|
default.entry(key).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Value::Array(default), Value::Array(alias)) => default.extend(alias),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
section.insert("default".to_string(), default_alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut merged = Map::new();
|
||||||
|
for (instance_name, instance) in section {
|
||||||
|
if let Some(instance) = sync_rendered_target_instance(
|
||||||
|
instance,
|
||||||
|
rendered.and_then(|instances| instances.get(&instance_name)),
|
||||||
|
descriptor.valid_keys,
|
||||||
|
) {
|
||||||
|
merged.insert(instance_name, instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rendered) = rendered {
|
||||||
|
for (instance_name, instance) in rendered {
|
||||||
|
if !merged.contains_key(instance_name) {
|
||||||
|
merged.insert(instance_name.clone(), instance.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !merged.is_empty() {
|
||||||
|
target_obj.insert(descriptor.external_key.to_string(), Value::Object(merged));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1496,6 +1653,14 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
|||||||
Some(Value::Object(v)) => v,
|
Some(Value::Object(v)) => v,
|
||||||
_ => Map::new(),
|
_ => Map::new(),
|
||||||
};
|
};
|
||||||
|
for key in [
|
||||||
|
storageclass::CLASS_STANDARD,
|
||||||
|
storageclass::CLASS_RRS,
|
||||||
|
storageclass::OPTIMIZE,
|
||||||
|
storageclass::INLINE_BLOCK,
|
||||||
|
] {
|
||||||
|
sc_obj.remove(key);
|
||||||
|
}
|
||||||
for (k, v) in build_storageclass_object(cfg) {
|
for (k, v) in build_storageclass_object(cfg) {
|
||||||
sc_obj.insert(k, v);
|
sc_obj.insert(k, v);
|
||||||
}
|
}
|
||||||
@@ -1503,7 +1668,10 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
|||||||
root.remove("storage_class");
|
root.remove("storage_class");
|
||||||
|
|
||||||
for descriptor in [scanner_config_descriptor(), heal_config_descriptor()] {
|
for descriptor in [scanner_config_descriptor(), heal_config_descriptor()] {
|
||||||
let existing = root.remove(descriptor.subsystem_key);
|
let mut existing = root.remove(descriptor.subsystem_key);
|
||||||
|
if descriptor.subsystem_key == HEAL_SUB_SYS && existing.as_ref().is_some_and(Value::is_null) {
|
||||||
|
existing = None;
|
||||||
|
}
|
||||||
let rendered = build_scalar_config_object(cfg, descriptor);
|
let rendered = build_scalar_config_object(cfg, descriptor);
|
||||||
if let Some(config_value) = sync_rendered_scalar_config_value(existing, &rendered, descriptor)? {
|
if let Some(config_value) = sync_rendered_scalar_config_value(existing, &rendered, descriptor)? {
|
||||||
root.insert(descriptor.subsystem_key.to_string(), config_value);
|
root.insert(descriptor.subsystem_key.to_string(), config_value);
|
||||||
@@ -1560,6 +1728,7 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
|
|||||||
matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty())
|
matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty())
|
||||||
&& matches!(root.get("storageclass"), Some(Value::Object(_)))
|
&& matches!(root.get("storageclass"), Some(Value::Object(_)))
|
||||||
&& !root.contains_key("storage_class")
|
&& !root.contains_key("storage_class")
|
||||||
|
&& !matches!(root.get(HEAL_SUB_SYS), Some(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
|
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
|
||||||
@@ -1593,7 +1762,7 @@ where
|
|||||||
FileInfo = FileInfo,
|
FileInfo = FileInfo,
|
||||||
ObjectToDelete = ObjectToDelete,
|
ObjectToDelete = ObjectToDelete,
|
||||||
DeletedObject = DeletedObject,
|
DeletedObject = DeletedObject,
|
||||||
>,
|
> + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||||
{
|
{
|
||||||
if let Some(decrypt) = &decrypt_fn {
|
if let Some(decrypt) = &decrypt_fn {
|
||||||
register_server_config_decrypt_fn(decrypt.clone());
|
register_server_config_decrypt_fn(decrypt.clone());
|
||||||
@@ -1601,14 +1770,7 @@ where
|
|||||||
|
|
||||||
let config_file = server_config_path();
|
let config_file = server_config_path();
|
||||||
match api
|
match api
|
||||||
.get_object_info(
|
.get_object_info(RUSTFS_META_BUCKET, &config_file, &ObjectOptions::default())
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
&config_file,
|
|
||||||
&ObjectOptions {
|
|
||||||
no_lock: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -1624,7 +1786,6 @@ where
|
|||||||
|
|
||||||
let opts = ObjectOptions {
|
let opts = ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
no_lock: true,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1677,7 +1838,33 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match save_config(api, &config_file, normalized).await {
|
let snapshot = match read_server_config_snapshot(api.clone()).await {
|
||||||
|
Ok(snapshot) => snapshot,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("recheck target server config failed, skip migration: {:?}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if snapshot.object_exists() {
|
||||||
|
debug!("server config was created while legacy migration was preparing, skip migration");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match save_config_with_opts(
|
||||||
|
api,
|
||||||
|
&config_file,
|
||||||
|
normalized,
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Migrated compatible server config from legacy metadata bucket");
|
info!("Migrated compatible server config from legacy metadata bucket");
|
||||||
}
|
}
|
||||||
@@ -1769,8 +1956,13 @@ where
|
|||||||
{
|
{
|
||||||
let config_file = server_config_path();
|
let config_file = server_config_path();
|
||||||
|
|
||||||
// Try to read the configuration file
|
// Try to read the configuration file.
|
||||||
match read_config_no_lock(api.clone(), &config_file).await {
|
let data = if namespace_lock_held {
|
||||||
|
read_config_no_lock(api.clone(), &config_file).await
|
||||||
|
} else {
|
||||||
|
read_config(api.clone(), &config_file).await
|
||||||
|
};
|
||||||
|
match data {
|
||||||
Ok(data) => read_server_config(api, &data, namespace_lock_held).await,
|
Ok(data) => read_server_config(api, &data, namespace_lock_held).await,
|
||||||
Err(Error::ConfigNotFound) => handle_missing_config(api, "Read the main configuration", namespace_lock_held).await,
|
Err(Error::ConfigNotFound) => handle_missing_config(api, "Read the main configuration", namespace_lock_held).await,
|
||||||
Err(err) => handle_config_read_error(err, &config_file),
|
Err(err) => handle_config_read_error(err, &config_file),
|
||||||
@@ -1787,7 +1979,12 @@ where
|
|||||||
warn!("Received empty configuration data, try to reread from '{}'", config_file);
|
warn!("Received empty configuration data, try to reread from '{}'", config_file);
|
||||||
|
|
||||||
// Try to read the configuration again
|
// Try to read the configuration again
|
||||||
match read_config_no_lock(api.clone(), &config_file).await {
|
let data = if namespace_lock_held {
|
||||||
|
read_config_no_lock(api.clone(), &config_file).await
|
||||||
|
} else {
|
||||||
|
read_config(api.clone(), &config_file).await
|
||||||
|
};
|
||||||
|
match data {
|
||||||
Ok(cfg_data) => {
|
Ok(cfg_data) => {
|
||||||
let cfg = decode_persisted_server_config(&cfg_data)?;
|
let cfg = decode_persisted_server_config(&cfg_data)?;
|
||||||
return Ok(cfg.merge());
|
return Ok(cfg.merge());
|
||||||
@@ -2036,11 +2233,16 @@ pub struct ServerConfigSnapshot {
|
|||||||
raw: Option<Vec<u8>>,
|
raw: Option<Vec<u8>>,
|
||||||
seed: Option<Vec<u8>>,
|
seed: Option<Vec<u8>>,
|
||||||
etag: Option<String>,
|
etag: Option<String>,
|
||||||
|
generation: Option<Uuid>,
|
||||||
_local_guard: OwnedRwLockWriteGuard<()>,
|
_local_guard: OwnedRwLockWriteGuard<()>,
|
||||||
_guard: rustfs_lock::NamespaceLockGuard,
|
_guard: rustfs_lock::NamespaceLockGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfigSnapshot {
|
impl ServerConfigSnapshot {
|
||||||
|
pub fn object_exists(&self) -> bool {
|
||||||
|
self.raw.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_lock_held(&self) -> Result<()> {
|
pub fn ensure_lock_held(&self) -> Result<()> {
|
||||||
if self._guard.is_lock_lost() {
|
if self._guard.is_lock_lost() {
|
||||||
return Err(Error::other("server config transaction lock was lost"));
|
return Err(Error::other("server config transaction lock was lost"));
|
||||||
@@ -2051,12 +2253,34 @@ impl ServerConfigSnapshot {
|
|||||||
pub fn is_lock_lost(&self) -> bool {
|
pub fn is_lock_lost(&self) -> bool {
|
||||||
self._guard.is_lock_lost()
|
self._guard.is_lock_lost()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn generation(&self) -> Option<Uuid> {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a server config transaction snapshot while holding the same local and
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// distributed write locks used by every other server-config writer. Internal
|
pub struct ServerConfigSaveResult {
|
||||||
/// reads and the later conditional write use no-lock object I/O; the guards
|
persisted: bool,
|
||||||
/// remain live until the snapshot is dropped.
|
generation: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfigSaveResult {
|
||||||
|
pub fn persisted(&self) -> bool {
|
||||||
|
self.persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generation(&self) -> Option<Uuid> {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a server config transaction snapshot while holding a dedicated
|
||||||
|
/// transaction lock. The config object's normal namespace lock remains
|
||||||
|
/// available to fence reads and the conditional write at commit time.
|
||||||
|
/// The transaction guard remains live until the snapshot is dropped,
|
||||||
|
/// serializing persistence and history ordering across admin nodes. Runtime
|
||||||
|
/// state is reloaded from the durable object after this guard is released.
|
||||||
pub async fn read_server_config_snapshot<S>(api: Arc<S>) -> Result<ServerConfigSnapshot>
|
pub async fn read_server_config_snapshot<S>(api: Arc<S>) -> Result<ServerConfigSnapshot>
|
||||||
where
|
where
|
||||||
S: ObjectIO<
|
S: ObjectIO<
|
||||||
@@ -2071,12 +2295,10 @@ where
|
|||||||
{
|
{
|
||||||
let config_file = server_config_path();
|
let config_file = server_config_path();
|
||||||
let local_guard = SERVER_CONFIG_LOCK.clone().write_owned().await;
|
let local_guard = SERVER_CONFIG_LOCK.clone().write_owned().await;
|
||||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &config_file).await?;
|
let transaction_lock = server_config_transaction_lock_path();
|
||||||
|
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||||
let guard = lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
let guard = lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||||
let read_options = ObjectOptions {
|
let read_options = ObjectOptions::default();
|
||||||
no_lock: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true).await {
|
match read_config_with_metadata_inner(api, &config_file, &read_options, true).await {
|
||||||
Ok((raw, object_info)) => {
|
Ok((raw, object_info)) => {
|
||||||
let (config, seed) = decode_persisted_server_config_with_seed(&raw)?;
|
let (config, seed) = decode_persisted_server_config_with_seed(&raw)?;
|
||||||
@@ -2085,6 +2307,7 @@ where
|
|||||||
raw: Some(raw),
|
raw: Some(raw),
|
||||||
seed: Some(seed),
|
seed: Some(seed),
|
||||||
etag: object_info.etag,
|
etag: object_info.etag,
|
||||||
|
generation: object_info.data_dir.filter(|generation| !generation.is_nil()),
|
||||||
_local_guard: local_guard,
|
_local_guard: local_guard,
|
||||||
_guard: guard,
|
_guard: guard,
|
||||||
})
|
})
|
||||||
@@ -2094,6 +2317,7 @@ where
|
|||||||
raw: None,
|
raw: None,
|
||||||
seed: None,
|
seed: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
|
generation: None,
|
||||||
_local_guard: local_guard,
|
_local_guard: local_guard,
|
||||||
_guard: guard,
|
_guard: guard,
|
||||||
}),
|
}),
|
||||||
@@ -2108,6 +2332,27 @@ where
|
|||||||
/// lock, so a concurrent update or transaction lease loss cannot commit an
|
/// lock, so a concurrent update or transaction lease loss cannot commit an
|
||||||
/// unfenced overwrite.
|
/// unfenced overwrite.
|
||||||
pub async fn save_server_config_snapshot<S>(api: Arc<S>, cfg: &Config, snapshot: &ServerConfigSnapshot) -> Result<bool>
|
pub async fn save_server_config_snapshot<S>(api: Arc<S>, cfg: &Config, snapshot: &ServerConfigSnapshot) -> Result<bool>
|
||||||
|
where
|
||||||
|
S: ObjectIO<
|
||||||
|
Error = Error,
|
||||||
|
RangeSpec = HTTPRangeSpec,
|
||||||
|
HeaderMap = HeaderMap,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
GetObjectReader = GetObjectReader,
|
||||||
|
PutObjectReader = PutObjReader,
|
||||||
|
> + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||||
|
{
|
||||||
|
save_server_config_snapshot_with_generation(api, cfg, snapshot)
|
||||||
|
.await
|
||||||
|
.map(|result| result.persisted())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_server_config_snapshot_with_generation<S>(
|
||||||
|
api: Arc<S>,
|
||||||
|
cfg: &Config,
|
||||||
|
snapshot: &ServerConfigSnapshot,
|
||||||
|
) -> Result<ServerConfigSaveResult>
|
||||||
where
|
where
|
||||||
S: ObjectIO<
|
S: ObjectIO<
|
||||||
Error = Error,
|
Error = Error,
|
||||||
@@ -2126,13 +2371,19 @@ where
|
|||||||
&& configs_semantically_equal(&snapshot.config, cfg)
|
&& configs_semantically_equal(&snapshot.config, cfg)
|
||||||
{
|
{
|
||||||
debug!("server config unchanged and already in standard object shape, skip write");
|
debug!("server config unchanged and already in standard object shape, skip write");
|
||||||
return Ok(false);
|
return Ok(ServerConfigSaveResult {
|
||||||
|
persisted: false,
|
||||||
|
generation: snapshot.generation(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = encode_server_config_blob(cfg, snapshot.seed.as_deref())?;
|
let data = encode_server_config_blob(cfg, snapshot.seed.as_deref())?;
|
||||||
if snapshot.raw.as_deref().is_some_and(|current| current == data.as_slice()) {
|
if snapshot.raw.as_deref().is_some_and(|current| current == data.as_slice()) {
|
||||||
debug!("server config bytes unchanged after encode, skip write");
|
debug!("server config bytes unchanged after encode, skip write");
|
||||||
return Ok(false);
|
return Ok(ServerConfigSaveResult {
|
||||||
|
persisted: false,
|
||||||
|
generation: snapshot.generation(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let http_preconditions = if snapshot.raw.is_some() {
|
let http_preconditions = if snapshot.raw.is_some() {
|
||||||
@@ -2152,19 +2403,22 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
save_config_with_opts(
|
snapshot.ensure_lock_held()?;
|
||||||
|
let object_info = save_config_with_opts_and_metadata(
|
||||||
api,
|
api,
|
||||||
&config_file,
|
&config_file,
|
||||||
data,
|
data,
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
no_lock: true,
|
|
||||||
http_preconditions: Some(http_preconditions),
|
http_preconditions: Some(http_preconditions),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(true)
|
Ok(ServerConfigSaveResult {
|
||||||
|
persisted: true,
|
||||||
|
generation: object_info.data_dir.filter(|generation| !generation.is_nil()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the server config while an upper layer holds the namespace write
|
/// Saves the server config while an upper layer holds the namespace write
|
||||||
@@ -2301,8 +2555,9 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, config_task_join_error,
|
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, config_task_join_error,
|
||||||
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
||||||
lookup_configs, read_config, read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate,
|
lookup_configs, new_and_save_server_config, read_config, read_config_preserve_empty, read_config_with_metadata,
|
||||||
read_server_config_snapshot, save_server_config, save_server_config_snapshot, server_config_path, storage_class_kvs_mut,
|
read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot,
|
||||||
|
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||||
};
|
};
|
||||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
@@ -2311,7 +2566,9 @@ mod tests {
|
|||||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||||
use crate::runtime::sources as runtime_sources;
|
use crate::runtime::sources as runtime_sources;
|
||||||
use crate::set_disk::SetDisks;
|
use crate::set_disk::SetDisks;
|
||||||
use crate::storage_api_contracts::{admin::StorageAdminApi, namespace::NamespaceLocking as _, range::HTTPRangeSpec};
|
use crate::storage_api_contracts::{
|
||||||
|
admin::StorageAdminApi, namespace::NamespaceLocking as _, object::HTTPPreconditions, range::HTTPRangeSpec,
|
||||||
|
};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rustfs_config::audit::{AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
|
use rustfs_config::audit::{AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
|
||||||
use rustfs_config::notify::{
|
use rustfs_config::notify::{
|
||||||
@@ -3104,6 +3361,85 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_heal_null_decodes_as_no_override_and_is_canonicalized_on_save() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{"standard":"","rrs":""},
|
||||||
|
"heal":null,
|
||||||
|
"future_root":{"mode":"keep"},
|
||||||
|
"openid":{"default":{
|
||||||
|
"config_url":"https://issuer.example/.well-known/openid-configuration",
|
||||||
|
"client_id":"console",
|
||||||
|
"client_secret":"oidc-secret",
|
||||||
|
"future_provider_control":"keep"
|
||||||
|
}},
|
||||||
|
"notify":{"webhook":{"primary":{
|
||||||
|
"enable":true,
|
||||||
|
"endpoint":"https://notify.example/hook",
|
||||||
|
"auth_token":"notify-secret",
|
||||||
|
"future_notify_control":"keep"
|
||||||
|
}}},
|
||||||
|
"logger":{"webhook":{"primary":{
|
||||||
|
"enable":true,
|
||||||
|
"endpoint":"https://audit.example/hook",
|
||||||
|
"auth_token":"audit-secret",
|
||||||
|
"future_audit_control":"keep"
|
||||||
|
}}}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let cfg = decode_server_config_blob(seed).expect("root heal null should mean no persisted override");
|
||||||
|
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_none());
|
||||||
|
assert!(!is_standard_object_server_config(seed));
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("legacy seed should canonicalize on an authorized save");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
|
||||||
|
assert!(value.get(HEAL_SUB_SYS).is_none());
|
||||||
|
assert_eq!(value["future_root"]["mode"].as_str(), Some("keep"));
|
||||||
|
assert_eq!(value["openid"]["default"]["client_secret"].as_str(), Some("oidc-secret"));
|
||||||
|
assert_eq!(value["openid"]["default"]["future_provider_control"].as_str(), Some("keep"));
|
||||||
|
assert_eq!(value["notify"]["webhook"]["primary"]["auth_token"].as_str(), Some("notify-secret"));
|
||||||
|
assert_eq!(value["notify"]["webhook"]["primary"]["future_notify_control"].as_str(), Some("keep"));
|
||||||
|
assert_eq!(value["logger"]["webhook"]["primary"]["auth_token"].as_str(), Some("audit-secret"));
|
||||||
|
assert_eq!(value["logger"]["webhook"]["primary"]["future_audit_control"].as_str(), Some("keep"));
|
||||||
|
assert!(is_standard_object_server_config(&encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_scalar_and_nested_null_config_shapes_remain_rejected() {
|
||||||
|
let invalid_sections = [
|
||||||
|
r#""scanner":null"#,
|
||||||
|
r#""heal":"""#,
|
||||||
|
r#""heal":false"#,
|
||||||
|
r#""heal":0"#,
|
||||||
|
r#""heal":{"default":null}"#,
|
||||||
|
r#""heal":{"_":null}"#,
|
||||||
|
r#""heal":{"bitrot_cycle":null}"#,
|
||||||
|
r#""heal":[{"key":"bitrot_cycle","value":null}]"#,
|
||||||
|
];
|
||||||
|
|
||||||
|
for section in invalid_sections {
|
||||||
|
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
|
||||||
|
let err = decode_server_config_blob(input.as_bytes()).expect_err("invalid scalar shape must remain rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("expected"),
|
||||||
|
"invalid section {section} returned an unrelated error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_heal_object_and_kvs_array_shapes_remain_accepted() {
|
||||||
|
let empty_object = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{}}"#;
|
||||||
|
let cfg = decode_server_config_blob(empty_object).expect("empty heal object should decode as no override");
|
||||||
|
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_none());
|
||||||
|
|
||||||
|
let kvs_array =
|
||||||
|
br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":[{"key":"bitrot_cycle","value":"off"}]}"#;
|
||||||
|
let cfg = decode_server_config_blob(kvs_array).expect("heal KVS array should decode");
|
||||||
|
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_update_preserves_unknown_root_and_oidc_provider_fields() {
|
fn scanner_update_preserves_unknown_root_and_oidc_provider_fields() {
|
||||||
let seed = br#"{
|
let seed = br#"{
|
||||||
@@ -3131,6 +3467,171 @@ mod tests {
|
|||||||
assert_eq!(value["openid"]["default"]["client_id"].as_str(), Some("console"));
|
assert_eq!(value["openid"]["default"]["client_id"].as_str(), Some("console"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storageclass_reset_removes_stale_inline_block_from_seed() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{
|
||||||
|
"standard":"EC:2",
|
||||||
|
"rrs":"EC:1",
|
||||||
|
"optimize":"availability",
|
||||||
|
"inline_block":"64KiB",
|
||||||
|
"future_storage_control":"keep"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&Config::new(), Some(seed)).expect("storageclass reset should encode");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||||
|
let storageclass = value["storageclass"].as_object().expect("storageclass object");
|
||||||
|
|
||||||
|
assert!(storageclass.get(crate::config::storageclass::INLINE_BLOCK).is_none());
|
||||||
|
assert_eq!(storageclass["future_storage_control"].as_str(), Some("keep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_update_preserves_unknown_fields_without_restoring_removed_instances() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{"standard":"","rrs":""},
|
||||||
|
"notify":{"webhook":{
|
||||||
|
"primary":{
|
||||||
|
"enable":true,
|
||||||
|
"endpoint":"https://notify.example/old",
|
||||||
|
"auth_token":"notify-secret",
|
||||||
|
"future_control":"keep"
|
||||||
|
},
|
||||||
|
"removed":{"enable":true,"endpoint":"https://notify.example/removed"},
|
||||||
|
"retained":{"enable":true,"endpoint":"https://notify.example/retained","future_control":"keep"},
|
||||||
|
"enable":{"enable":true,"endpoint":"https://notify.example/named-enable","future_control":"keep"}
|
||||||
|
}}
|
||||||
|
}"#;
|
||||||
|
let mut cfg = decode_server_config_blob(seed).expect("target seed should decode");
|
||||||
|
let webhook = cfg
|
||||||
|
.0
|
||||||
|
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||||
|
.expect("notify webhook subsystem should exist");
|
||||||
|
webhook
|
||||||
|
.get_mut("primary")
|
||||||
|
.expect("primary target should exist")
|
||||||
|
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||||
|
webhook.remove("removed");
|
||||||
|
webhook.remove("retained");
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("target update should encode");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||||
|
let webhook = value["notify"]["webhook"].as_object().expect("webhook section");
|
||||||
|
|
||||||
|
assert_eq!(webhook["primary"]["endpoint"].as_str(), Some("https://notify.example/new"));
|
||||||
|
assert_eq!(webhook["primary"]["auth_token"].as_str(), Some("notify-secret"));
|
||||||
|
assert_eq!(webhook["primary"]["future_control"].as_str(), Some("keep"));
|
||||||
|
assert!(webhook.get("removed").is_none());
|
||||||
|
assert_eq!(webhook["retained"]["future_control"].as_str(), Some("keep"));
|
||||||
|
assert!(webhook["retained"].get("enable").is_none());
|
||||||
|
assert!(webhook["retained"].get("endpoint").is_none());
|
||||||
|
assert_eq!(webhook["enable"]["endpoint"].as_str(), Some("https://notify.example/named-enable"));
|
||||||
|
assert_eq!(webhook["enable"]["future_control"].as_str(), Some("keep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shorthand_target_update_preserves_shape_and_unknown_nested_fields() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{"standard":"","rrs":""},
|
||||||
|
"notify":{"webhook":{
|
||||||
|
"enable":true,
|
||||||
|
"endpoint":"https://notify.example/old",
|
||||||
|
"future_control":{"endpoint":"leave-untouched","mode":"keep"}
|
||||||
|
}}
|
||||||
|
}"#;
|
||||||
|
let mut cfg = decode_server_config_blob(seed).expect("shorthand target should decode");
|
||||||
|
cfg.0
|
||||||
|
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||||
|
.and_then(|targets| targets.get_mut(DEFAULT_DELIMITER))
|
||||||
|
.expect("default webhook target should exist")
|
||||||
|
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("shorthand target update should encode");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||||
|
let webhook = value["notify"]["webhook"].as_object().expect("webhook shorthand object");
|
||||||
|
|
||||||
|
assert_eq!(webhook["endpoint"].as_str(), Some("https://notify.example/new"));
|
||||||
|
assert!(webhook.get("default").is_none());
|
||||||
|
assert_eq!(webhook["future_control"]["endpoint"].as_str(), Some("leave-untouched"));
|
||||||
|
assert_eq!(webhook["future_control"]["mode"].as_str(), Some("keep"));
|
||||||
|
let decoded = decode_server_config_blob(&encoded).expect("updated shorthand target should remain decodable");
|
||||||
|
assert_eq!(
|
||||||
|
decoded
|
||||||
|
.get_value(NOTIFY_WEBHOOK_SUB_SYS, DEFAULT_DELIMITER)
|
||||||
|
.expect("updated default webhook target")
|
||||||
|
.get(rustfs_config::WEBHOOK_ENDPOINT),
|
||||||
|
"https://notify.example/new"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_kvs_update_preserves_unknown_entries_and_attributes() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{"standard":"","rrs":""},
|
||||||
|
"notify":{"webhook":{"primary":[
|
||||||
|
{"key":"enable","value":"on","hidden_if_empty":false},
|
||||||
|
{"key":"endpoint","value":"https://notify.example/old","future_attribute":"keep-endpoint"},
|
||||||
|
{"key":"future_control","value":"keep","future_attribute":"keep-control"}
|
||||||
|
]}}
|
||||||
|
}"#;
|
||||||
|
let mut cfg = decode_server_config_blob(seed).expect("target KVS seed should decode");
|
||||||
|
cfg.0
|
||||||
|
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||||
|
.and_then(|targets| targets.get_mut("primary"))
|
||||||
|
.expect("primary webhook target should exist")
|
||||||
|
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("target KVS update should encode");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||||
|
let entries = value["notify"]["webhook"]["primary"]
|
||||||
|
.as_array()
|
||||||
|
.expect("target KVS shape should be preserved");
|
||||||
|
let endpoint = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["key"].as_str() == Some(rustfs_config::WEBHOOK_ENDPOINT))
|
||||||
|
.expect("endpoint entry should remain");
|
||||||
|
let future = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["key"].as_str() == Some("future_control"))
|
||||||
|
.expect("unknown target entry should remain");
|
||||||
|
|
||||||
|
assert_eq!(endpoint["value"].as_str(), Some("https://notify.example/new"));
|
||||||
|
assert_eq!(endpoint["future_attribute"].as_str(), Some("keep-endpoint"));
|
||||||
|
assert_eq!(future["value"].as_str(), Some("keep"));
|
||||||
|
assert_eq!(future["future_attribute"].as_str(), Some("keep-control"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_default_alias_is_canonicalized_without_losing_unknown_fields() {
|
||||||
|
let seed = br#"{
|
||||||
|
"version":"33",
|
||||||
|
"storageclass":{"standard":"","rrs":""},
|
||||||
|
"notify":{"webhook":{
|
||||||
|
"_":{"enable":false,"endpoint":"https://notify.example/alias","future_alias":"keep"},
|
||||||
|
"default":{"enable":true,"endpoint":"https://notify.example/default","future_default":"keep"}
|
||||||
|
}}
|
||||||
|
}"#;
|
||||||
|
let cfg = decode_server_config_blob(seed).expect("dual default aliases should decode");
|
||||||
|
let expected_endpoint = cfg
|
||||||
|
.get_value(NOTIFY_WEBHOOK_SUB_SYS, DEFAULT_DELIMITER)
|
||||||
|
.expect("default webhook target should exist")
|
||||||
|
.get(rustfs_config::WEBHOOK_ENDPOINT);
|
||||||
|
|
||||||
|
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("default alias should canonicalize");
|
||||||
|
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||||
|
let webhook = value["notify"]["webhook"].as_object().expect("webhook section");
|
||||||
|
|
||||||
|
assert!(webhook.get(DEFAULT_DELIMITER).is_none());
|
||||||
|
assert_eq!(webhook["default"]["endpoint"].as_str(), Some(expected_endpoint.as_str()));
|
||||||
|
assert_eq!(webhook["default"]["future_alias"].as_str(), Some("keep"));
|
||||||
|
assert_eq!(webhook["default"]["future_default"].as_str(), Some("keep"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_scanner_config_changes_are_semantically_significant() {
|
fn test_scanner_config_changes_are_semantically_significant() {
|
||||||
let baseline = Config::new();
|
let baseline = Config::new();
|
||||||
@@ -4124,6 +4625,7 @@ mod tests {
|
|||||||
|
|
||||||
/// What reads of the config object currently return.
|
/// What reads of the config object currently return.
|
||||||
enum RecoveryReadState {
|
enum RecoveryReadState {
|
||||||
|
Missing,
|
||||||
Blob(Vec<u8>),
|
Blob(Vec<u8>),
|
||||||
QuorumError,
|
QuorumError,
|
||||||
}
|
}
|
||||||
@@ -4136,6 +4638,7 @@ mod tests {
|
|||||||
heal_calls: AtomicUsize,
|
heal_calls: AtomicUsize,
|
||||||
write_calls: AtomicUsize,
|
write_calls: AtomicUsize,
|
||||||
last_put_no_lock: AtomicBool,
|
last_put_no_lock: AtomicBool,
|
||||||
|
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
|
||||||
revision: AtomicUsize,
|
revision: AtomicUsize,
|
||||||
drive_counts: Vec<usize>,
|
drive_counts: Vec<usize>,
|
||||||
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
||||||
@@ -4150,6 +4653,7 @@ mod tests {
|
|||||||
heal_calls: AtomicUsize::new(0),
|
heal_calls: AtomicUsize::new(0),
|
||||||
write_calls: AtomicUsize::new(0),
|
write_calls: AtomicUsize::new(0),
|
||||||
last_put_no_lock: AtomicBool::new(false),
|
last_put_no_lock: AtomicBool::new(false),
|
||||||
|
last_put_preconditions: Mutex::new(None),
|
||||||
revision: AtomicUsize::new(1),
|
revision: AtomicUsize::new(1),
|
||||||
drive_counts: vec![2],
|
drive_counts: vec![2],
|
||||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||||
@@ -4206,6 +4710,7 @@ mod tests {
|
|||||||
_opts: &ObjectOptions,
|
_opts: &ObjectOptions,
|
||||||
) -> Result<GetObjectReader> {
|
) -> Result<GetObjectReader> {
|
||||||
let data = match &*self.state.lock().expect("state lock poisoned") {
|
let data = match &*self.state.lock().expect("state lock poisoned") {
|
||||||
|
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
|
||||||
RecoveryReadState::Blob(data) => data.clone(),
|
RecoveryReadState::Blob(data) => data.clone(),
|
||||||
RecoveryReadState::QuorumError => return Err(Error::ErasureReadQuorum),
|
RecoveryReadState::QuorumError => return Err(Error::ErasureReadQuorum),
|
||||||
};
|
};
|
||||||
@@ -4213,6 +4718,9 @@ mod tests {
|
|||||||
size: data.len() as i64,
|
size: data.len() as i64,
|
||||||
actual_size: data.len() as i64,
|
actual_size: data.len() as i64,
|
||||||
etag: Some(format!("config-{}", self.revision.load(Ordering::SeqCst))),
|
etag: Some(format!("config-{}", self.revision.load(Ordering::SeqCst))),
|
||||||
|
data_dir: Some(uuid::Uuid::from_u128(
|
||||||
|
u128::try_from(self.revision.load(Ordering::SeqCst)).expect("test revision should fit in u128"),
|
||||||
|
)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
Ok(GetObjectReader {
|
Ok(GetObjectReader {
|
||||||
@@ -4231,15 +4739,19 @@ mod tests {
|
|||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
) -> Result<ObjectInfo> {
|
) -> Result<ObjectInfo> {
|
||||||
let current_etag = format!("config-{}", self.revision.load(Ordering::SeqCst));
|
let current_etag = format!("config-{}", self.revision.load(Ordering::SeqCst));
|
||||||
|
let object_exists = matches!(&*self.state.lock().expect("state lock poisoned"), RecoveryReadState::Blob(_));
|
||||||
if let Some(preconditions) = &opts.http_preconditions
|
if let Some(preconditions) = &opts.http_preconditions
|
||||||
&& (preconditions.if_match_value().is_some_and(|etag| etag != current_etag)
|
&& (preconditions
|
||||||
|| preconditions.if_none_match_value() == Some("*"))
|
.if_match_value()
|
||||||
|
.is_some_and(|etag| !object_exists || etag != current_etag)
|
||||||
|
|| (object_exists && preconditions.if_none_match_value() == Some("*")))
|
||||||
{
|
{
|
||||||
return Err(Error::PreconditionFailed);
|
return Err(Error::PreconditionFailed);
|
||||||
}
|
}
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
data.stream.read_to_end(&mut body).await?;
|
data.stream.read_to_end(&mut body).await?;
|
||||||
self.last_put_no_lock.store(opts.no_lock, Ordering::SeqCst);
|
self.last_put_no_lock.store(opts.no_lock, Ordering::SeqCst);
|
||||||
|
*self.last_put_preconditions.lock().expect("preconditions lock poisoned") = opts.http_preconditions.clone();
|
||||||
self.write_calls.fetch_add(1, Ordering::SeqCst);
|
self.write_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
*self.state.lock().expect("state lock poisoned") = RecoveryReadState::Blob(body.clone());
|
*self.state.lock().expect("state lock poisoned") = RecoveryReadState::Blob(body.clone());
|
||||||
let revision = self.revision.fetch_add(1, Ordering::SeqCst) + 1;
|
let revision = self.revision.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
@@ -4247,6 +4759,7 @@ mod tests {
|
|||||||
size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
||||||
actual_size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
actual_size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
||||||
etag: Some(format!("config-{revision}")),
|
etag: Some(format!("config-{revision}")),
|
||||||
|
data_dir: Some(uuid::Uuid::from_u128(u128::try_from(revision).expect("test revision should fit in u128"))),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -4284,10 +4797,18 @@ mod tests {
|
|||||||
.expect("scanner-only config change should be persisted");
|
.expect("scanner-only config change should be persisted");
|
||||||
|
|
||||||
assert_eq!(store.write_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(store.write_calls.load(Ordering::SeqCst), 1);
|
||||||
assert!(store.last_put_no_lock.load(Ordering::SeqCst));
|
assert!(!store.last_put_no_lock.load(Ordering::SeqCst));
|
||||||
|
let preconditions = store
|
||||||
|
.last_put_preconditions
|
||||||
|
.lock()
|
||||||
|
.expect("preconditions lock poisoned")
|
||||||
|
.clone()
|
||||||
|
.expect("existing config update must be conditional");
|
||||||
|
assert_eq!(preconditions.if_match_value(), Some("config-1"));
|
||||||
|
assert_eq!(preconditions.if_none_match_value(), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.lock_resources.lock().expect("lock resources mutex poisoned").as_slice(),
|
store.lock_resources.lock().expect("lock resources mutex poisoned").as_slice(),
|
||||||
&[server_config_path()]
|
&[server_config_transaction_lock_path()]
|
||||||
);
|
);
|
||||||
let decoded = read_config_without_migrate(store)
|
let decoded = read_config_without_migrate(store)
|
||||||
.await
|
.await
|
||||||
@@ -4301,6 +4822,73 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn server_config_snapshot_save_returns_committed_generation() {
|
||||||
|
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
||||||
|
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
|
||||||
|
let snapshot = read_server_config_snapshot(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("server config snapshot");
|
||||||
|
|
||||||
|
let result = save_server_config_snapshot_with_generation(store, &config_with_scanner_cycle("61"), &snapshot)
|
||||||
|
.await
|
||||||
|
.expect("conditional config save");
|
||||||
|
|
||||||
|
assert!(result.persisted());
|
||||||
|
assert_eq!(result.generation(), Some(uuid::Uuid::from_u128(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_server_config_is_created_with_if_none_match() {
|
||||||
|
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
|
||||||
|
let cfg = config_with_scanner_cycle("61");
|
||||||
|
|
||||||
|
save_server_config(store.clone(), &cfg)
|
||||||
|
.await
|
||||||
|
.expect("missing config should be created conditionally");
|
||||||
|
|
||||||
|
assert_eq!(store.write_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(!store.last_put_no_lock.load(Ordering::SeqCst));
|
||||||
|
let preconditions = store
|
||||||
|
.last_put_preconditions
|
||||||
|
.lock()
|
||||||
|
.expect("preconditions lock poisoned")
|
||||||
|
.clone()
|
||||||
|
.expect("missing config create must be conditional");
|
||||||
|
assert_eq!(preconditions.if_match_value(), None);
|
||||||
|
assert_eq!(preconditions.if_none_match_value(), Some("*"));
|
||||||
|
let persisted = read_config_without_migrate(store)
|
||||||
|
.await
|
||||||
|
.expect("created config should reload");
|
||||||
|
assert_eq!(
|
||||||
|
persisted
|
||||||
|
.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER)
|
||||||
|
.expect("persisted scanner config")
|
||||||
|
.get(SCANNER_CYCLE),
|
||||||
|
"61"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_config_initialization_recheck_preserves_concurrent_config() {
|
||||||
|
let existing = config_with_scanner_cycle("73");
|
||||||
|
let baseline = encode_server_config_blob(&existing, None).expect("existing config should encode");
|
||||||
|
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
|
||||||
|
|
||||||
|
let observed = new_and_save_server_config(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("initialization recheck should return the config created by another writer");
|
||||||
|
|
||||||
|
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(
|
||||||
|
observed
|
||||||
|
.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER)
|
||||||
|
.expect("concurrent scanner config")
|
||||||
|
.get(SCANNER_CYCLE),
|
||||||
|
"73"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_server_config_snapshot_cannot_overwrite_newer_update() {
|
async fn stale_server_config_snapshot_cannot_overwrite_newer_update() {
|
||||||
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
||||||
@@ -4357,7 +4945,7 @@ mod tests {
|
|||||||
let lock = rustfs_lock::NamespaceLock::new("server-config-lease-loss".to_string(), client.clone());
|
let lock = rustfs_lock::NamespaceLock::new("server-config-lease-loss".to_string(), client.clone());
|
||||||
let guard = lock
|
let guard = lock
|
||||||
.lock_guard(
|
.lock_guard(
|
||||||
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, server_config_path()),
|
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, server_config_transaction_lock_path()),
|
||||||
"server-config-lease-loss",
|
"server-config-lease-loss",
|
||||||
std::time::Duration::from_secs(1),
|
std::time::Duration::from_secs(1),
|
||||||
std::time::Duration::from_millis(120),
|
std::time::Duration::from_millis(120),
|
||||||
@@ -4372,6 +4960,7 @@ mod tests {
|
|||||||
raw: Some(baseline.clone()),
|
raw: Some(baseline.clone()),
|
||||||
seed: None,
|
seed: None,
|
||||||
etag: Some("config-0".to_string()),
|
etag: Some("config-0".to_string()),
|
||||||
|
generation: Some(uuid::Uuid::from_u128(1)),
|
||||||
_local_guard: local_guard,
|
_local_guard: local_guard,
|
||||||
_guard: guard,
|
_guard: guard,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -339,7 +339,9 @@ impl Sets {
|
|||||||
futures.push(set.delete_object(bucket, object, opt.clone()));
|
futures.push(set.delete_object(bucket, object, opt.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let _results = join_all(futures).await;
|
if let Some(err) = join_all(futures).await.into_iter().find_map(Result::err) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -615,7 +617,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
|
|||||||
self.get_disks_by_key(object).delete_object(bucket, object, opts).await
|
self.get_disks_by_key(object).delete_object(bucket, object, opts).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self, objects, opts))]
|
||||||
async fn delete_objects(
|
async fn delete_objects(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5078,7 +5078,7 @@ impl LocalDisk {
|
|||||||
Ok((buf, mtime))
|
Ok((buf, mtime))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "LocalDisk")]
|
||||||
async fn read_metadata_with_dmtime(&self, file_path: impl AsRef<Path>) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
|
async fn read_metadata_with_dmtime(&self, file_path: impl AsRef<Path>) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
|
||||||
check_path_length(file_path.as_ref().to_string_lossy().as_ref())?;
|
check_path_length(file_path.as_ref().to_string_lossy().as_ref())?;
|
||||||
|
|
||||||
@@ -5121,7 +5121,7 @@ impl LocalDisk {
|
|||||||
Ok((data, modtime))
|
Ok((data, modtime))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "LocalDisk")]
|
||||||
async fn read_all_data(&self, volume: &str, volume_dir: impl AsRef<Path>, file_path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
async fn read_all_data(&self, volume: &str, volume_dir: impl AsRef<Path>, file_path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||||
// TODO: timeout support
|
// TODO: timeout support
|
||||||
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir, file_path).await?;
|
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir, file_path).await?;
|
||||||
@@ -6641,6 +6641,49 @@ impl LocalDisk {
|
|||||||
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||||
restore_delete_rollback_after_error(object_dir, &xl_path, Some(rollback_dir), volume, object, stage, err).await
|
restore_delete_rollback_after_error(object_dir, &xl_path, Some(rollback_dir), volume, object, stage, err).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute every deferred data-dir deletion pending on `volume` right now,
|
||||||
|
/// even while snapshot leases are still held. Bucket deletion requires it:
|
||||||
|
/// a streaming reader defers the physical cleanup of an already-deleted
|
||||||
|
/// version, and a non-force `delete_volume` would otherwise fail closed
|
||||||
|
/// with `VolumeNotEmpty` on those remnants even though the bucket is
|
||||||
|
/// logically empty. The still-active readers keep their open descriptors;
|
||||||
|
/// only path-based reopens observe the removal.
|
||||||
|
async fn settle_pending_snapshot_deletes(&self, volume: &str) {
|
||||||
|
let pending: Vec<(SnapshotLeaseKey, DeleteOptions)> = {
|
||||||
|
let mut registry = self.snapshot_leases.lock().await;
|
||||||
|
registry
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|(key, entry)| key.volume == volume && !entry.deleting && entry.pending_delete.is_some())
|
||||||
|
.map(|(key, entry)| {
|
||||||
|
entry.deleting = true;
|
||||||
|
(key.clone(), entry.pending_delete.clone().expect("filtered on Some"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, opts) in pending {
|
||||||
|
let result = self.delete_unleased(&key.volume, &key.path, &opts).await;
|
||||||
|
let mut registry = self.snapshot_leases.lock().await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
registry.entries.remove(&key);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(entry) = registry.entries.get_mut(&key) {
|
||||||
|
entry.deleting = false;
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
volume = %key.volume,
|
||||||
|
path = %key.path,
|
||||||
|
error = %err,
|
||||||
|
"failed to settle deferred data-dir deletion before volume removal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -9122,6 +9165,14 @@ impl DiskAPI for LocalDisk {
|
|||||||
let p = self.get_bucket_path(volume)?;
|
let p = self.get_bucket_path(volume)?;
|
||||||
let _volume_mutation_guard = os::disk_volume_mutation_lock(&self.root, volume).write_owned().await;
|
let _volume_mutation_guard = os::disk_volume_mutation_lock(&self.root, volume).write_owned().await;
|
||||||
|
|
||||||
|
// A streaming reader's snapshot lease defers the physical cleanup of
|
||||||
|
// data dirs whose version delete already committed. Those remnants are
|
||||||
|
// logically deleted, so run the parked cleanups now instead of letting
|
||||||
|
// the non-force removal below fail closed on them (the s3-tests SSE-C
|
||||||
|
// teardown races exactly this way: DeleteObjects, then DeleteBucket
|
||||||
|
// while an abandoned GET body still pins the lease).
|
||||||
|
self.settle_pending_snapshot_deletes(volume).await;
|
||||||
|
|
||||||
// Non-force removes empty directory remnants children-first with
|
// Non-force removes empty directory remnants children-first with
|
||||||
// non-recursive rmdir calls. A file that exists during the scan, or
|
// non-recursive rmdir calls. A file that exists during the scan, or
|
||||||
// appears before its parent is removed, fails closed with
|
// appears before its parent is removed, fails closed with
|
||||||
@@ -15981,6 +16032,74 @@ mod test {
|
|||||||
assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound)));
|
assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_volume_settles_lease_deferred_cleanup() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let root_dir = tempdir().expect("temp dir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
||||||
|
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||||
|
let volume = "snapshot-lease-bucket-delete";
|
||||||
|
let object = "multipart_enc";
|
||||||
|
let version_id = Uuid::new_v4();
|
||||||
|
let data_dir = Uuid::new_v4();
|
||||||
|
let rollback_dir = Uuid::new_v4();
|
||||||
|
let data_path = path_join_buf(&[object, &data_dir.to_string()]);
|
||||||
|
let part = path_join_buf(&[&data_path, "part.1"]);
|
||||||
|
ensure_test_volume(&disk, volume).await;
|
||||||
|
disk.write_all(volume, &part, Bytes::from_static(b"payload"))
|
||||||
|
.await
|
||||||
|
.expect("shard should be written");
|
||||||
|
let fi = test_file_info(object, version_id, Some(data_dir), None);
|
||||||
|
disk.write_all(volume, &path_join_buf(&[object, STORAGE_FORMAT_FILE]), test_meta(fi.clone()).into())
|
||||||
|
.await
|
||||||
|
.expect("metadata should be written");
|
||||||
|
|
||||||
|
// An abandoned streaming GET pins the data dir with a snapshot lease.
|
||||||
|
let snapshot = disk
|
||||||
|
.acquire_snapshot_lease(volume, &data_path)
|
||||||
|
.await
|
||||||
|
.expect("snapshot lease should be acquired");
|
||||||
|
disk.delete_version(
|
||||||
|
volume,
|
||||||
|
object,
|
||||||
|
fi.clone(),
|
||||||
|
false,
|
||||||
|
DeleteOptions {
|
||||||
|
old_data_dir: Some(rollback_dir),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("version delete should commit metadata");
|
||||||
|
disk.delete(
|
||||||
|
volume,
|
||||||
|
&format!("{object}/{rollback_dir}"),
|
||||||
|
DeleteOptions {
|
||||||
|
recursive: true,
|
||||||
|
immediate: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("version delete should schedule physical cleanup");
|
||||||
|
|
||||||
|
// The bucket is logically empty; a non-force volume delete must settle
|
||||||
|
// the deferred data-dir cleanup instead of failing with VolumeNotEmpty.
|
||||||
|
disk.delete_volume(volume, false)
|
||||||
|
.await
|
||||||
|
.expect("bucket delete must not observe lease-deferred remnants");
|
||||||
|
assert!(matches!(
|
||||||
|
disk.read_all(volume, &part).await,
|
||||||
|
Err(DiskError::FileNotFound | DiskError::VolumeNotFound)
|
||||||
|
));
|
||||||
|
|
||||||
|
// The late lease release finds nothing pending and stays idempotent.
|
||||||
|
disk.release_snapshot_lease(volume, &data_path, snapshot)
|
||||||
|
.await
|
||||||
|
.expect("releasing the lease after bucket deletion should be a no-op");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn version_delete_cleanup_intent_survives_local_disk_restart() {
|
async fn version_delete_cleanup_intent_survives_local_disk_restart() {
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ impl Disk {
|
|||||||
Disk::Remote(remote_disk) => remote_disk.set_disk_id(id).await,
|
Disk::Remote(remote_disk) => remote_disk.set_disk_id(id).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn local_health_tracker_epoch_for_reconnect(&self) -> Option<disk_store::ReconnectDiskHealthState> {
|
||||||
|
match self {
|
||||||
|
Disk::Local(local_disk) => Some(local_disk.health_tracker_epoch_for_reconnect()),
|
||||||
|
Disk::Remote(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cached_disk_id(&self) -> Option<Uuid> {
|
||||||
|
match self {
|
||||||
|
Disk::Local(local_disk) => local_disk.get_current_disk_id().await,
|
||||||
|
Disk::Remote(remote_disk) => remote_disk.get_disk_id().await.ok().flatten(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -606,6 +620,13 @@ impl Disk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn metrics_snapshot(&self) -> Option<DiskMetrics> {
|
||||||
|
match self {
|
||||||
|
Disk::Local(local_disk) => Some(local_disk.metrics_snapshot()),
|
||||||
|
Disk::Remote(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn health_check_enabled_for_test(&self) -> bool {
|
pub fn health_check_enabled_for_test(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
@@ -664,9 +685,21 @@ impl Disk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
|
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
|
||||||
|
new_disk_with_health_tracker(ep, opt, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn new_disk_with_health_tracker(
|
||||||
|
ep: &Endpoint,
|
||||||
|
opt: &DiskOption,
|
||||||
|
reconnect: Option<disk_store::ReconnectDiskHealthState>,
|
||||||
|
) -> Result<DiskStore> {
|
||||||
if ep.is_local {
|
if ep.is_local {
|
||||||
let s = LocalDisk::new(ep, opt.cleanup).await?;
|
let s = LocalDisk::new(ep, opt.cleanup).await?;
|
||||||
Ok(Arc::new(Disk::Local(Box::new(LocalDiskWrapper::new(Arc::new(s), opt.health_check)))))
|
Ok(Arc::new(Disk::Local(Box::new(LocalDiskWrapper::new_with_reconnect_state(
|
||||||
|
Arc::new(s),
|
||||||
|
opt.health_check,
|
||||||
|
reconnect,
|
||||||
|
)))))
|
||||||
} else {
|
} else {
|
||||||
let data_transport = build_internode_data_transport_from_env();
|
let data_transport = build_internode_data_transport_from_env();
|
||||||
let remote_disk = RemoteDisk::new(ep, opt, data_transport?).await?;
|
let remote_disk = RemoteDisk::new(ep, opt, data_transport?).await?;
|
||||||
|
|||||||
@@ -677,21 +677,22 @@ pub(crate) type ExistingBaseDirectoryGuard = ();
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn lock_windows_directory(path: &Path) -> io::Result<winapi_util::Handle> {
|
fn lock_windows_directory(path: &Path) -> io::Result<winapi_util::Handle> {
|
||||||
use std::os::windows::fs::OpenOptionsExt;
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
|
FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||||
|
FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||||
|
};
|
||||||
|
|
||||||
const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10;
|
// Relative child publication requires write sharing on every guarded
|
||||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
// ancestor. Omitting delete sharing still prevents any directory in the
|
||||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
// resolved path from being renamed or removed before the commit finishes.
|
||||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
|
||||||
const FILE_SHARE_READ: u32 = 0x1;
|
|
||||||
|
|
||||||
let file = std::fs::OpenOptions::new()
|
let file = std::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.share_mode(FILE_SHARE_READ)
|
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
|
||||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||||
.open(path)?;
|
.open(path)?;
|
||||||
let handle = winapi_util::Handle::from_file(file);
|
let handle = winapi_util::Handle::from_file(file);
|
||||||
let info = winapi_util::file::information(&handle)?;
|
let info = winapi_util::file::information(&handle)?;
|
||||||
if info.file_attributes() & FILE_ATTRIBUTE_DIRECTORY == 0
|
if info.file_attributes() & u64::from(FILE_ATTRIBUTE_DIRECTORY) == 0
|
||||||
|| info.file_attributes() & u64::from(FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
|| info.file_attributes() & u64::from(FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||||
{
|
{
|
||||||
return Err(io::Error::from(io::ErrorKind::NotADirectory));
|
return Err(io::Error::from(io::ErrorKind::NotADirectory));
|
||||||
@@ -1223,23 +1224,53 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_parent_guard_blocks_base_and_intermediate_replacement() {
|
fn windows_parent_guard_blocks_parent_replacement() {
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
let temp_dir = tempdir().expect("create temp dir");
|
||||||
let base = temp_dir.path().join("bucket");
|
let base = temp_dir.path().join("bucket");
|
||||||
std::fs::create_dir(&base).expect("create destination base");
|
std::fs::create_dir(&base).expect("create destination base");
|
||||||
let parent = base.join("object").join("nested");
|
let parent = base.join("object").join("nested");
|
||||||
let guard = mkdir_all_below_existing_base_std(&parent, &base).expect("create and lock destination parents");
|
let guard = mkdir_all_below_existing_base_std(&parent, &base).expect("create and lock destination parents");
|
||||||
|
|
||||||
|
std::fs::read_dir(&parent).expect("the locked parent must remain readable");
|
||||||
std::fs::rename(&base, temp_dir.path().join("replacement-base"))
|
std::fs::rename(&base, temp_dir.path().join("replacement-base"))
|
||||||
.expect_err("the locked base must not be replaceable before commit");
|
.expect_err("the locked base must not be replaceable before commit");
|
||||||
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
||||||
.expect_err("a locked intermediate directory must not be replaceable before commit");
|
.expect_err("a locked intermediate directory must not be replaceable before commit");
|
||||||
|
std::fs::rename(&parent, base.join("replacement-parent"))
|
||||||
|
.expect_err("the locked destination parent must not be replaceable before commit");
|
||||||
|
assert!(parent.is_dir(), "failed replacement must leave the guarded parent in place");
|
||||||
|
|
||||||
drop(guard);
|
drop(guard);
|
||||||
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
||||||
.expect("replacement should succeed after the commit guard is released");
|
.expect("replacement should succeed after the commit guard is released");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn windows_guarded_parent_allows_same_and_descendant_publication() {
|
||||||
|
let temp_dir = tempdir().expect("create temp dir");
|
||||||
|
let base = temp_dir.path().join("bucket");
|
||||||
|
let parent = base.join("object");
|
||||||
|
std::fs::create_dir_all(&parent).expect("create destination parent");
|
||||||
|
let _guard = mkdir_all_below_existing_base_std(&parent, &base).expect("guard destination parent");
|
||||||
|
let first_src = temp_dir.path().join("first-stage");
|
||||||
|
let second_src = temp_dir.path().join("second-stage");
|
||||||
|
std::fs::write(&first_src, b"first").expect("write first source");
|
||||||
|
std::fs::write(&second_src, b"second").expect("write second source");
|
||||||
|
|
||||||
|
rename_all(&first_src, parent.join("first"), &base)
|
||||||
|
.await
|
||||||
|
.expect("same-parent rename must succeed while a guard is held");
|
||||||
|
rename_all(&second_src, parent.join("nested").join("second"), &base)
|
||||||
|
.await
|
||||||
|
.expect("descendant-parent rename must succeed while an ancestor guard is held");
|
||||||
|
assert_eq!(std::fs::read(parent.join("first")).expect("read first destination"), b"first");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(parent.join("nested").join("second")).expect("read second destination"),
|
||||||
|
b"second"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rename_parent_creation_rejects_symlinked_base() {
|
async fn rename_parent_creation_rejects_symlinked_base() {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ where
|
|||||||
/// or `out` is larger than one shard. On error `out`'s contents are
|
/// or `out` is larger than one shard. On error `out`'s contents are
|
||||||
/// unspecified but never contain bytes that failed the hash check — the copy
|
/// unspecified but never contain bytes that failed the hash check — the copy
|
||||||
/// happens only after verification.
|
/// happens only after verification.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "BitrotReader")]
|
||||||
pub async fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
pub async fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||||
let want = out.len();
|
let want = out.len();
|
||||||
self.begin_read(want)?;
|
self.begin_read(want)?;
|
||||||
@@ -303,7 +303,7 @@ where
|
|||||||
|
|
||||||
/// Write a (hash+data) block. Returns the number of data bytes written.
|
/// Write a (hash+data) block. Returns the number of data bytes written.
|
||||||
/// Returns an error if called after a short write or if data exceeds shard_size.
|
/// Returns an error if called after a short write or if data exceeds shard_size.
|
||||||
#[hotpath::measure(label = "BitrotWriter::write")]
|
#[hotpath::measure(label = "BitrotWriter::write", impl_type = "BitrotWriter")]
|
||||||
pub async fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
pub async fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
if buf.is_empty() {
|
if buf.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ impl<R> ParallelReader<R>
|
|||||||
where
|
where
|
||||||
R: crate::erasure::coding::ShardSource,
|
R: crate::erasure::coding::ShardSource,
|
||||||
{
|
{
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "ParallelReader")]
|
||||||
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
||||||
// On the reconstruction-verifying GET path, read every live shard reader
|
// On the reconstruction-verifying GET path, read every live shard reader
|
||||||
// in lockstep so all readers advance one block per stripe and stay
|
// in lockstep so all readers advance one block per stripe and stay
|
||||||
@@ -1505,7 +1505,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Erasure {
|
impl Erasure {
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub async fn decode<W, R>(
|
pub async fn decode<W, R>(
|
||||||
&self,
|
&self,
|
||||||
writer: &mut W,
|
writer: &mut W,
|
||||||
@@ -1645,15 +1645,28 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_EMIT, emit_stage_start);
|
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_EMIT, emit_stage_start);
|
||||||
error!(
|
let reason = classify_io_error(&e);
|
||||||
block_offset,
|
if reason == GetObjectFailureReason::DownstreamClosed {
|
||||||
block_length,
|
debug!(
|
||||||
bytes_written = *written,
|
block_offset,
|
||||||
stage = GET_STAGE_EMIT,
|
block_length,
|
||||||
reason = classify_io_error(&e).as_str(),
|
bytes_written = *written,
|
||||||
error = ?e,
|
stage = GET_STAGE_EMIT,
|
||||||
"Erasure decode failed to emit reconstructed data"
|
reason = reason.as_str(),
|
||||||
);
|
error = ?e,
|
||||||
|
"Erasure decode stopped after downstream closed"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error!(
|
||||||
|
block_offset,
|
||||||
|
block_length,
|
||||||
|
bytes_written = *written,
|
||||||
|
stage = GET_STAGE_EMIT,
|
||||||
|
reason = reason.as_str(),
|
||||||
|
error = ?e,
|
||||||
|
"Erasure decode failed to emit reconstructed data"
|
||||||
|
);
|
||||||
|
}
|
||||||
*ret_err = Some(e);
|
*ret_err = Some(e);
|
||||||
return StripeFlow::Stop;
|
return StripeFlow::Stop;
|
||||||
}
|
}
|
||||||
@@ -1945,7 +1958,7 @@ mod tests {
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
};
|
};
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
@@ -2120,6 +2133,59 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DownstreamClosedWriter;
|
||||||
|
|
||||||
|
impl AsyncWrite for DownstreamClosedWriter {
|
||||||
|
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||||
|
Poll::Ready(Err(crate::diagnostics::get::mark_get_object_downstream_closed(io::Error::new(
|
||||||
|
ErrorKind::BrokenPipe,
|
||||||
|
"injected downstream close",
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
struct CapturedLogWriter(Arc<Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
impl CapturedLogs {
|
||||||
|
fn contents(&self) -> String {
|
||||||
|
String::from_utf8(self.0.lock().expect("captured logs mutex should not be poisoned").clone())
|
||||||
|
.expect("captured logs should be valid UTF-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::io::Write for CapturedLogWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.expect("captured logs mutex should not be poisoned")
|
||||||
|
.extend_from_slice(buf);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
|
||||||
|
type Writer = CapturedLogWriter;
|
||||||
|
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
CapturedLogWriter(Arc::clone(&self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_reader_constructor_variants_preserve_read_cost_and_verification_flags() {
|
fn parallel_reader_constructor_variants_preserve_read_cost_and_verification_flags() {
|
||||||
let erasure = Erasure::new(2, 1, 64);
|
let erasure = Erasure::new(2, 1, 64);
|
||||||
@@ -2215,6 +2281,47 @@ mod tests {
|
|||||||
assert_eq!(err.to_string(), "injected emit failure");
|
assert_eq!(err.to_string(), "injected emit failure");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn erasure_decode_logs_reconstructed_downstream_close_at_debug() {
|
||||||
|
let logs = CapturedLogs::default();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_writer(logs.clone())
|
||||||
|
.with_ansi(false)
|
||||||
|
.without_time()
|
||||||
|
.finish();
|
||||||
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
|
|
||||||
|
let erasure = Erasure::new(2, 1, 64);
|
||||||
|
let data: Vec<u8> = (0..64).collect();
|
||||||
|
let shard_size = erasure.shard_size();
|
||||||
|
let encoded = erasure.encode_data(&data).expect("test data should encode");
|
||||||
|
let readers = vec![
|
||||||
|
None,
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
Cursor::new(encoded[1].to_vec()),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
Cursor::new(encoded[2].to_vec()),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut writer = DownstreamClosedWriter;
|
||||||
|
let (written, err) = erasure.decode(&mut writer, readers, 0, data.len(), data.len()).await;
|
||||||
|
|
||||||
|
assert_eq!(written, 0);
|
||||||
|
assert_eq!(err.expect("downstream close must still terminate the GET").kind(), ErrorKind::BrokenPipe);
|
||||||
|
let captured = logs.contents();
|
||||||
|
assert!(captured.contains("Erasure decode stopped after downstream closed"));
|
||||||
|
assert!(!captured.contains("Erasure decode failed to emit reconstructed data"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_erasure_decode_rejects_reader_count_and_range_overflow() {
|
async fn test_erasure_decode_rejects_reader_count_and_range_overflow() {
|
||||||
let erasure = Erasure::new(2, 1, 64);
|
let erasure = Erasure::new(2, 1, 64);
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ use std::vec;
|
|||||||
use tokio::io::AsyncRead;
|
use tokio::io::AsyncRead;
|
||||||
use tokio::runtime::RuntimeFlavor;
|
use tokio::runtime::RuntimeFlavor;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::{JoinError, JoinHandle};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
/// Queue-capacity input for encoded blocks awaiting shard writers; it is not a
|
||||||
|
/// per-PUT or process-RSS memory limit.
|
||||||
const ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: &str = "RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES";
|
const ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: &str = "RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES";
|
||||||
const ENV_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS: &str = "RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS";
|
const ENV_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS: &str = "RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS";
|
||||||
const ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST: &str = "RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST";
|
const ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST: &str = "RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST";
|
||||||
@@ -87,6 +90,33 @@ fn use_bytesmut_ingest() -> bool {
|
|||||||
rustfs_utils::get_env_bool(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST)
|
rustfs_utils::get_env_bool(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the encoder producer scoped to its parent future. Tokio detaches a
|
||||||
|
/// task when its `JoinHandle` is dropped, so the producer must be aborted when
|
||||||
|
/// an upload is cancelled before the encode pipeline finishes.
|
||||||
|
struct AbortOnDropTask<T>(JoinHandle<T>);
|
||||||
|
|
||||||
|
impl<T> AbortOnDropTask<T> {
|
||||||
|
fn new(task: JoinHandle<T>) -> Self {
|
||||||
|
Self(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn abort_and_wait(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
let _ = (&mut self.0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join(&mut self) -> Result<T, JoinError> {
|
||||||
|
(&mut self.0).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Drop for AbortOnDropTask<T> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read up to `limit` bytes into `buf`'s uninitialized spare capacity, appending after its
|
/// Read up to `limit` bytes into `buf`'s uninitialized spare capacity, appending after its
|
||||||
/// current length, and distinguish a clean EOF from a short read.
|
/// current length, and distinguish a clean EOF from a short read.
|
||||||
///
|
///
|
||||||
@@ -133,22 +163,65 @@ fn queued_block_bytes(block: &[Bytes]) -> usize {
|
|||||||
block.iter().map(Bytes::len).sum()
|
block.iter().map(Bytes::len).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_queued_inflight_bytes(rx: &mut mpsc::Receiver<Vec<Bytes>>) {
|
/// Owns an encoded queue entry's gauge contribution until its consumer takes it.
|
||||||
while let Some(block) = rx.recv().await {
|
struct QueuedInflightBytes {
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_block_bytes(&block));
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueuedInflightBytes {
|
||||||
|
fn new(bytes: usize) -> Self {
|
||||||
|
rustfs_io_metrics::add_ec_encode_inflight_bytes(bytes);
|
||||||
|
Self { bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn settle(&mut self) {
|
||||||
|
let bytes = std::mem::take(&mut self.bytes);
|
||||||
|
if bytes != 0 {
|
||||||
|
rustfs_io_metrics::remove_ec_encode_inflight_bytes(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for QueuedInflightBytes {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.settle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Couples an encoded block with its queue gauge contribution. Keeping the
|
||||||
|
/// guard in the queue entry also covers Tokio sends that complete through a
|
||||||
|
/// permit after the receiver has closed.
|
||||||
|
struct InflightEntry<T> {
|
||||||
|
entry: T,
|
||||||
|
accounting: QueuedInflightBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> InflightEntry<T> {
|
||||||
|
fn new(entry: T, bytes: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entry,
|
||||||
|
accounting: QueuedInflightBytes::new(bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_inner(mut self) -> T {
|
||||||
|
self.accounting.settle();
|
||||||
|
self.entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_queued<T>(
|
||||||
|
sender: &mpsc::Sender<InflightEntry<T>>,
|
||||||
|
entry: T,
|
||||||
|
bytes: usize,
|
||||||
|
) -> Result<(), mpsc::error::SendError<InflightEntry<T>>> {
|
||||||
|
sender.send(InflightEntry::new(entry, bytes)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queued_batch_bytes(batch: &[Vec<Bytes>]) -> usize {
|
fn queued_batch_bytes(batch: &[Vec<Bytes>]) -> usize {
|
||||||
batch.iter().map(|block| queued_block_bytes(block)).sum()
|
batch.iter().map(|block| queued_block_bytes(block)).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_queued_batched_inflight_bytes(rx: &mut mpsc::Receiver<Vec<Vec<Bytes>>>) {
|
|
||||||
while let Some(batch) = rx.recv().await {
|
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_batch_bytes(&batch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
||||||
summary.dominant_error_label
|
summary.dominant_error_label
|
||||||
}
|
}
|
||||||
@@ -504,7 +577,7 @@ impl Erasure {
|
|||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub async fn encode<R>(
|
pub async fn encode<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -540,13 +613,14 @@ impl Erasure {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound queued encoded blocks by memory budget to avoid per-request spikes.
|
// Bound queued encoded blocks by a queue budget; this does not bound
|
||||||
|
// reader, encoder, writer, allocator, or process-RSS memory.
|
||||||
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
||||||
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
||||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||||
let (tx, mut rx) = mpsc::channel::<Vec<Bytes>>(inflight_blocks);
|
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Bytes>>>(inflight_blocks);
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||||
let block_size = self.block_size;
|
let block_size = self.block_size;
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
if use_bytesmut_ingest {
|
if use_bytesmut_ingest {
|
||||||
@@ -567,10 +641,9 @@ impl Erasure {
|
|||||||
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
|
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
|
||||||
buf = BytesMut::with_capacity(ingest_capacity);
|
buf = BytesMut::with_capacity(ingest_capacity);
|
||||||
let queued_bytes = queued_block_bytes(&res);
|
let queued_bytes = queued_block_bytes(&res);
|
||||||
rustfs_io_metrics::add_ec_encode_inflight_bytes(queued_bytes);
|
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||||
let send_wait_stage_start = stage_timer_if_enabled();
|
let send_wait_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(err) = tx.send(res).await {
|
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_bytes);
|
|
||||||
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
||||||
}
|
}
|
||||||
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
||||||
@@ -598,10 +671,9 @@ impl Erasure {
|
|||||||
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
||||||
buf = returned_buf;
|
buf = returned_buf;
|
||||||
let queued_bytes = queued_block_bytes(&res);
|
let queued_bytes = queued_block_bytes(&res);
|
||||||
rustfs_io_metrics::add_ec_encode_inflight_bytes(queued_bytes);
|
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||||
let send_wait_stage_start = stage_timer_if_enabled();
|
let send_wait_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(err) = tx.send(res).await {
|
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_bytes);
|
|
||||||
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
||||||
}
|
}
|
||||||
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
||||||
@@ -626,7 +698,7 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
});
|
}));
|
||||||
|
|
||||||
let mut writers = MultiWriter::new(writers, quorum);
|
let mut writers = MultiWriter::new(writers, quorum);
|
||||||
|
|
||||||
@@ -638,11 +710,11 @@ impl Erasure {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
record_internal_stage_if_enabled("erasure_encode_recv_wait", recv_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_recv_wait", recv_wait_stage_start);
|
||||||
|
let block = block.into_inner();
|
||||||
if block.is_empty() {
|
if block.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let queued_bytes = queued_block_bytes(&block);
|
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_block_bytes(&block));
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_bytes);
|
|
||||||
let write_stage_start = stage_timer_if_enabled();
|
let write_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(err) = writers.write(block).await {
|
if let Err(err) = writers.write(block).await {
|
||||||
write_err = Some(err);
|
write_err = Some(err);
|
||||||
@@ -652,9 +724,8 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(err) = write_err {
|
if let Some(err) = write_err {
|
||||||
task.abort();
|
task.abort_and_wait().await;
|
||||||
let _ = task.await;
|
drop(rx);
|
||||||
drain_queued_inflight_bytes(&mut rx).await;
|
|
||||||
let shutdown_stage_start = stage_timer_if_enabled();
|
let shutdown_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(shutdown_err) = writers.shutdown().await {
|
if let Err(shutdown_err) = writers.shutdown().await {
|
||||||
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
||||||
@@ -663,14 +734,14 @@ impl Erasure {
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (reader, total) = task.await??;
|
let (reader, total) = task.join().await??;
|
||||||
let shutdown_stage_start = stage_timer_if_enabled();
|
let shutdown_stage_start = stage_timer_if_enabled();
|
||||||
writers.shutdown().await?;
|
writers.shutdown().await?;
|
||||||
record_internal_stage_if_enabled("erasure_encode_shutdown", shutdown_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_shutdown", shutdown_stage_start);
|
||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub async fn encode_batched<R>(
|
pub async fn encode_batched<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
mut reader: R,
|
mut reader: R,
|
||||||
@@ -692,14 +763,15 @@ impl Erasure {
|
|||||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||||
let batch_blocks = encode_batch_block_count().min(inflight_blocks);
|
let batch_blocks = encode_batch_block_count().min(inflight_blocks);
|
||||||
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
|
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
|
||||||
let (tx, mut rx) = mpsc::channel::<Vec<Vec<Bytes>>>(channel_capacity);
|
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Vec<Bytes>>>>(channel_capacity);
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||||
let block_size = self.block_size;
|
let block_size = self.block_size;
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
let mut buf = vec![0u8; block_size];
|
let mut buf = vec![0u8; block_size];
|
||||||
let mut pending_batch = Vec::with_capacity(batch_blocks);
|
let mut pending_batch = Vec::with_capacity(batch_blocks);
|
||||||
let mut pending_batch_bytes = 0usize;
|
let mut pending_batch_bytes = 0usize;
|
||||||
|
let mut pending_batch_stage = None;
|
||||||
loop {
|
loop {
|
||||||
match rustfs_utils::read_full_or_eof(&mut reader, &mut buf).await {
|
match rustfs_utils::read_full_or_eof(&mut reader, &mut buf).await {
|
||||||
Ok(Some(n)) => {
|
Ok(Some(n)) => {
|
||||||
@@ -711,15 +783,16 @@ impl Erasure {
|
|||||||
let queued_bytes = queued_block_bytes(&res);
|
let queued_bytes = queued_block_bytes(&res);
|
||||||
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
|
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
|
||||||
pending_batch.push(res);
|
pending_batch.push(res);
|
||||||
|
drop(pending_batch_stage.take());
|
||||||
|
pending_batch_stage = Some(rustfs_io_metrics::track_ec_encode_producer_bytes(pending_batch_bytes));
|
||||||
|
|
||||||
if pending_batch.len() >= batch_blocks {
|
if pending_batch.len() >= batch_blocks {
|
||||||
rustfs_io_metrics::add_ec_encode_inflight_bytes(pending_batch_bytes);
|
|
||||||
let send_wait_stage_start = stage_timer_if_enabled();
|
let send_wait_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(err) = tx.send(pending_batch).await {
|
if let Err(err) = send_queued(&tx, pending_batch, pending_batch_bytes).await {
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(pending_batch_bytes);
|
|
||||||
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
||||||
}
|
}
|
||||||
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
||||||
|
drop(pending_batch_stage.take());
|
||||||
pending_batch = Vec::with_capacity(batch_blocks);
|
pending_batch = Vec::with_capacity(batch_blocks);
|
||||||
pending_batch_bytes = 0;
|
pending_batch_bytes = 0;
|
||||||
}
|
}
|
||||||
@@ -742,17 +815,16 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !pending_batch.is_empty() {
|
if !pending_batch.is_empty() {
|
||||||
rustfs_io_metrics::add_ec_encode_inflight_bytes(pending_batch_bytes);
|
|
||||||
let send_wait_stage_start = stage_timer_if_enabled();
|
let send_wait_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(err) = tx.send(pending_batch).await {
|
if let Err(err) = send_queued(&tx, pending_batch, pending_batch_bytes).await {
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(pending_batch_bytes);
|
|
||||||
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
||||||
}
|
}
|
||||||
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
||||||
|
drop(pending_batch_stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
});
|
}));
|
||||||
|
|
||||||
let mut writers = MultiWriter::new(writers, quorum);
|
let mut writers = MultiWriter::new(writers, quorum);
|
||||||
let mut write_err = None;
|
let mut write_err = None;
|
||||||
@@ -763,7 +835,8 @@ impl Erasure {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
record_internal_stage_if_enabled("erasure_encode_batched_recv_wait", recv_wait_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_batched_recv_wait", recv_wait_stage_start);
|
||||||
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_batch_bytes(&batch));
|
let batch = batch.into_inner();
|
||||||
|
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch));
|
||||||
let write_stage_start = stage_timer_if_enabled();
|
let write_stage_start = stage_timer_if_enabled();
|
||||||
for block in batch {
|
for block in batch {
|
||||||
if let Err(err) = writers.write(block).await {
|
if let Err(err) = writers.write(block).await {
|
||||||
@@ -778,9 +851,8 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(err) = write_err {
|
if let Some(err) = write_err {
|
||||||
task.abort();
|
task.abort_and_wait().await;
|
||||||
let _ = task.await;
|
drop(rx);
|
||||||
drain_queued_batched_inflight_bytes(&mut rx).await;
|
|
||||||
let shutdown_stage_start = stage_timer_if_enabled();
|
let shutdown_stage_start = stage_timer_if_enabled();
|
||||||
if let Err(shutdown_err) = writers.shutdown().await {
|
if let Err(shutdown_err) = writers.shutdown().await {
|
||||||
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
||||||
@@ -789,7 +861,7 @@ impl Erasure {
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (reader, total) = task.await??;
|
let (reader, total) = task.join().await??;
|
||||||
let shutdown_stage_start = stage_timer_if_enabled();
|
let shutdown_stage_start = stage_timer_if_enabled();
|
||||||
writers.shutdown().await?;
|
writers.shutdown().await?;
|
||||||
record_internal_stage_if_enabled("erasure_encode_batched_shutdown", shutdown_stage_start);
|
record_internal_stage_if_enabled("erasure_encode_batched_shutdown", shutdown_stage_start);
|
||||||
@@ -798,7 +870,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
||||||
/// Reads all data, encodes directly, writes shards sequentially.
|
/// Reads all data, encodes directly, writes shards sequentially.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub async fn encode_inline_small<R>(
|
pub async fn encode_inline_small<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -813,7 +885,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
||||||
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub async fn encode_single_block_non_inline<R>(
|
pub async fn encode_single_block_non_inline<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -839,7 +911,104 @@ mod tests {
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
use tokio::io::{AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
struct PendingReader {
|
||||||
|
entered: Option<oneshot::Sender<()>>,
|
||||||
|
dropped: Option<oneshot::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingReader {
|
||||||
|
fn new() -> (Self, oneshot::Receiver<()>, oneshot::Receiver<()>) {
|
||||||
|
let (entered_tx, entered_rx) = oneshot::channel();
|
||||||
|
let (dropped_tx, dropped_rx) = oneshot::channel();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
entered: Some(entered_tx),
|
||||||
|
dropped: Some(dropped_tx),
|
||||||
|
},
|
||||||
|
entered_rx,
|
||||||
|
dropped_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for PendingReader {
|
||||||
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
if let Some(entered) = self.entered.take() {
|
||||||
|
let _ = entered.send(());
|
||||||
|
}
|
||||||
|
Poll::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingReader {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(dropped) = self.dropped.take() {
|
||||||
|
let _ = dropped.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BlocksThenPendingReader {
|
||||||
|
blocks_remaining: usize,
|
||||||
|
block: Vec<u8>,
|
||||||
|
blocked: Option<oneshot::Sender<()>>,
|
||||||
|
dropped: Option<oneshot::Sender<()>>,
|
||||||
|
final_block: Option<oneshot::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlocksThenPendingReader {
|
||||||
|
fn new(
|
||||||
|
blocks_remaining: usize,
|
||||||
|
block_size: usize,
|
||||||
|
) -> (Self, oneshot::Receiver<()>, oneshot::Receiver<()>, oneshot::Receiver<()>) {
|
||||||
|
let (blocked_tx, blocked_rx) = oneshot::channel();
|
||||||
|
let (dropped_tx, dropped_rx) = oneshot::channel();
|
||||||
|
let (final_block_tx, final_block_rx) = oneshot::channel();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
blocks_remaining,
|
||||||
|
block: vec![0x5a; block_size],
|
||||||
|
blocked: Some(blocked_tx),
|
||||||
|
dropped: Some(dropped_tx),
|
||||||
|
final_block: Some(final_block_tx),
|
||||||
|
},
|
||||||
|
blocked_rx,
|
||||||
|
dropped_rx,
|
||||||
|
final_block_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for BlocksThenPendingReader {
|
||||||
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
if self.blocks_remaining == 0 {
|
||||||
|
if let Some(blocked) = self.blocked.take() {
|
||||||
|
let _ = blocked.send(());
|
||||||
|
}
|
||||||
|
return Poll::Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.blocks_remaining == 1
|
||||||
|
&& let Some(final_block) = self.final_block.take()
|
||||||
|
{
|
||||||
|
let _ = final_block.send(());
|
||||||
|
}
|
||||||
|
self.blocks_remaining -= 1;
|
||||||
|
buf.put_slice(&self.block);
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BlocksThenPendingReader {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(dropped) = self.dropped.take() {
|
||||||
|
let _ = dropped.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn erasure_with_zero_block_size() -> Erasure {
|
fn erasure_with_zero_block_size() -> Erasure {
|
||||||
let mut erasure = Erasure::default();
|
let mut erasure = Erasure::default();
|
||||||
@@ -897,6 +1066,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FailAfterReaderBlocksWriter {
|
||||||
|
reader_blocked: oneshot::Receiver<()>,
|
||||||
|
writes: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWrite for FailAfterReaderBlocksWriter {
|
||||||
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
||||||
|
match Pin::new(&mut self.reader_blocked).poll(cx) {
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
Poll::Ready(_) => {
|
||||||
|
self.writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Poll::Ready(Err(std::io::Error::other("injected write failure after producer blocks")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StallOnWriteWithSignal {
|
||||||
|
entered: Option<oneshot::Sender<()>>,
|
||||||
|
writes: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWrite for StallOnWriteWithSignal {
|
||||||
|
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
||||||
|
self.writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if let Some(entered) = self.entered.take() {
|
||||||
|
let _ = entered.send(());
|
||||||
|
}
|
||||||
|
Poll::Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct ShortWriteWriter;
|
struct ShortWriteWriter;
|
||||||
|
|
||||||
@@ -1046,6 +1263,262 @@ mod tests {
|
|||||||
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::None)
|
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EncodePipeline {
|
||||||
|
Vec,
|
||||||
|
BytesMut,
|
||||||
|
Batched,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn aborting_encode_drops_blocked_producer(pipeline: EncodePipeline) {
|
||||||
|
const BLOCK_SIZE: usize = 16;
|
||||||
|
|
||||||
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE))];
|
||||||
|
let (reader, entered, dropped) = PendingReader::new();
|
||||||
|
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
||||||
|
|
||||||
|
let encode = match pipeline {
|
||||||
|
EncodePipeline::Vec => {
|
||||||
|
tokio::spawn(async move { erasure.encode_with_ingest_mode(reader, &mut writers, 1, false).await })
|
||||||
|
}
|
||||||
|
EncodePipeline::BytesMut => {
|
||||||
|
tokio::spawn(async move { erasure.encode_with_ingest_mode(reader, &mut writers, 1, true).await })
|
||||||
|
}
|
||||||
|
EncodePipeline::Batched => tokio::spawn(async move { erasure.encode_batched(reader, &mut writers, 1).await }),
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), entered)
|
||||||
|
.await
|
||||||
|
.expect("producer should enter the blocked reader before cancellation")
|
||||||
|
.expect("blocked reader should signal entry");
|
||||||
|
encode.abort();
|
||||||
|
assert!(matches!(encode.await, Err(err) if err.is_cancelled()), "encode task should be cancelled");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), dropped)
|
||||||
|
.await
|
||||||
|
.expect("cancelling encode should drop the producer reader")
|
||||||
|
.expect("blocked reader should signal producer drop");
|
||||||
|
assert!(
|
||||||
|
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
|
"cancelling before the first encoded block must not make data visible"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
gauge_baseline,
|
||||||
|
"cancelling the encode pipeline must preserve the inflight queue gauge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn writer_error_aborts_blocked_producer(pipeline: EncodePipeline) {
|
||||||
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
|
let producer_baseline = rustfs_io_metrics::current_ec_encode_producer_bytes();
|
||||||
|
let writer_baseline = rustfs_io_metrics::current_ec_encode_writer_bytes();
|
||||||
|
let producer_peak_before = rustfs_io_metrics::current_ec_encode_producer_bytes_peak();
|
||||||
|
let writer_peak_before = rustfs_io_metrics::current_ec_encode_writer_bytes_peak();
|
||||||
|
let block_size = match pipeline {
|
||||||
|
EncodePipeline::Batched => usize::try_from(producer_peak_before.max(writer_peak_before).saturating_add(1))
|
||||||
|
.expect("stage peak fits the test address space"),
|
||||||
|
EncodePipeline::Vec | EncodePipeline::BytesMut => 16,
|
||||||
|
};
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
||||||
|
let erasure = Arc::new(Erasure::new(1, 0, block_size));
|
||||||
|
let batch_blocks = encode_batch_block_count().min(encode_channel_capacity(
|
||||||
|
erasure.shard_size().saturating_mul(erasure.total_shard_count()),
|
||||||
|
erasure_encode_max_inflight_bytes(),
|
||||||
|
));
|
||||||
|
let blocks_before_pending = match pipeline {
|
||||||
|
EncodePipeline::Batched => batch_blocks,
|
||||||
|
EncodePipeline::Vec | EncodePipeline::BytesMut => 1,
|
||||||
|
};
|
||||||
|
let (reader, reader_blocked, reader_dropped, _final_block) =
|
||||||
|
BlocksThenPendingReader::new(blocks_before_pending, block_size);
|
||||||
|
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let mut writers = vec![Some(bitrot_writer(
|
||||||
|
FailAfterReaderBlocksWriter {
|
||||||
|
reader_blocked,
|
||||||
|
writes: writes.clone(),
|
||||||
|
},
|
||||||
|
block_size,
|
||||||
|
))];
|
||||||
|
|
||||||
|
let result = match pipeline {
|
||||||
|
EncodePipeline::Vec => erasure.encode_with_ingest_mode(reader, &mut writers, 1, false).await,
|
||||||
|
EncodePipeline::BytesMut => erasure.encode_with_ingest_mode(reader, &mut writers, 1, true).await,
|
||||||
|
EncodePipeline::Batched => erasure.encode_batched(reader, &mut writers, 1).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = match result {
|
||||||
|
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("Failed to write data"));
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
||||||
|
.await
|
||||||
|
.expect("writer failure should abort the blocked producer")
|
||||||
|
.expect("blocked producer should signal reader drop");
|
||||||
|
assert_eq!(
|
||||||
|
writes.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"writer failure must stop the pipeline before any additional shard write"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
gauge_baseline,
|
||||||
|
"writer failure must settle all queued and pending encoded bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
||||||
|
producer_baseline,
|
||||||
|
"writer failure must settle producer stage bytes for every ingest pipeline"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
||||||
|
writer_baseline,
|
||||||
|
"writer failure must settle writer stage bytes for every ingest pipeline"
|
||||||
|
);
|
||||||
|
if matches!(pipeline, EncodePipeline::Batched) {
|
||||||
|
let expected_batch_bytes = u64::try_from(block_size)
|
||||||
|
.expect("block size fits the stage gauge")
|
||||||
|
.checked_mul(u64::try_from(batch_blocks).expect("batch block count fits the stage gauge"))
|
||||||
|
.expect("test batch payload fits the stage gauge");
|
||||||
|
assert!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_producer_bytes_peak() >= producer_peak_before.max(expected_batch_bytes),
|
||||||
|
"batched producer must expose its full pending batch before writer failure"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_writer_bytes_peak() >= writer_peak_before.max(expected_batch_bytes),
|
||||||
|
"batched writer must expose its full batch before writer failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn aborting_full_queue_settles_pending_send() {
|
||||||
|
const BLOCK_SIZE: usize = 16;
|
||||||
|
|
||||||
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
|
let producer_baseline = rustfs_io_metrics::current_ec_encode_producer_bytes();
|
||||||
|
let writer_baseline = rustfs_io_metrics::current_ec_encode_writer_bytes();
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
||||||
|
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
||||||
|
let inflight_blocks = encode_channel_capacity(
|
||||||
|
erasure.shard_size().saturating_mul(erasure.total_shard_count()),
|
||||||
|
erasure_encode_max_inflight_bytes(),
|
||||||
|
);
|
||||||
|
let (reader, _reader_blocked, reader_dropped, final_block) =
|
||||||
|
BlocksThenPendingReader::new(inflight_blocks + 2, BLOCK_SIZE);
|
||||||
|
let (writer_entered_tx, writer_entered) = oneshot::channel();
|
||||||
|
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let mut writers = vec![Some(bitrot_writer(
|
||||||
|
StallOnWriteWithSignal {
|
||||||
|
entered: Some(writer_entered_tx),
|
||||||
|
writes: writes.clone(),
|
||||||
|
},
|
||||||
|
BLOCK_SIZE,
|
||||||
|
))];
|
||||||
|
let erasure_for_task = erasure.clone();
|
||||||
|
let encode = tokio::spawn(async move { erasure_for_task.encode_with_ingest_mode(reader, &mut writers, 1, false).await });
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), writer_entered)
|
||||||
|
.await
|
||||||
|
.expect("consumer should start the first writer call")
|
||||||
|
.expect("stalling writer should signal entry");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), final_block)
|
||||||
|
.await
|
||||||
|
.expect("producer should supply the block whose send fills the queue")
|
||||||
|
.expect("reader should signal final block");
|
||||||
|
|
||||||
|
let expected_queued_bytes =
|
||||||
|
u64::try_from((inflight_blocks + 1) * BLOCK_SIZE).expect("queued byte count should fit the gauge");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while rustfs_io_metrics::current_ec_encode_inflight_bytes() < gauge_baseline + expected_queued_bytes {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("producer should account for the pending send after the queue fills");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while rustfs_io_metrics::current_ec_encode_producer_bytes() == producer_baseline
|
||||||
|
|| rustfs_io_metrics::current_ec_encode_writer_bytes() == writer_baseline
|
||||||
|
{
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("full queue must retain producer and writer stage ownership before cancellation");
|
||||||
|
|
||||||
|
encode.abort();
|
||||||
|
assert!(matches!(encode.await, Err(err) if err.is_cancelled()), "encode task should be cancelled");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
||||||
|
.await
|
||||||
|
.expect("cancelling a full queue should abort its producer")
|
||||||
|
.expect("full-queue producer should signal reader drop");
|
||||||
|
assert_eq!(
|
||||||
|
writes.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"cancellation must not resume the stalled writer"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
gauge_baseline,
|
||||||
|
"cancelling a full queue must settle queued and pending bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
||||||
|
producer_baseline,
|
||||||
|
"cancelling a full queue must settle the pending producer stage"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
||||||
|
writer_baseline,
|
||||||
|
"cancelling a full queue must settle the stalled writer stage"
|
||||||
|
);
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn cancelling_vec_encode_drops_blocked_producer() {
|
||||||
|
aborting_encode_drops_blocked_producer(EncodePipeline::Vec).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn cancelling_bytesmut_encode_drops_blocked_producer() {
|
||||||
|
aborting_encode_drops_blocked_producer(EncodePipeline::BytesMut).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn cancelling_batched_encode_drops_blocked_producer() {
|
||||||
|
aborting_encode_drops_blocked_producer(EncodePipeline::Batched).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn vec_writer_error_aborts_blocked_producer() {
|
||||||
|
writer_error_aborts_blocked_producer(EncodePipeline::Vec).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn bytesmut_writer_error_aborts_blocked_producer() {
|
||||||
|
writer_error_aborts_blocked_producer(EncodePipeline::BytesMut).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn batched_writer_error_aborts_blocked_producer() {
|
||||||
|
writer_error_aborts_blocked_producer(EncodePipeline::Batched).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn cancelling_full_queue_settles_pending_send() {
|
||||||
|
aborting_full_queue_settles_pending_send().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn helper_writers_cover_flush_and_shutdown_paths() {
|
async fn helper_writers_cover_flush_and_shutdown_paths() {
|
||||||
let mut failing_write = FailingWriteWriter;
|
let mut failing_write = FailingWriteWriter;
|
||||||
@@ -1329,25 +1802,99 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn drain_queued_inflight_bytes_consumes_pending_blocks() {
|
#[serial_test::serial]
|
||||||
let (tx, mut rx) = mpsc::channel(2);
|
async fn queued_inflight_bytes_are_settled_on_all_queue_exit_paths() {
|
||||||
tx.send(vec![Bytes::from_static(b"queued")]).await.unwrap();
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
drop(tx);
|
let (tx, rx) = mpsc::channel(1);
|
||||||
|
let queued = vec![Bytes::from_static(b"queued")];
|
||||||
|
let queued_bytes = queued_block_bytes(&queued);
|
||||||
|
|
||||||
drain_queued_inflight_bytes(&mut rx).await;
|
send_queued(&tx, queued, queued_bytes)
|
||||||
|
.await
|
||||||
|
.expect("first queue entry should fit");
|
||||||
|
|
||||||
assert!(rx.recv().await.is_none());
|
let blocked = vec![Bytes::from_static(b"blocked")];
|
||||||
|
let blocked_bytes = queued_block_bytes(&blocked);
|
||||||
|
{
|
||||||
|
let pending_send = send_queued(&tx, blocked, blocked_bytes);
|
||||||
|
tokio::pin!(pending_send);
|
||||||
|
assert!(
|
||||||
|
futures::poll!(pending_send.as_mut()).is_pending(),
|
||||||
|
"full queue must suspend producer send"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
baseline + u64::try_from(queued_bytes).expect("queue bytes fit the gauge"),
|
||||||
|
"dropping a pending producer send must compensate its bytes"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(rx);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
baseline,
|
||||||
|
"dropping the receiver must settle every buffered entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rejected = vec![Bytes::from_static(b"rejected")];
|
||||||
|
let rejected_bytes = queued_block_bytes(&rejected);
|
||||||
|
assert!(
|
||||||
|
send_queued(&tx, rejected, rejected_bytes).await.is_err(),
|
||||||
|
"closed receiver must reject a new send"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
baseline,
|
||||||
|
"failed sends must compensate their bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn drain_queued_batched_inflight_bytes_consumes_pending_batches() {
|
#[serial_test::serial]
|
||||||
let (tx, mut rx) = mpsc::channel(2);
|
async fn queued_batch_entry_settles_bytes_before_handoff() {
|
||||||
tx.send(vec![vec![Bytes::from_static(b"queued")]]).await.unwrap();
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
drop(tx);
|
let (tx, rx) = mpsc::channel(2);
|
||||||
|
let mut rx = rx;
|
||||||
|
let batch = vec![vec![Bytes::from_static(b"queued")], vec![Bytes::from_static(b"batch")]];
|
||||||
|
let batch_bytes = queued_batch_bytes(&batch);
|
||||||
|
|
||||||
drain_queued_batched_inflight_bytes(&mut rx).await;
|
send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued");
|
||||||
|
let batch = rx.recv().await.expect("queued batch should be received").into_inner();
|
||||||
|
assert_eq!(batch_bytes, queued_batch_bytes(&batch));
|
||||||
|
|
||||||
assert!(rx.recv().await.is_none());
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
baseline,
|
||||||
|
"receiving a batch must settle all contained block bytes before shard writes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn queued_entry_settles_when_a_closed_receiver_accepts_an_outstanding_permit() {
|
||||||
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
let permit = tx
|
||||||
|
.clone()
|
||||||
|
.reserve_owned()
|
||||||
|
.await
|
||||||
|
.expect("open receiver should reserve queue capacity");
|
||||||
|
rx.close();
|
||||||
|
assert!(
|
||||||
|
matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
|
||||||
|
"an outstanding permit must leave the closed queue observably empty"
|
||||||
|
);
|
||||||
|
|
||||||
|
let block = vec![Bytes::from_static(b"late-permit")];
|
||||||
|
let block_bytes = queued_block_bytes(&block);
|
||||||
|
permit.send(InflightEntry::new(block, block_bytes));
|
||||||
|
drop(rx);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
baseline,
|
||||||
|
"a queue entry sent through an outstanding permit must settle when Tokio drops it"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1394,6 +1941,61 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn bytesmut_streaming_encode_observes_all_payload_stage_peaks() {
|
||||||
|
let queue_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||||
|
let producer_peak_before = rustfs_io_metrics::current_ec_encode_producer_bytes_peak();
|
||||||
|
let queue_peak_before = rustfs_io_metrics::current_ec_encode_queue_bytes_peak();
|
||||||
|
let writer_peak_before = rustfs_io_metrics::current_ec_encode_writer_bytes_peak();
|
||||||
|
let prior_peak = producer_peak_before.max(queue_peak_before).max(writer_peak_before);
|
||||||
|
let block_size = usize::try_from(prior_peak.saturating_add(1)).expect("stage peak fits the test address space");
|
||||||
|
let erasure = Arc::new(Erasure::new(1, 0, block_size));
|
||||||
|
let encoded_block_bytes = erasure.shard_size() * erasure.total_shard_count();
|
||||||
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), block_size))];
|
||||||
|
let reader = tokio::io::BufReader::new(Cursor::new(vec![0x5a; block_size * 2]));
|
||||||
|
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
||||||
|
let result = erasure.encode_with_ingest_mode(reader, &mut writers, 1, true).await;
|
||||||
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
||||||
|
|
||||||
|
let (_reader, written) = result.expect("bytesmut streaming encode should complete");
|
||||||
|
assert_eq!(written, block_size * 2);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
||||||
|
queue_baseline,
|
||||||
|
"completed streaming encode must not retain queue bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
||||||
|
0,
|
||||||
|
"completed streaming encode must not retain producer bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
||||||
|
0,
|
||||||
|
"completed streaming encode must not retain writer bytes"
|
||||||
|
);
|
||||||
|
let expected_peak = u64::try_from(encoded_block_bytes).expect("encoded block bytes fit the gauge");
|
||||||
|
assert!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_producer_bytes_peak() >= producer_peak_before.max(expected_peak),
|
||||||
|
"producer peak must observe encoded bytes before queue hand-off"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_queue_bytes_peak() >= queue_peak_before.max(expected_peak),
|
||||||
|
"queue peak must observe encoded bytes pending shard writers"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rustfs_io_metrics::current_ec_encode_writer_bytes_peak() >= writer_peak_before.max(expected_peak),
|
||||||
|
"writer peak must observe encoded bytes after queue hand-off"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
|
"stage peak observation must not change writer commit behavior"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn encode_streaming_write_quorum_failure_aborts_and_reports_error() {
|
async fn encode_streaming_write_quorum_failure_aborts_and_reports_error() {
|
||||||
const DATA_SHARDS: usize = 2;
|
const DATA_SHARDS: usize = 2;
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ impl Erasure {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A vector of encoded shards as `Bytes`.
|
/// A vector of encoded shards as `Bytes`.
|
||||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -688,7 +688,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Encode owned data, avoiding a copy when the caller already has a heap buffer.
|
/// Encode owned data, avoiding a copy when the caller already has a heap buffer.
|
||||||
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -752,7 +752,7 @@ impl Erasure {
|
|||||||
/// block), the `resize(need_total_size)` below stays within capacity for every
|
/// block), the `resize(need_total_size)` below stays within capacity for every
|
||||||
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
||||||
/// `data_len` — so this function never reallocates the buffer.
|
/// `data_len` — so this function never reallocates the buffer.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -805,7 +805,7 @@ impl Erasure {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Ok if reconstruction succeeds, error otherwise.
|
/// Ok if reconstruction succeeds, error otherwise.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub fn decode_data(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
pub fn decode_data(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||||
if self.parity_shards > 0 {
|
if self.parity_shards > 0 {
|
||||||
if self.uses_legacy {
|
if self.uses_legacy {
|
||||||
@@ -825,7 +825,7 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decode and reconstruct missing data shards, then regenerate parity shards.
|
/// Decode and reconstruct missing data shards, then regenerate parity shards.
|
||||||
#[hotpath::measure]
|
#[hotpath::measure(impl_type = "Erasure")]
|
||||||
pub fn decode_data_and_parity(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
pub fn decode_data_and_parity(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||||
if self.parity_shards > 0 {
|
if self.parity_shards > 0 {
|
||||||
if self.uses_legacy {
|
if self.uses_legacy {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use crate::diagnostics::get::{
|
|||||||
GET_STAGE_READER_MMAP_PATH_RESOLVE, GET_STAGE_READER_OPEN_MMAP_COPY_FALLBACK, GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS,
|
GET_STAGE_READER_MMAP_PATH_RESOLVE, GET_STAGE_READER_OPEN_MMAP_COPY_FALLBACK, GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS,
|
||||||
GET_STAGE_READER_OPEN_STREAM, GET_STAGE_READER_STREAM_FIRST_READ, record_get_stage_duration_if_enabled,
|
GET_STAGE_READER_OPEN_STREAM, GET_STAGE_READER_STREAM_FIRST_READ, record_get_stage_duration_if_enabled,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
use crate::disk::FileWriter;
|
||||||
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
||||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
|
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -36,6 +38,11 @@ use std::time::Instant;
|
|||||||
use tokio::io::{AsyncRead, ReadBuf};
|
use tokio::io::{AsyncRead, ReadBuf};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "hotpath"))]
|
||||||
|
tokio::task_local! {
|
||||||
|
static FORCE_MMAP_COPY_FAILURE_FOR_TEST: ();
|
||||||
|
}
|
||||||
|
|
||||||
/// A shard source for the bitrot reader.
|
/// A shard source for the bitrot reader.
|
||||||
///
|
///
|
||||||
/// `InMemory` keeps the `Bytes` concrete instead of erasing it behind
|
/// `InMemory` keeps the `Bytes` concrete instead of erasing it behind
|
||||||
@@ -360,10 +367,21 @@ async fn open_disk_reader(
|
|||||||
mmap_copy_stage: GET_STAGE_READER_MMAP_COPY_BUFFER,
|
mmap_copy_stage: GET_STAGE_READER_MMAP_COPY_BUFFER,
|
||||||
direct_read_copy_stage: GET_STAGE_READER_MMAP_DIRECT_READ_COPY,
|
direct_read_copy_stage: GET_STAGE_READER_MMAP_DIRECT_READ_COPY,
|
||||||
});
|
});
|
||||||
match disk
|
let mmap_result = {
|
||||||
.read_file_mmap_copy_with_metrics(bucket, path, offset, length, mmap_metrics)
|
#[cfg(all(test, feature = "hotpath"))]
|
||||||
.await
|
if FORCE_MMAP_COPY_FAILURE_FOR_TEST.try_with(|_| ()).is_ok() {
|
||||||
{
|
Err(DiskError::other("forced mmap-copy failure for test"))
|
||||||
|
} else {
|
||||||
|
disk.read_file_mmap_copy_with_metrics(bucket, path, offset, length, mmap_metrics)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
#[cfg(not(all(test, feature = "hotpath")))]
|
||||||
|
{
|
||||||
|
disk.read_file_mmap_copy_with_metrics(bucket, path, offset, length, mmap_metrics)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match mmap_result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let duration_ms = zero_copy_start.elapsed().as_secs_f64() * 1000.0;
|
let duration_ms = zero_copy_start.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
@@ -398,7 +416,11 @@ async fn open_disk_reader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match stream_result {
|
return match stream_result {
|
||||||
Ok(reader) => Ok(wrap_first_read_metrics(reader, metrics_path)),
|
Ok(reader) => {
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
let reader = instrument_raw_shard_reader(reader, disk.is_local());
|
||||||
|
Ok(wrap_first_read_metrics(reader, metrics_path))
|
||||||
|
}
|
||||||
Err(_) => Err(err),
|
Err(_) => Err(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -407,6 +429,8 @@ async fn open_disk_reader(
|
|||||||
|
|
||||||
let stream_start = stage_metrics_enabled.then(Instant::now);
|
let stream_start = stage_metrics_enabled.then(Instant::now);
|
||||||
let reader = disk.read_file_stream(bucket, path, offset, length).await?;
|
let reader = disk.read_file_stream(bucket, path, offset, length).await?;
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
let reader = instrument_raw_shard_reader(reader, disk.is_local());
|
||||||
if let Some(metrics_path) = metrics_path {
|
if let Some(metrics_path) = metrics_path {
|
||||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READER_OPEN_STREAM, stream_start);
|
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READER_OPEN_STREAM, stream_start);
|
||||||
}
|
}
|
||||||
@@ -427,6 +451,37 @@ fn wrap_first_read_metrics(reader: FileReader, metrics_path: Option<&'static str
|
|||||||
ShardReader::Stream(reader)
|
ShardReader::Stream(reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The labels are deliberately fixed: object keys, disk paths, and remote hosts
|
||||||
|
// are all high-cardinality or sensitive and belong nowhere in a profiling report.
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
const RAW_SHARD_READ_LOCAL_LABEL: &str = "EC raw shard read local";
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
const RAW_SHARD_READ_REMOTE_LABEL: &str = "EC raw shard read remote";
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
const RAW_SHARD_WRITE_LOCAL_LABEL: &str = "EC raw shard write local";
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
const RAW_SHARD_WRITE_REMOTE_LABEL: &str = "EC raw shard write remote";
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
fn instrument_raw_shard_reader(reader: FileReader, is_local: bool) -> FileReader {
|
||||||
|
// `io!` aggregates by call site, so the local and remote branches must remain distinct.
|
||||||
|
if is_local {
|
||||||
|
Box::new(hotpath::io!(reader, label = RAW_SHARD_READ_LOCAL_LABEL))
|
||||||
|
} else {
|
||||||
|
Box::new(hotpath::io!(reader, label = RAW_SHARD_READ_REMOTE_LABEL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
fn instrument_raw_shard_writer(writer: FileWriter, is_local: bool) -> FileWriter {
|
||||||
|
// `io!` aggregates by call site, so the local and remote branches must remain distinct.
|
||||||
|
if is_local {
|
||||||
|
Box::new(hotpath::io!(writer, label = RAW_SHARD_WRITE_LOCAL_LABEL))
|
||||||
|
} else {
|
||||||
|
Box::new(hotpath::io!(writer, label = RAW_SHARD_WRITE_REMOTE_LABEL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn bitrot_encoded_range(offset: usize, length: usize, shard_size: usize, checksum_algo: HashAlgorithm) -> (usize, usize) {
|
fn bitrot_encoded_range(offset: usize, length: usize, shard_size: usize, checksum_algo: HashAlgorithm) -> (usize, usize) {
|
||||||
(
|
(
|
||||||
offset.div_ceil(shard_size) * checksum_algo.size() + offset,
|
offset.div_ceil(shard_size) * checksum_algo.size() + offset,
|
||||||
@@ -686,6 +741,8 @@ pub async fn create_bitrot_writer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let file = disk.create_file("", volume, path, length).await?;
|
let file = disk.create_file("", volume, path, length).await?;
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
let file = instrument_raw_shard_writer(file, disk.is_local());
|
||||||
CustomWriter::new_tokio_writer(file)
|
CustomWriter::new_tokio_writer(file)
|
||||||
} else {
|
} else {
|
||||||
return Err(DiskError::DiskNotFound);
|
return Err(DiskError::DiskNotFound);
|
||||||
@@ -698,6 +755,194 @@ pub async fn create_bitrot_writer(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
use crate::cluster::rpc::RemoteDisk;
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
use crate::cluster::rpc::internode_data_transport::{
|
||||||
|
InternodeDataTransport, InternodeDataTransportCapabilities, ReadStreamRequest, WalkDirStreamRequest, WriteStreamRequest,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
use crate::disk::{Disk, DiskOption, error::Result};
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct TestRemoteDataTransport {
|
||||||
|
bytes: Arc<Mutex<Vec<u8>>>,
|
||||||
|
write_error: Option<io::ErrorKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
impl TestRemoteDataTransport {
|
||||||
|
fn with_write_error(kind: io::ErrorKind) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: Arc::default(),
|
||||||
|
write_error: Some(kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&self) -> Vec<u8> {
|
||||||
|
self.bytes
|
||||||
|
.lock()
|
||||||
|
.expect("test remote transport bytes lock should not be poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestRemoteWriter {
|
||||||
|
bytes: Arc<Mutex<Vec<u8>>>,
|
||||||
|
write_error: Option<io::ErrorKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
impl tokio::io::AsyncWrite for TestRemoteWriter {
|
||||||
|
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||||
|
if let Some(kind) = self.write_error {
|
||||||
|
return Poll::Ready(Err(io::Error::from(kind)));
|
||||||
|
}
|
||||||
|
self.bytes
|
||||||
|
.lock()
|
||||||
|
.expect("test remote transport bytes lock should not be poisoned")
|
||||||
|
.extend_from_slice(buf);
|
||||||
|
Poll::Ready(Ok(buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl InternodeDataTransport for TestRemoteDataTransport {
|
||||||
|
async fn open_read(&self, _request: ReadStreamRequest) -> Result<FileReader> {
|
||||||
|
Ok(Box::new(Cursor::new(self.bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
|
||||||
|
Ok(Box::new(TestRemoteWriter {
|
||||||
|
bytes: Arc::clone(&self.bytes),
|
||||||
|
write_error: self.write_error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
|
||||||
|
panic!("open_walk_dir must not be used by the raw shard I/O test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"bitrot-test-remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> InternodeDataTransportCapabilities {
|
||||||
|
InternodeDataTransportCapabilities::tcp_http()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_test_disk() -> (DiskStore, tempfile::TempDir) {
|
||||||
|
use crate::disk::endpoint::Endpoint;
|
||||||
|
use crate::disk::{DiskOption, new_disk};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||||
|
let mut endpoint =
|
||||||
|
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||||
|
endpoint.set_pool_index(0);
|
||||||
|
endpoint.set_set_index(0);
|
||||||
|
endpoint.set_disk_index(0);
|
||||||
|
let disk = new_disk(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("local disk should be created");
|
||||||
|
|
||||||
|
(disk, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
async fn remote_test_disk(transport: TestRemoteDataTransport) -> (DiskStore, TestRemoteDataTransport) {
|
||||||
|
use crate::disk::endpoint::Endpoint;
|
||||||
|
|
||||||
|
let endpoint = Endpoint {
|
||||||
|
url: url::Url::parse("http://remote-node:9000/data/rustfs0").expect("test remote endpoint should parse"),
|
||||||
|
is_local: false,
|
||||||
|
pool_idx: 0,
|
||||||
|
set_idx: 0,
|
||||||
|
disk_idx: 0,
|
||||||
|
};
|
||||||
|
let remote = RemoteDisk::new(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
Arc::new(transport.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("test remote disk should be created");
|
||||||
|
|
||||||
|
(Arc::new(Disk::Remote(Box::new(remote))), transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn round_trip_disk_bitrot(disk: &DiskStore, bucket: &str, path: &str, payload: &[u8], shard_size: usize) -> Vec<u8> {
|
||||||
|
disk.make_volume(bucket).await.expect("volume should be created");
|
||||||
|
let mut writer = create_bitrot_writer(
|
||||||
|
false,
|
||||||
|
Some(disk),
|
||||||
|
bucket,
|
||||||
|
path,
|
||||||
|
i64::try_from(payload.len()).expect("test payload length should fit i64"),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("disk bitrot writer should open the raw shard file");
|
||||||
|
for chunk in payload.chunks(shard_size) {
|
||||||
|
writer
|
||||||
|
.write(chunk)
|
||||||
|
.await
|
||||||
|
.expect("disk bitrot writer should preserve each shard block");
|
||||||
|
}
|
||||||
|
writer.shutdown().await.expect("disk bitrot writer should close cleanly");
|
||||||
|
|
||||||
|
let mut reader = create_bitrot_reader(
|
||||||
|
None,
|
||||||
|
Some(disk),
|
||||||
|
bucket,
|
||||||
|
path,
|
||||||
|
0,
|
||||||
|
payload.len(),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("disk bitrot reader should open the raw shard file")
|
||||||
|
.expect("disk bitrot reader should exist");
|
||||||
|
let mut actual = Vec::with_capacity(payload.len());
|
||||||
|
while actual.len() < payload.len() {
|
||||||
|
let remaining = payload.len() - actual.len();
|
||||||
|
let mut chunk = vec![0; remaining.min(shard_size)];
|
||||||
|
let read = reader
|
||||||
|
.read(&mut chunk)
|
||||||
|
.await
|
||||||
|
.expect("disk bitrot reader should return the complete shard body");
|
||||||
|
assert!(read > 0, "disk bitrot reader must not end before the expected shard body is complete");
|
||||||
|
actual.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
actual
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_mmap_read_enabled_accepts_legacy_zero_copy_alias() {
|
fn object_mmap_read_enabled_accepts_legacy_zero_copy_alias() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
@@ -724,6 +969,214 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
#[test]
|
||||||
|
fn raw_shard_io_wrappers_report_fixed_labels_and_preserve_bytes() {
|
||||||
|
const CHILD_ENV: &str = "RUSTFS_HOTPATH_RAW_SHARD_IO_TEST_CHILD";
|
||||||
|
if std::env::var_os(CHILD_ENV).is_none() {
|
||||||
|
let status = std::process::Command::new(std::env::current_exe().expect("test executable path should be available"))
|
||||||
|
.arg("--exact")
|
||||||
|
.arg("io_support::bitrot::tests::raw_shard_io_wrappers_report_fixed_labels_and_preserve_bytes")
|
||||||
|
.arg("--nocapture")
|
||||||
|
.env(CHILD_ENV, "1")
|
||||||
|
.status()
|
||||||
|
.expect("isolated HotPath I/O test process should start");
|
||||||
|
assert!(status.success(), "isolated HotPath I/O test process should pass");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("test runtime should be created")
|
||||||
|
.block_on(raw_shard_io_wrappers_report_fixed_labels_and_preserve_bytes_in_isolated_process());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hotpath")]
|
||||||
|
async fn raw_shard_io_wrappers_report_fixed_labels_and_preserve_bytes_in_isolated_process() {
|
||||||
|
use hotpath::{Format, HotpathGuardBuilder, Section};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let report_dir = tempfile::tempdir().expect("report tempdir should be created");
|
||||||
|
let report_path = report_dir.path().join("hotpath-io.json");
|
||||||
|
let guard = HotpathGuardBuilder::new("raw_shard_io_test")
|
||||||
|
.format(Format::Json)
|
||||||
|
.output_path(&report_path)
|
||||||
|
.sections(vec![Section::Io])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let (disk, _dir) = local_test_disk().await;
|
||||||
|
let bucket = "test-bucket";
|
||||||
|
let path = "obj/hotpath-part.1";
|
||||||
|
let payload = b"local shard bytes";
|
||||||
|
let shard_size = 4;
|
||||||
|
let local_read = round_trip_disk_bitrot(&disk, bucket, path, payload, shard_size).await;
|
||||||
|
assert_eq!(local_read, payload, "local raw shard I/O instrumentation must not alter stored bytes");
|
||||||
|
|
||||||
|
let fallback_path = "obj/hotpath-mmap-fallback-part.1";
|
||||||
|
let fallback_payload = b"mmap fallback shard bytes";
|
||||||
|
disk.write_all("test-bucket", fallback_path, Bytes::from_static(fallback_payload))
|
||||||
|
.await
|
||||||
|
.expect("fallback shard file should be written");
|
||||||
|
let mut fallback_reader = FORCE_MMAP_COPY_FAILURE_FOR_TEST
|
||||||
|
.scope((), open_disk_reader(&disk, bucket, fallback_path, 0, fallback_payload.len(), true, None))
|
||||||
|
.await
|
||||||
|
.expect("mmap-copy failure should fall back to a raw shard stream");
|
||||||
|
assert!(
|
||||||
|
matches!(fallback_reader, ShardReader::Stream(_)),
|
||||||
|
"forced mmap-copy failure must use the streaming fallback"
|
||||||
|
);
|
||||||
|
let mut fallback_read = Vec::new();
|
||||||
|
fallback_reader
|
||||||
|
.read_to_end(&mut fallback_read)
|
||||||
|
.await
|
||||||
|
.expect("mmap-copy fallback stream should preserve bytes");
|
||||||
|
assert_eq!(fallback_read, fallback_payload);
|
||||||
|
|
||||||
|
let (remote_disk, remote_transport) = remote_test_disk(TestRemoteDataTransport::default()).await;
|
||||||
|
let remote_payload = b"remote shard bytes";
|
||||||
|
let mut remote_writer = create_bitrot_writer(
|
||||||
|
false,
|
||||||
|
Some(&remote_disk),
|
||||||
|
bucket,
|
||||||
|
"obj/hotpath-remote-part.1",
|
||||||
|
i64::try_from(remote_payload.len()).expect("remote payload length should fit i64"),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("remote bitrot writer should use the production raw writer path");
|
||||||
|
for chunk in remote_payload.chunks(shard_size) {
|
||||||
|
remote_writer
|
||||||
|
.write(chunk)
|
||||||
|
.await
|
||||||
|
.expect("remote bitrot writer should preserve bytes");
|
||||||
|
}
|
||||||
|
remote_writer
|
||||||
|
.shutdown()
|
||||||
|
.await
|
||||||
|
.expect("remote bitrot writer should close cleanly");
|
||||||
|
assert_eq!(remote_transport.bytes(), remote_payload);
|
||||||
|
|
||||||
|
let mut reader = create_bitrot_reader(
|
||||||
|
None,
|
||||||
|
Some(&remote_disk),
|
||||||
|
bucket,
|
||||||
|
"obj/hotpath-remote-part.1",
|
||||||
|
0,
|
||||||
|
remote_payload.len(),
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("remote bitrot reader should use the production raw reader path")
|
||||||
|
.expect("remote bitrot reader should exist");
|
||||||
|
let mut remote_read = Vec::with_capacity(remote_payload.len());
|
||||||
|
while remote_read.len() < remote_payload.len() {
|
||||||
|
let remaining = remote_payload.len() - remote_read.len();
|
||||||
|
let mut chunk = vec![0; remaining.min(shard_size)];
|
||||||
|
let read = reader
|
||||||
|
.read(&mut chunk)
|
||||||
|
.await
|
||||||
|
.expect("remote bitrot reader should preserve bytes");
|
||||||
|
assert!(read > 0, "remote bitrot reader must not end before the expected shard body is complete");
|
||||||
|
remote_read.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
assert_eq!(remote_read, remote_payload);
|
||||||
|
|
||||||
|
let (failing_remote_disk, _) = remote_test_disk(TestRemoteDataTransport::with_write_error(io::ErrorKind::Other)).await;
|
||||||
|
let mut failing_writer = create_bitrot_writer(
|
||||||
|
false,
|
||||||
|
Some(&failing_remote_disk),
|
||||||
|
bucket,
|
||||||
|
"obj/hotpath-remote-write-error-part.1",
|
||||||
|
4,
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failing remote bitrot writer should open before its first write");
|
||||||
|
let write_error = failing_writer
|
||||||
|
.write(b"fail")
|
||||||
|
.await
|
||||||
|
.expect_err("raw shard writer failures must remain visible through the bitrot writer");
|
||||||
|
assert_eq!(write_error.kind(), io::ErrorKind::Other);
|
||||||
|
|
||||||
|
let (would_block_remote_disk, _) =
|
||||||
|
remote_test_disk(TestRemoteDataTransport::with_write_error(io::ErrorKind::WouldBlock)).await;
|
||||||
|
let mut would_block_writer = create_bitrot_writer(
|
||||||
|
false,
|
||||||
|
Some(&would_block_remote_disk),
|
||||||
|
bucket,
|
||||||
|
"obj/hotpath-remote-write-would-block-part.1",
|
||||||
|
4,
|
||||||
|
shard_size,
|
||||||
|
HashAlgorithm::None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("would-block remote bitrot writer should open before its first write");
|
||||||
|
let would_block = would_block_writer
|
||||||
|
.write(b"wait")
|
||||||
|
.await
|
||||||
|
.expect_err("would-block must remain visible to the caller");
|
||||||
|
assert_eq!(would_block.kind(), io::ErrorKind::WouldBlock);
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
let report = std::fs::read_to_string(&report_path).expect("HotPath I/O report should be written");
|
||||||
|
let report: serde_json::Value = serde_json::from_str(&report).expect("HotPath I/O report should be valid JSON");
|
||||||
|
let entries = report["io"]["data"]
|
||||||
|
.as_array()
|
||||||
|
.expect("HotPath I/O report should include data rows");
|
||||||
|
let io_bytes = |label: &str, direction: &str| {
|
||||||
|
let byte_count: u64 = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["label"].as_str().is_some_and(|entry_label| entry_label == label))
|
||||||
|
.filter_map(|entry| entry[direction]["bytes"].as_u64())
|
||||||
|
.sum();
|
||||||
|
assert!(byte_count > 0, "report must include fixed label {label}");
|
||||||
|
byte_count
|
||||||
|
};
|
||||||
|
let io_errors = |label: &str, direction: &str| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["label"].as_str().is_some_and(|entry_label| entry_label == label))
|
||||||
|
.filter_map(|entry| entry[direction]["errors"].as_u64())
|
||||||
|
.sum::<u64>()
|
||||||
|
};
|
||||||
|
let payload_len = u64::try_from(payload.len()).expect("test payload length should fit u64");
|
||||||
|
assert_eq!(
|
||||||
|
io_bytes(RAW_SHARD_READ_LOCAL_LABEL, "read"),
|
||||||
|
payload_len + u64::try_from(fallback_payload.len()).expect("fallback payload length should fit u64")
|
||||||
|
);
|
||||||
|
assert_eq!(io_bytes(RAW_SHARD_WRITE_LOCAL_LABEL, "write"), payload_len);
|
||||||
|
assert_eq!(
|
||||||
|
io_bytes(RAW_SHARD_READ_REMOTE_LABEL, "read"),
|
||||||
|
u64::try_from(remote_payload.len()).expect("remote payload length should fit u64")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io_bytes(RAW_SHARD_WRITE_REMOTE_LABEL, "write"),
|
||||||
|
u64::try_from(remote_payload.len()).expect("remote payload length should fit u64")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io_errors(RAW_SHARD_WRITE_REMOTE_LABEL, "write"),
|
||||||
|
1,
|
||||||
|
"the wrapper must record the real remote writer failure without changing it, while excluding WouldBlock"
|
||||||
|
);
|
||||||
|
for label in [
|
||||||
|
RAW_SHARD_READ_LOCAL_LABEL,
|
||||||
|
RAW_SHARD_READ_REMOTE_LABEL,
|
||||||
|
RAW_SHARD_WRITE_LOCAL_LABEL,
|
||||||
|
RAW_SHARD_WRITE_REMOTE_LABEL,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!label.contains(['/', ':', '?', '@']),
|
||||||
|
"raw shard I/O labels must not carry a path, host, query, or credential delimiter: {label}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_mmap_read_max_length_defaults_and_env_override() {
|
fn object_mmap_read_max_length_defaults_and_env_override() {
|
||||||
temp_env::with_var(ENV_OBJECT_MMAP_READ_MAX_LENGTH, None::<&str>, || {
|
temp_env::with_var(ENV_OBJECT_MMAP_READ_MAX_LENGTH, None::<&str>, || {
|
||||||
@@ -741,25 +1194,9 @@ mod tests {
|
|||||||
// be materialized in memory by the mmap-copy path; over-cap reads stream.
|
// be materialized in memory by the mmap-copy path; over-cap reads stream.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_disk_reader_streams_when_length_exceeds_mmap_cap() {
|
async fn open_disk_reader_streams_when_length_exceeds_mmap_cap() {
|
||||||
use crate::disk::endpoint::Endpoint;
|
|
||||||
use crate::disk::{DiskOption, new_disk};
|
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
let (disk, _dir) = local_test_disk().await;
|
||||||
let mut endpoint =
|
|
||||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(0);
|
|
||||||
let disk = new_disk(
|
|
||||||
&endpoint,
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("local disk should be created");
|
|
||||||
|
|
||||||
let payload = vec![7u8; 4096];
|
let payload = vec![7u8; 4096];
|
||||||
disk.make_volume("test-bucket").await.expect("volume should be created");
|
disk.make_volume("test-bucket").await.expect("volume should be created");
|
||||||
@@ -814,6 +1251,17 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disk_bitrot_reader_and_writer_preserve_full_shard_body() {
|
||||||
|
let (disk, _dir) = local_test_disk().await;
|
||||||
|
let bucket = "test-bucket";
|
||||||
|
let path = "obj/wrapped-part.1";
|
||||||
|
let payload = b"wrapped shard body";
|
||||||
|
let actual = round_trip_disk_bitrot(&disk, bucket, path, payload, 4).await;
|
||||||
|
|
||||||
|
assert_eq!(actual, payload, "raw shard I/O instrumentation must not alter stored bytes");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_bitrot_reader_with_inline_data() {
|
async fn test_create_bitrot_reader_with_inline_data() {
|
||||||
let test_data = b"hello world test data";
|
let test_data = b"hello world test data";
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use rustfs_config::{
|
|||||||
ENV_STARTUP_TOPOLOGY_WAIT_MODE, ENV_STARTUP_TOPOLOGY_WAIT_TIMEOUT, ENV_UNSAFE_BYPASS_DISK_CHECK,
|
ENV_STARTUP_TOPOLOGY_WAIT_MODE, ENV_STARTUP_TOPOLOGY_WAIT_TIMEOUT, ENV_UNSAFE_BYPASS_DISK_CHECK,
|
||||||
};
|
};
|
||||||
use rustfs_utils::{XHost, check_local_server_addr, get_env_opt_str, get_host_ip, is_local_host};
|
use rustfs_utils::{XHost, check_local_server_addr, get_env_opt_str, get_host_ip, is_local_host};
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map::Entry},
|
collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map::Entry},
|
||||||
future::Future,
|
future::Future,
|
||||||
@@ -598,6 +600,58 @@ const DNS_RETRY_JITTER_PERCENT: u64 = 20;
|
|||||||
/// wait does not flood the log with one line per backoff tick.
|
/// wait does not flood the log with one line per backoff tick.
|
||||||
const TOPOLOGY_WARN_THROTTLE: Duration = Duration::from_secs(30);
|
const TOPOLOGY_WARN_THROTTLE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
static FORCED_LOCAL_HOST_RESOLUTION_TIMEOUTS: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
struct LocalHostResolutionTimeoutGuard {
|
||||||
|
hosts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Drop for LocalHostResolutionTimeoutGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut forced_hosts = FORCED_LOCAL_HOST_RESOLUTION_TIMEOUTS
|
||||||
|
.lock()
|
||||||
|
.expect("local-host test resolver mutex poisoned");
|
||||||
|
for host in &self.hosts {
|
||||||
|
forced_hosts.remove(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn force_local_host_resolution_timeout_for_test(hosts: &[&str]) -> LocalHostResolutionTimeoutGuard {
|
||||||
|
let hosts = hosts.iter().map(|host| (*host).to_string()).collect::<Vec<_>>();
|
||||||
|
let mut forced_hosts = FORCED_LOCAL_HOST_RESOLUTION_TIMEOUTS
|
||||||
|
.lock()
|
||||||
|
.expect("local-host test resolver mutex poisoned");
|
||||||
|
forced_hosts.extend(hosts.iter().cloned());
|
||||||
|
LocalHostResolutionTimeoutGuard { hosts }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn local_host_resolution_timeout_forced(host: &Host<&str>) -> bool {
|
||||||
|
let host = match host {
|
||||||
|
Host::Domain(domain) => (*domain).to_string(),
|
||||||
|
Host::Ipv4(ip) => ip.to_string(),
|
||||||
|
Host::Ipv6(ip) => ip.to_string(),
|
||||||
|
};
|
||||||
|
FORCED_LOCAL_HOST_RESOLUTION_TIMEOUTS
|
||||||
|
.lock()
|
||||||
|
.expect("local-host test resolver mutex poisoned")
|
||||||
|
.contains(&host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_is_local_host(host: Host<&str>, port: u16, local_port: u16) -> Result<bool> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if local_host_resolution_timeout_forced(&host) {
|
||||||
|
return Err(Error::new(ErrorKind::TimedOut, "resolver timeout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
is_local_host(host, port, local_port)
|
||||||
|
}
|
||||||
|
|
||||||
struct DnsRetryDeadline {
|
struct DnsRetryDeadline {
|
||||||
started: Instant,
|
started: Instant,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -694,7 +748,7 @@ async fn resolve_local_host_with_retry(
|
|||||||
retry_dns_operation(
|
retry_dns_operation(
|
||||||
|| {
|
|| {
|
||||||
let host = host.clone();
|
let host = host.clone();
|
||||||
async move { is_local_host(host, port, local_port) }
|
async move { endpoint_is_local_host(host, port, local_port) }
|
||||||
},
|
},
|
||||||
async_sleep,
|
async_sleep,
|
||||||
dns_retry_deadline,
|
dns_retry_deadline,
|
||||||
@@ -2231,6 +2285,9 @@ mod test {
|
|||||||
#[serial]
|
#[serial]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_server_endpoints_bounds_kubernetes_alias_dns_fallback() {
|
async fn create_server_endpoints_bounds_kubernetes_alias_dns_fallback() {
|
||||||
|
let _resolution_timeout =
|
||||||
|
force_local_host_resolution_timeout_for_test(&["unrelated-0.example.invalid", "unrelated-1.example.invalid"]);
|
||||||
|
|
||||||
async_with_vars(
|
async_with_vars(
|
||||||
[
|
[
|
||||||
(ENV_LOCAL_ENDPOINT_HOST, None),
|
(ENV_LOCAL_ENDPOINT_HOST, None),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
use crate::bucket::metadata_sys::get_versioning_config;
|
use crate::bucket::metadata_sys::get_versioning_config;
|
||||||
use crate::bucket::replication::{
|
use crate::bucket::replication::{
|
||||||
ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, replication_status_from_filemeta,
|
DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
|
||||||
replication_statuses_map, version_purge_status_from_filemeta, version_purge_statuses_map,
|
replication_status_from_filemeta, replication_statuses_map, version_purge_status_from_filemeta, version_purge_statuses_map,
|
||||||
};
|
};
|
||||||
use crate::bucket::versioning::VersioningApi as _;
|
use crate::bucket::versioning::VersioningApi as _;
|
||||||
use crate::config::storageclass;
|
use crate::config::storageclass;
|
||||||
|
|||||||
@@ -20,6 +20,47 @@ use crate::storage_api_contracts::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct DeleteLockFence {
|
||||||
|
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
forced_lost: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for DeleteLockFence {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("DeleteLockFence")
|
||||||
|
.field("signal_count", &self.signals.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteLockFence {
|
||||||
|
pub(crate) fn new(signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Self {
|
||||||
|
Self {
|
||||||
|
signals: Arc::new(signals),
|
||||||
|
#[cfg(test)]
|
||||||
|
forced_lost: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.forced_lost {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.signals.iter().any(|signal| signal.is_lost())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn lost_for_test() -> Self {
|
||||||
|
Self {
|
||||||
|
signals: Arc::default(),
|
||||||
|
forced_lost: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct ObjectOptions {
|
pub struct ObjectOptions {
|
||||||
// Use the maximum parity (N/2), used when saving server configuration files
|
// Use the maximum parity (N/2), used when saving server configuration files
|
||||||
@@ -54,8 +95,11 @@ pub struct ObjectOptions {
|
|||||||
pub http_preconditions: Option<HTTPPreconditions>,
|
pub http_preconditions: Option<HTTPPreconditions>,
|
||||||
|
|
||||||
pub delete_replication: Option<ReplicationState>,
|
pub delete_replication: Option<ReplicationState>,
|
||||||
|
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||||
|
pub delete_lock_fence: Option<DeleteLockFence>,
|
||||||
pub replication_request: bool,
|
pub replication_request: bool,
|
||||||
pub delete_marker: bool,
|
pub delete_marker: bool,
|
||||||
|
pub synthetic_version_id: bool,
|
||||||
|
|
||||||
pub transition: TransitionOptions,
|
pub transition: TransitionOptions,
|
||||||
pub expiration: ExpirationOptions,
|
pub expiration: ExpirationOptions,
|
||||||
@@ -68,6 +112,9 @@ pub struct ObjectOptions {
|
|||||||
pub want_checksum: Option<Checksum>,
|
pub want_checksum: Option<Checksum>,
|
||||||
pub skip_verify_bitrot: bool,
|
pub skip_verify_bitrot: bool,
|
||||||
pub capacity_scope_token: Option<Uuid>,
|
pub capacity_scope_token: Option<Uuid>,
|
||||||
|
/// Storage-owned journal writer used by the atomic delete path. This is
|
||||||
|
/// populated only by the `ECStore` wrapper that holds the namespace locks.
|
||||||
|
pub tier_delete_journal_api: Option<Arc<crate::store::ECStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectOptions {
|
impl ObjectOptions {
|
||||||
|
|||||||
@@ -337,6 +337,15 @@ pub struct NotificationPeerErr {
|
|||||||
pub err: Option<Error>,
|
pub err: Option<Error>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One peer's answer to a KMS configuration fingerprint probe.
|
||||||
|
pub struct PeerKmsConfigFingerprint {
|
||||||
|
pub host: String,
|
||||||
|
/// `None` when the peer has no KMS configuration of its own, or could not
|
||||||
|
/// be asked at all, in which case `err` carries the reason.
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
pub err: Option<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
fn notification_peer_result<T>(host: String, result: Result<T>) -> NotificationPeerErr {
|
fn notification_peer_result<T>(host: String, result: Result<T>) -> NotificationPeerErr {
|
||||||
NotificationPeerErr { host, err: result.err() }
|
NotificationPeerErr { host, err: result.err() }
|
||||||
}
|
}
|
||||||
@@ -518,6 +527,47 @@ impl NotificationSys {
|
|||||||
self.signal_dynamic_config(sub_sys, false).await
|
self.signal_dynamic_config(sub_sys, false).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask every peer to re-read the cluster-persisted KMS configuration.
|
||||||
|
///
|
||||||
|
/// Best-effort by contract: the caller has already switched locally, so a
|
||||||
|
/// peer that fails is reported rather than rolled back. Peers built before
|
||||||
|
/// the KMS subsystem existed reject the signal with an explicit error.
|
||||||
|
pub async fn reload_kms_config(&self) -> Vec<NotificationPeerErr> {
|
||||||
|
self.reload_dynamic_config(crate::cluster::rpc::KMS_SIGNAL_SUBSYSTEM).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the KMS configuration fingerprint each peer is running.
|
||||||
|
///
|
||||||
|
/// A peer whose build predates the KMS subsystem rejects the probe, so it
|
||||||
|
/// is reported as an error rather than silently agreeing with this node.
|
||||||
|
pub async fn kms_config_fingerprints(&self) -> Vec<PeerKmsConfigFingerprint> {
|
||||||
|
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
||||||
|
for client in self.peer_clients.iter() {
|
||||||
|
futures.push(async move {
|
||||||
|
let Some(client) = client else {
|
||||||
|
return PeerKmsConfigFingerprint {
|
||||||
|
host: String::new(),
|
||||||
|
fingerprint: None,
|
||||||
|
err: Some(Error::other("peer is not reachable")),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
match client.kms_config_fingerprint().await {
|
||||||
|
Ok(fingerprint) => PeerKmsConfigFingerprint {
|
||||||
|
host: client.host.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
err: None,
|
||||||
|
},
|
||||||
|
Err(e) => PeerKmsConfigFingerprint {
|
||||||
|
host: client.host.to_string(),
|
||||||
|
fingerprint: None,
|
||||||
|
err: Some(e),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
join_all(futures).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn refresh_config_snapshot(&self) -> Vec<NotificationPeerErr> {
|
pub async fn refresh_config_snapshot(&self) -> Vec<NotificationPeerErr> {
|
||||||
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
||||||
for client in self.peer_clients.iter() {
|
for client in self.peer_clients.iter() {
|
||||||
|
|||||||
@@ -9383,6 +9383,8 @@ mod tests {
|
|||||||
backend_identity: Some(current_identity),
|
backend_identity: Some(current_identity),
|
||||||
version_id_exact: true,
|
version_id_exact: true,
|
||||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||||
|
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
journal_store
|
journal_store
|
||||||
.insert_config_object(
|
.insert_config_object(
|
||||||
|
|||||||
@@ -4358,8 +4358,15 @@ async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<ru
|
|||||||
let runtime_state = disk.runtime_state();
|
let runtime_state = disk.runtime_state();
|
||||||
let offline_duration_seconds = disk.offline_duration_secs();
|
let offline_duration_seconds = disk.offline_duration_secs();
|
||||||
let capacity_snapshot = disk.last_capacity_snapshot();
|
let capacity_snapshot = disk.last_capacity_snapshot();
|
||||||
|
let cached_disk_id = disk.cached_disk_id().await;
|
||||||
if runtime_state.should_probe_for_admin() || runtime_state == disk::health_state::RuntimeDriveHealthState::Suspect {
|
if runtime_state.should_probe_for_admin() || runtime_state == disk::health_state::RuntimeDriveHealthState::Suspect {
|
||||||
match disk.disk_info(&DiskInfoOptions::default()).await {
|
match disk
|
||||||
|
.disk_info(&DiskInfoOptions {
|
||||||
|
metrics: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
disk.record_capacity_probe(res.total, res.used, res.free);
|
disk.record_capacity_probe(res.total, res.used, res.free);
|
||||||
ret.push(rustfs_madmin::Disk {
|
ret.push(rustfs_madmin::Disk {
|
||||||
@@ -4390,6 +4397,7 @@ async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<ru
|
|||||||
utilization: utilization_percent(res.total, res.used),
|
utilization: utilization_percent(res.total, res.used),
|
||||||
used_inodes: res.used_inodes,
|
used_inodes: res.used_inodes,
|
||||||
free_inodes: res.free_inodes,
|
free_inodes: res.free_inodes,
|
||||||
|
metrics: Some(res.metrics),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4397,12 +4405,15 @@ async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<ru
|
|||||||
let mut disk_info = rustfs_madmin::Disk {
|
let mut disk_info = rustfs_madmin::Disk {
|
||||||
state: err.to_string(),
|
state: err.to_string(),
|
||||||
endpoint: eps[i].to_string(),
|
endpoint: eps[i].to_string(),
|
||||||
|
drive_path: eps[i].get_file_path(),
|
||||||
local: eps[i].is_local,
|
local: eps[i].is_local,
|
||||||
pool_index: eps[i].pool_idx,
|
pool_index: eps[i].pool_idx,
|
||||||
set_index: eps[i].set_idx,
|
set_index: eps[i].set_idx,
|
||||||
disk_index: eps[i].disk_idx,
|
disk_index: eps[i].disk_idx,
|
||||||
runtime_state: Some(runtime_state.as_str().to_string()),
|
runtime_state: Some(runtime_state.as_str().to_string()),
|
||||||
offline_duration_seconds,
|
offline_duration_seconds,
|
||||||
|
metrics: disk.metrics_snapshot(),
|
||||||
|
uuid: cached_disk_id.map_or_else(String::new, |id| id.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
if let Some((total, used, free, _)) = capacity_snapshot {
|
if let Some((total, used, free, _)) = capacity_snapshot {
|
||||||
@@ -4421,16 +4432,16 @@ async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<ru
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ret.push(build_runtime_snapshot_disk(
|
let mut disk_info =
|
||||||
&eps[i],
|
build_runtime_snapshot_disk(&eps[i], runtime_state, offline_duration_seconds, capacity_snapshot);
|
||||||
runtime_state,
|
disk_info.metrics = disk.metrics_snapshot();
|
||||||
offline_duration_seconds,
|
disk_info.uuid = cached_disk_id.map_or_else(String::new, |id| id.to_string());
|
||||||
capacity_snapshot,
|
ret.push(disk_info);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ret.push(rustfs_madmin::Disk {
|
ret.push(rustfs_madmin::Disk {
|
||||||
endpoint: eps[i].to_string(),
|
endpoint: eps[i].to_string(),
|
||||||
|
drive_path: eps[i].get_file_path(),
|
||||||
local: eps[i].is_local,
|
local: eps[i].is_local,
|
||||||
pool_index: eps[i].pool_idx,
|
pool_index: eps[i].pool_idx,
|
||||||
set_index: eps[i].set_idx,
|
set_index: eps[i].set_idx,
|
||||||
@@ -4456,6 +4467,7 @@ fn build_runtime_snapshot_disk(
|
|||||||
) -> rustfs_madmin::Disk {
|
) -> rustfs_madmin::Disk {
|
||||||
let mut disk = rustfs_madmin::Disk {
|
let mut disk = rustfs_madmin::Disk {
|
||||||
endpoint: endpoint.to_string(),
|
endpoint: endpoint.to_string(),
|
||||||
|
drive_path: endpoint.get_file_path(),
|
||||||
local: endpoint.is_local,
|
local: endpoint.is_local,
|
||||||
pool_index: endpoint.pool_idx,
|
pool_index: endpoint.pool_idx,
|
||||||
set_index: endpoint.set_idx,
|
set_index: endpoint.set_idx,
|
||||||
@@ -4698,6 +4710,7 @@ pub fn is_infrequent_access_class(storage_class: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::bucket::replication::{replication_statuses_map, version_purge_statuses_map};
|
use crate::bucket::replication::{replication_statuses_map, version_purge_statuses_map};
|
||||||
|
use crate::cluster::rpc::{RemoteDisk, TcpHttpInternodeDataTransport};
|
||||||
use crate::disk::CHECK_PART_UNKNOWN;
|
use crate::disk::CHECK_PART_UNKNOWN;
|
||||||
use crate::disk::CHECK_PART_VOLUME_NOT_FOUND;
|
use crate::disk::CHECK_PART_VOLUME_NOT_FOUND;
|
||||||
use crate::disk::DataDirDeleteStatus;
|
use crate::disk::DataDirDeleteStatus;
|
||||||
@@ -4714,6 +4727,7 @@ mod tests {
|
|||||||
use crate::layout::endpoints::SetupType;
|
use crate::layout::endpoints::SetupType;
|
||||||
use crate::object_api::BLOCK_SIZE_V2;
|
use crate::object_api::BLOCK_SIZE_V2;
|
||||||
use crate::object_api::ObjectInfo;
|
use crate::object_api::ObjectInfo;
|
||||||
|
use crate::set_disk::core::io_primitives::rename_fanout_barrier;
|
||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
|
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
|
||||||
namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _,
|
namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _,
|
||||||
@@ -4988,6 +5002,26 @@ mod tests {
|
|||||||
(dir, endpoint, disk)
|
(dir, endpoint, disk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn make_remote_disk_for_info_test(disk_idx: usize) -> (Endpoint, DiskStore) {
|
||||||
|
let endpoint_url = format!("http://remote-server:9000/data{disk_idx}");
|
||||||
|
let mut endpoint = Endpoint::try_from(endpoint_url.as_str()).expect("remote endpoint should parse");
|
||||||
|
endpoint.set_pool_index(0);
|
||||||
|
endpoint.set_set_index(0);
|
||||||
|
endpoint.set_disk_index(disk_idx);
|
||||||
|
let remote_disk = RemoteDisk::new(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
Arc::new(TcpHttpInternodeDataTransport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("remote disk should be created");
|
||||||
|
|
||||||
|
(endpoint, Arc::new(disk::Disk::Remote(Box::new(remote_disk))))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_rename_data_quorum_failure_rolls_back_destination_object() {
|
async fn test_rename_data_quorum_failure_rolls_back_destination_object() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||||
@@ -7697,6 +7731,13 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("disk 1 should exist")
|
.expect("disk 1 should exist")
|
||||||
.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
|
.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
|
||||||
|
let offline_disk_id = Uuid::new_v4();
|
||||||
|
disks[2]
|
||||||
|
.as_ref()
|
||||||
|
.expect("disk 2 should exist")
|
||||||
|
.set_disk_id_state(Some(offline_disk_id))
|
||||||
|
.await
|
||||||
|
.expect("offline disk id should be cached");
|
||||||
disks[2]
|
disks[2]
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("disk 2 should exist")
|
.expect("disk 2 should exist")
|
||||||
@@ -7708,14 +7749,82 @@ mod tests {
|
|||||||
assert_eq!(info[0].state, "ok");
|
assert_eq!(info[0].state, "ok");
|
||||||
assert_eq!(info[0].runtime_state.as_deref(), Some("online"));
|
assert_eq!(info[0].runtime_state.as_deref(), Some("online"));
|
||||||
assert!(!info[0].drive_path.is_empty(), "online disk should keep immediate disk_info probe");
|
assert!(!info[0].drive_path.is_empty(), "online disk should keep immediate disk_info probe");
|
||||||
|
assert!(
|
||||||
|
info[0]
|
||||||
|
.metrics
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metrics| metrics.api_calls.get("disk_info"))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
> 0,
|
||||||
|
"online disk should expose disk_info operation metrics"
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(info[1].state, "ok");
|
assert_eq!(info[1].state, "ok");
|
||||||
assert_eq!(info[1].runtime_state.as_deref(), Some("suspect"));
|
assert_eq!(info[1].runtime_state.as_deref(), Some("suspect"));
|
||||||
assert!(!info[1].drive_path.is_empty(), "suspect disk should still probe for fresher disk info");
|
assert!(!info[1].drive_path.is_empty(), "suspect disk should still probe for fresher disk info");
|
||||||
|
assert!(
|
||||||
|
info[1]
|
||||||
|
.metrics
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metrics| metrics.last_minute.get("disk_info"))
|
||||||
|
.map(|action| action.count)
|
||||||
|
.unwrap_or_default()
|
||||||
|
> 0,
|
||||||
|
"suspect disk should expose last-minute disk_info latency"
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(info[2].state, "offline");
|
assert_eq!(info[2].state, "offline");
|
||||||
assert_eq!(info[2].runtime_state.as_deref(), Some("offline"));
|
assert_eq!(info[2].runtime_state.as_deref(), Some("offline"));
|
||||||
assert!(info[2].drive_path.is_empty(), "offline disk should use runtime snapshot fallback");
|
assert_eq!(
|
||||||
|
info[2].drive_path,
|
||||||
|
endpoints[2].get_file_path(),
|
||||||
|
"offline disk should keep stable endpoint path"
|
||||||
|
);
|
||||||
|
assert_eq!(info[2].uuid, offline_disk_id.to_string());
|
||||||
|
assert!(
|
||||||
|
info[2].metrics.is_some(),
|
||||||
|
"offline runtime fallback should preserve disk metrics snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_disks_info_preserves_remote_cached_disk_id_when_offline() {
|
||||||
|
let (endpoint, disk) = make_remote_disk_for_info_test(0).await;
|
||||||
|
let remote_disk_id = Uuid::new_v4();
|
||||||
|
disk.set_disk_id_state(Some(remote_disk_id))
|
||||||
|
.await
|
||||||
|
.expect("remote disk id should be cached");
|
||||||
|
disk.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
|
||||||
|
|
||||||
|
let info = get_disks_info(&[Some(disk)], &[endpoint]).await;
|
||||||
|
|
||||||
|
assert_eq!(info.len(), 1);
|
||||||
|
assert_eq!(info[0].state, "offline");
|
||||||
|
assert_eq!(info[0].runtime_state.as_deref(), Some("offline"));
|
||||||
|
assert_eq!(info[0].uuid, remote_disk_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_disks_info_preserves_cached_disk_id_after_failed_live_probe() {
|
||||||
|
let format = FormatV3::new(1, 1);
|
||||||
|
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
|
||||||
|
let cached_disk_id = Uuid::new_v4();
|
||||||
|
disk.set_disk_id_state(Some(cached_disk_id))
|
||||||
|
.await
|
||||||
|
.expect("disk id should be cached before the failed probe");
|
||||||
|
disk.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
|
||||||
|
|
||||||
|
let info = get_disks_info(&[Some(disk)], &[endpoint]).await;
|
||||||
|
|
||||||
|
assert_eq!(info.len(), 1);
|
||||||
|
assert_eq!(info[0].runtime_state.as_deref(), Some("suspect"));
|
||||||
|
assert_eq!(info[0].uuid, cached_disk_id.to_string());
|
||||||
|
assert_eq!(
|
||||||
|
info[0].drive_path,
|
||||||
|
temp_dir.path().to_string_lossy(),
|
||||||
|
"failed live probe should still keep the endpoint path"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -9565,6 +9674,15 @@ mod tests {
|
|||||||
make_local_bucket_test_set_disks_with_drive_count(2).await
|
make_local_bucket_test_set_disks_with_drive_count(2).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_exclusive_object_lock_held(set_disks: &SetDisks, bucket: &str, object: &str) {
|
||||||
|
let lock = set_disks
|
||||||
|
.local_lock_manager_for_test()
|
||||||
|
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||||
|
.expect("object lock should be visible while rename is paused");
|
||||||
|
assert!(matches!(lock.mode, rustfs_lock::LockMode::Exclusive));
|
||||||
|
assert_eq!(lock.owner.as_ref(), set_disks.locker_owner.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
async fn make_local_bucket_test_set_disks_with_drive_count(drive_count: usize) -> Arc<SetDisks> {
|
async fn make_local_bucket_test_set_disks_with_drive_count(drive_count: usize) -> Arc<SetDisks> {
|
||||||
let format = FormatV3::new(1, drive_count);
|
let format = FormatV3::new(1, drive_count);
|
||||||
let mut endpoints = Vec::new();
|
let mut endpoints = Vec::new();
|
||||||
@@ -9599,7 +9717,9 @@ mod tests {
|
|||||||
disks.push(Some(disk));
|
disks.push(Some(disk));
|
||||||
}
|
}
|
||||||
|
|
||||||
let set_disks = SetDisks::new(
|
let instance_ctx = Arc::new(InstanceContext::new());
|
||||||
|
instance_ctx.update_erasure_type(SetupType::Erasure).await;
|
||||||
|
let set_disks = SetDisks::new_with_instance_ctx(
|
||||||
"test-owner".to_string(),
|
"test-owner".to_string(),
|
||||||
Arc::new(RwLock::new(disks)),
|
Arc::new(RwLock::new(disks)),
|
||||||
drive_count,
|
drive_count,
|
||||||
@@ -9609,6 +9729,7 @@ mod tests {
|
|||||||
endpoints,
|
endpoints,
|
||||||
format,
|
format,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
instance_ctx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
set_disks.set_test_storage_class_config(
|
set_disks.set_test_storage_class_config(
|
||||||
@@ -10262,6 +10383,216 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conditional_replace_holds_object_lock_through_rename() {
|
||||||
|
let set_disks = make_local_bucket_test_set_disks().await;
|
||||||
|
let bucket = "bucket-conditional-replace-fence";
|
||||||
|
let object = "config/conditional-replace.json";
|
||||||
|
set_disks
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket should be created");
|
||||||
|
|
||||||
|
let mut initial_reader = PutObjReader::from_vec(b"initial config".to_vec());
|
||||||
|
let initial = set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut initial_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("initial config should be written");
|
||||||
|
let initial_etag = initial.etag.expect("initial config should have an ETag");
|
||||||
|
|
||||||
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let writer_store = set_disks.clone();
|
||||||
|
let expected_etag = initial_etag.clone();
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(b"replacement config".to_vec());
|
||||||
|
writer_store
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
preserve_etag: Some("replacement-etag".to_string()),
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_match: Some(expected_etag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("conditional replace should reach the rename barrier");
|
||||||
|
assert_exclusive_object_lock_held(&set_disks, bucket, object);
|
||||||
|
barrier.release();
|
||||||
|
writer
|
||||||
|
.await
|
||||||
|
.expect("conditional writer task should finish")
|
||||||
|
.expect("matching conditional replace should commit");
|
||||||
|
assert!(
|
||||||
|
set_disks
|
||||||
|
.local_lock_manager_for_test()
|
||||||
|
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||||
|
.is_none(),
|
||||||
|
"conditional replace should release the object lock after commit"
|
||||||
|
);
|
||||||
|
let contender = set_disks
|
||||||
|
.new_ns_lock(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("contender namespace lock should be created");
|
||||||
|
let contender_guard = contender
|
||||||
|
.get_write_lock(std::time::Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("contender should acquire after conditional replace commits");
|
||||||
|
drop(contender_guard);
|
||||||
|
|
||||||
|
let mut stale_reader = PutObjReader::from_vec(b"stale config".to_vec());
|
||||||
|
let err = set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut stale_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_match: Some(initial_etag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("the old ETag must fail after the fenced replacement commits");
|
||||||
|
assert_eq!(err, StorageError::PreconditionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repeated_body_write_keeps_etag_but_changes_data_dir_generation() {
|
||||||
|
let set_disks = make_local_bucket_test_set_disks().await;
|
||||||
|
let bucket = "bucket-write-generation";
|
||||||
|
let object = "config/write-generation.json";
|
||||||
|
let body = b"identical config body".to_vec();
|
||||||
|
set_disks
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket should be created");
|
||||||
|
|
||||||
|
let mut first_reader = PutObjReader::from_vec(body.clone());
|
||||||
|
let first = set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut first_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("first config body should be written");
|
||||||
|
let mut second_reader = PutObjReader::from_vec(body);
|
||||||
|
let second = set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut second_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("identical config body should be rewritten");
|
||||||
|
|
||||||
|
assert_eq!(first.etag, second.etag, "content ETag should expose the ABA collision");
|
||||||
|
assert_ne!(first.data_dir, second.data_dir, "each committed body write needs a unique generation");
|
||||||
|
assert!(first.data_dir.is_some() && second.data_dir.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conditional_create_holds_object_lock_through_rename() {
|
||||||
|
let set_disks = make_local_bucket_test_set_disks().await;
|
||||||
|
let bucket = "bucket-conditional-create-fence";
|
||||||
|
let object = "config/conditional-create.json";
|
||||||
|
set_disks
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket should be created");
|
||||||
|
|
||||||
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let writer_store = set_disks.clone();
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(b"created config".to_vec());
|
||||||
|
writer_store
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("conditional create should reach the rename barrier");
|
||||||
|
assert_exclusive_object_lock_held(&set_disks, bucket, object);
|
||||||
|
barrier.release();
|
||||||
|
writer
|
||||||
|
.await
|
||||||
|
.expect("conditional writer task should finish")
|
||||||
|
.expect("first conditional create should commit");
|
||||||
|
assert!(
|
||||||
|
set_disks
|
||||||
|
.local_lock_manager_for_test()
|
||||||
|
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||||
|
.is_none(),
|
||||||
|
"conditional create should release the object lock after commit"
|
||||||
|
);
|
||||||
|
let contender = set_disks
|
||||||
|
.new_ns_lock(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("contender namespace lock should be created");
|
||||||
|
let contender_guard = contender
|
||||||
|
.get_write_lock(std::time::Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("contender should acquire after conditional create commits");
|
||||||
|
drop(contender_guard);
|
||||||
|
|
||||||
|
let mut duplicate_reader = PutObjReader::from_vec(b"duplicate config".to_vec());
|
||||||
|
let err = set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut duplicate_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a second create-only write must not replace the committed config");
|
||||||
|
assert_eq!(err, StorageError::PreconditionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_level_if_none_match_fails_closed_without_read_quorum() {
|
async fn set_level_if_none_match_fails_closed_without_read_quorum() {
|
||||||
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
|
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user